Parcourir la source

Merge pull request #556 from alvinunreal/binpath

Add binaryPath
Alvin il y a 2 mois
Parent
commit
3419c935ec
44 fichiers modifiés avec 1787 ajouts et 368 suppressions
  1. 18 134
      companion/Cargo.lock
  2. 1 1
      companion/Cargo.toml
  3. BIN
      companion/animations/council.jpg
  4. BIN
      companion/animations/designer.jpg
  5. BIN
      companion/animations/explorer.jpg
  6. BIN
      companion/animations/fixer.jpg
  7. BIN
      companion/animations/intro.jpg
  8. BIN
      companion/animations/librarian.jpg
  9. BIN
      companion/animations/oracle.jpg
  10. BIN
      companion/animations/orchestrator.jpg
  11. BIN
      companion/animations/question.jpg
  12. BIN
      companion/gifs/council.gif
  13. BIN
      companion/gifs/designer.gif
  14. BIN
      companion/gifs/explorer.gif
  15. BIN
      companion/gifs/fixer.gif
  16. BIN
      companion/gifs/intro.gif
  17. BIN
      companion/gifs/librarian.gif
  18. BIN
      companion/gifs/oracle.gif
  19. BIN
      companion/gifs/orchestrator.gif
  20. BIN
      companion/gifs/question.gif
  21. 271 30
      companion/src/app.rs
  22. 175 26
      companion/src/gifs.rs
  23. 41 0
      companion/src/log.rs
  24. 24 5
      companion/src/main.rs
  25. 66 10
      companion/src/singleton.rs
  26. 20 0
      companion/src/state.rs
  27. 61 5
      docs/companion.md
  28. 1 0
      docs/configuration.md
  29. 30 0
      oh-my-opencode-slim.schema.json
  30. 1 0
      package.json
  31. 1 0
      scripts/verify-release-artifact.ts
  32. 23 130
      src/cli/companion.ts
  33. 4 0
      src/cli/providers.ts
  34. 11 0
      src/companion/companion-manifest.json
  35. 35 2
      src/companion/manager.test.ts
  36. 50 18
      src/companion/manager.ts
  37. 269 0
      src/companion/updater.test.ts
  38. 451 0
      src/companion/updater.ts
  39. 5 0
      src/config/loader.ts
  40. 25 0
      src/config/schema.ts
  41. 124 0
      src/hooks/auto-update-checker/index.test.ts
  42. 55 6
      src/hooks/auto-update-checker/index.ts
  43. 3 0
      src/hooks/auto-update-checker/types.ts
  44. 22 1
      src/index.ts

+ 18 - 134
companion/Cargo.lock

@@ -627,12 +627,6 @@ dependencies = [
  "unicode-width",
  "unicode-width",
 ]
 ]
 
 
-[[package]]
-name = "color_quant"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
-
 [[package]]
 [[package]]
 name = "com"
 name = "com"
 version = "0.6.0"
 version = "0.6.0"
@@ -957,20 +951,6 @@ dependencies = [
  "winit",
  "winit",
 ]
 ]
 
 
-[[package]]
-name = "egui_extras"
-version = "0.29.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bf3c1f5cd8dfe2ade470a218696c66cf556fcfd701e7830fa2e9f4428292a2a1"
-dependencies = [
- "ahash",
- "egui",
- "enum-map",
- "image",
- "log",
- "mime_guess2",
-]
-
 [[package]]
 [[package]]
 name = "egui_glow"
 name = "egui_glow"
 version = "0.29.1"
 version = "0.29.1"
@@ -1003,27 +983,6 @@ version = "1.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
 checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
 
 
-[[package]]
-name = "enum-map"
-version = "2.7.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
-dependencies = [
- "enum-map-derive",
- "serde",
-]
-
-[[package]]
-name = "enum-map-derive"
-version = "0.17.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn 2.0.117",
-]
-
 [[package]]
 [[package]]
 name = "enumflags2"
 name = "enumflags2"
 version = "0.7.12"
 version = "0.7.12"
@@ -1304,16 +1263,6 @@ dependencies = [
  "wasip3",
  "wasip3",
 ]
 ]
 
 
-[[package]]
-name = "gif"
-version = "0.14.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
-dependencies = [
- "color_quant",
- "weezl",
-]
-
 [[package]]
 [[package]]
 name = "gl_generator"
 name = "gl_generator"
 version = "0.14.0"
 version = "0.14.0"
@@ -1638,11 +1587,11 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
 dependencies = [
 dependencies = [
  "bytemuck",
  "bytemuck",
  "byteorder-lite",
  "byteorder-lite",
- "color_quant",
- "gif",
  "moxcms",
  "moxcms",
  "num-traits",
  "num-traits",
  "png",
  "png",
+ "zune-core",
+ "zune-jpeg",
 ]
 ]
 
 
 [[package]]
 [[package]]
@@ -1889,24 +1838,6 @@ dependencies = [
  "paste",
  "paste",
 ]
 ]
 
 
-[[package]]
-name = "mime"
-version = "0.3.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
-
-[[package]]
-name = "mime_guess2"
-version = "2.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1706dc14a2e140dec0a7a07109d9a3d5890b81e85bd6c60b906b249a77adf0ca"
-dependencies = [
- "mime",
- "phf",
- "phf_shared",
- "unicase",
-]
-
 [[package]]
 [[package]]
 name = "miniz_oxide"
 name = "miniz_oxide"
 version = "0.8.9"
 version = "0.8.9"
@@ -2324,7 +2255,7 @@ dependencies = [
  "dirs",
  "dirs",
  "eframe",
  "eframe",
  "egui",
  "egui",
- "egui_extras",
+ "image",
  "libc",
  "libc",
  "serde",
  "serde",
  "serde_json",
  "serde_json",
@@ -2413,50 +2344,6 @@ version = "2.3.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
 checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
 
 
-[[package]]
-name = "phf"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
-dependencies = [
- "phf_macros",
- "phf_shared",
-]
-
-[[package]]
-name = "phf_generator"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
-dependencies = [
- "phf_shared",
- "rand",
-]
-
-[[package]]
-name = "phf_macros"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
-dependencies = [
- "phf_generator",
- "phf_shared",
- "proc-macro2",
- "quote",
- "syn 2.0.117",
- "unicase",
-]
-
-[[package]]
-name = "phf_shared"
-version = "0.11.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
-dependencies = [
- "siphasher",
- "unicase",
-]
-
 [[package]]
 [[package]]
 name = "pin-project"
 name = "pin-project"
 version = "1.1.13"
 version = "1.1.13"
@@ -2913,12 +2800,6 @@ version = "0.1.5"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
 checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
 
 
-[[package]]
-name = "siphasher"
-version = "1.0.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
-
 [[package]]
 [[package]]
 name = "slab"
 name = "slab"
 version = "0.4.12"
 version = "0.4.12"
@@ -3263,12 +3144,6 @@ dependencies = [
  "windows-sys 0.61.2",
  "windows-sys 0.61.2",
 ]
 ]
 
 
-[[package]]
-name = "unicase"
-version = "2.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
-
 [[package]]
 [[package]]
 name = "unicode-ident"
 name = "unicode-ident"
 version = "1.0.24"
 version = "1.0.24"
@@ -3611,12 +3486,6 @@ dependencies = [
  "web-sys",
  "web-sys",
 ]
 ]
 
 
-[[package]]
-name = "weezl"
-version = "0.1.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
-
 [[package]]
 [[package]]
 name = "wgpu"
 name = "wgpu"
 version = "22.1.0"
 version = "22.1.0"
@@ -4511,6 +4380,21 @@ version = "1.0.21"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
 checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
 
 
+[[package]]
+name = "zune-core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
+
+[[package]]
+name = "zune-jpeg"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
+dependencies = [
+ "zune-core",
+]
+
 [[package]]
 [[package]]
 name = "zvariant"
 name = "zvariant"
 version = "4.2.0"
 version = "4.2.0"

+ 1 - 1
companion/Cargo.toml

@@ -11,7 +11,7 @@ path = "src/main.rs"
 [dependencies]
 [dependencies]
 eframe = { version = "0.29", default-features = true }
 eframe = { version = "0.29", default-features = true }
 egui = "0.29"
 egui = "0.29"
-egui_extras = { version = "0.29", features = ["image", "gif"] }
+image = { version = "0.25", default-features = false, features = ["jpeg"] }
 serde = { version = "1", features = ["derive"] }
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"
 serde_json = "1"
 dirs = "5"
 dirs = "5"

BIN
companion/animations/council.jpg


BIN
companion/animations/designer.jpg


BIN
companion/animations/explorer.jpg


BIN
companion/animations/fixer.jpg


BIN
companion/animations/intro.jpg


BIN
companion/animations/librarian.jpg


BIN
companion/animations/oracle.jpg


BIN
companion/animations/orchestrator.jpg


BIN
companion/animations/question.jpg


BIN
companion/gifs/council.gif


BIN
companion/gifs/designer.gif


BIN
companion/gifs/explorer.gif


BIN
companion/gifs/fixer.gif


BIN
companion/gifs/intro.gif


BIN
companion/gifs/librarian.gif


BIN
companion/gifs/oracle.gif


BIN
companion/gifs/orchestrator.gif


BIN
companion/gifs/question.gif


+ 271 - 30
companion/src/app.rs

@@ -7,7 +7,7 @@ use std::time::Duration;
 
 
 use eframe::egui;
 use eframe::egui;
 
 
-use crate::gifs::Gifs;
+use crate::gifs::{AnimationFrame, Gifs};
 use crate::niri;
 use crate::niri;
 use crate::screen::primary_size;
 use crate::screen::primary_size;
 use crate::state::{
 use crate::state::{
@@ -47,6 +47,9 @@ struct WindowGeometryKey {
 struct ConfigKey {
 struct ConfigKey {
     position: String,
     position: String,
     size: String,
     size: String,
+    gif_pack: String,
+    loop_style: String,
+    speed_bits: u32,
 }
 }
 
 
 fn grid_cols(n: usize) -> usize {
 fn grid_cols(n: usize) -> usize {
@@ -78,16 +81,65 @@ fn config_key(config: Option<&CompanionConfigState>) -> Option<ConfigKey> {
     config.map(|cfg| ConfigKey {
     config.map(|cfg| ConfigKey {
         position: cfg.position.clone(),
         position: cfg.position.clone(),
         size: cfg.size.clone(),
         size: cfg.size.clone(),
+        gif_pack: normalized_gif_pack(&cfg.gif_pack).to_string(),
+        loop_style: normalized_loop_style(&cfg.loop_style).to_string(),
+        speed_bits: normalized_speed(cfg.speed).to_bits(),
     })
     })
 }
 }
 
 
-fn apply_config(key: Option<&ConfigKey>, position: &mut String, size: &mut f32) {
+fn config_for_owner<'a>(
+    sessions: &'a [SessionInfo],
+    owner_session_id: Option<&str>,
+    global_config: Option<&'a CompanionConfigState>,
+) -> Option<&'a CompanionConfigState> {
+    owner_session_id
+        .and_then(|owner| {
+            sessions
+                .iter()
+                .find(|session| session.session_id == owner)
+                .and_then(|session| session.config.as_ref())
+        })
+        .or(global_config)
+}
+
+fn normalized_gif_pack(pack: &str) -> &str {
+    match pack {
+        "default" => "default",
+        _ => "default",
+    }
+}
+
+fn normalized_loop_style(style: &str) -> &str {
+    match style {
+        "smooth" => "smooth",
+        _ => "classic",
+    }
+}
+
+fn normalized_speed(speed: f32) -> f32 {
+    crate::gifs::normalized_speed(speed)
+}
+
+fn apply_config(
+    key: Option<&ConfigKey>,
+    position: &mut String,
+    size: &mut f32,
+    gif_pack: &mut String,
+    loop_style: &mut String,
+    speed: &mut f32,
+) {
     if let Some(cfg) = key {
     if let Some(cfg) = key {
         *position = cfg.position.clone();
         *position = cfg.position.clone();
         *size = size_from_config(&cfg.size);
         *size = size_from_config(&cfg.size);
+        *gif_pack = cfg.gif_pack.clone();
+        *loop_style = cfg.loop_style.clone();
+        *speed = f32::from_bits(cfg.speed_bits);
     } else {
     } else {
         *position = "bottom-right".to_string();
         *position = "bottom-right".to_string();
         *size = DEFAULT_SIZE;
         *size = DEFAULT_SIZE;
+        *gif_pack = "default".to_string();
+        *loop_style = "classic".to_string();
+        *speed = normalized_speed(f32::NAN);
     }
     }
 }
 }
 
 
@@ -127,6 +179,23 @@ fn restore_window_position(pos: [f32; 2], screen: [f32; 2], win: [f32; 2]) -> [f
     }
     }
 }
 }
 
 
+fn stack_window_position(
+    position: [f32; 2],
+    anchor: &str,
+    rank: usize,
+    screen: [f32; 2],
+    win: [f32; 2],
+) -> [f32; 2] {
+    let offset = (rank.min(8) as f32) * 18.0;
+    let stacked = match anchor {
+        "bottom-left" => [position[0] + offset, position[1] - offset],
+        "top-right" => [position[0] - offset, position[1] + offset],
+        "top-left" => [position[0] + offset, position[1] + offset],
+        _ => [position[0] - offset, position[1] - offset],
+    };
+    clamp_window_position(stacked, screen, win)
+}
+
 fn canonical_project_key(cwd: &str) -> String {
 fn canonical_project_key(cwd: &str) -> String {
     std::path::Path::new(cwd)
     std::path::Path::new(cwd)
         .canonicalize()
         .canonicalize()
@@ -167,12 +236,14 @@ fn choose_session(sessions: &[SessionInfo]) -> Option<usize> {
     sessions
     sessions
         .iter()
         .iter()
         .enumerate()
         .enumerate()
+        .rev()
         .find(|(_, s)| s.status == "waiting-input")
         .find(|(_, s)| s.status == "waiting-input")
         .map(|(i, _)| i)
         .map(|(i, _)| i)
         .or_else(|| {
         .or_else(|| {
             sessions
             sessions
                 .iter()
                 .iter()
                 .enumerate()
                 .enumerate()
+                .rev()
                 .find(|(_, s)| s.active_agents.iter().any(|agent| agent != "intro"))
                 .find(|(_, s)| s.active_agents.iter().any(|agent| agent != "intro"))
                 .map(|(i, _)| i)
                 .map(|(i, _)| i)
         })
         })
@@ -180,24 +251,40 @@ fn choose_session(sessions: &[SessionInfo]) -> Option<usize> {
             sessions
             sessions
                 .iter()
                 .iter()
                 .enumerate()
                 .enumerate()
+                .rev()
                 .find(|(_, s)| s.status == "busy")
                 .find(|(_, s)| s.status == "busy")
                 .map(|(i, _)| i)
                 .map(|(i, _)| i)
         })
         })
-        .or_else(|| sessions.first().map(|_| 0))
+        .or_else(|| sessions.last().map(|_| sessions.len() - 1))
+}
+
+fn choose_owned_session(sessions: &[SessionInfo], owner_session_id: Option<&str>) -> Option<usize> {
+    if let Some(owner_session_id) = owner_session_id {
+        return sessions
+            .iter()
+            .position(|session| session.session_id == owner_session_id);
+    }
+
+    choose_session(sessions)
 }
 }
 
 
 pub struct CompanionApp {
 pub struct CompanionApp {
     state_path: std::path::PathBuf,
     state_path: std::path::PathBuf,
+    owner_session_id: Option<String>,
     sessions: Vec<SessionInfo>,
     sessions: Vec<SessionInfo>,
     gifs: Gifs,
     gifs: Gifs,
     rx: Receiver<()>,
     rx: Receiver<()>,
     registered: bool,
     registered: bool,
     size: f32,
     size: f32,
+    gif_pack: String,
+    loop_style: String,
+    speed: f32,
     screen: [f32; 2],
     screen: [f32; 2],
     position: String,
     position: String,
     has_modern_config: bool,
     has_modern_config: bool,
     applied_config: Option<ConfigKey>,
     applied_config: Option<ConfigKey>,
     applied_geometry: Option<WindowGeometryKey>,
     applied_geometry: Option<WindowGeometryKey>,
+    last_logged_selection: Option<String>,
     window_positions: std::collections::BTreeMap<String, WindowPositionState>,
     window_positions: std::collections::BTreeMap<String, WindowPositionState>,
     project_keys: std::collections::BTreeMap<String, String>,
     project_keys: std::collections::BTreeMap<String, String>,
     drag_project_key: Option<String>,
     drag_project_key: Option<String>,
@@ -207,30 +294,57 @@ pub struct CompanionApp {
 impl CompanionApp {
 impl CompanionApp {
     pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
     pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
         let state_path = crate::state::state_file_path();
         let state_path = crate::state::state_file_path();
+        let owner_session_id = std::env::var("OH_MY_OPENCODE_SLIM_COMPANION_SESSION_ID")
+            .ok()
+            .filter(|session_id| !session_id.trim().is_empty());
         let state = read_state(&state_path);
         let state = read_state(&state_path);
+        crate::log::debug(format!(
+            "app new owner={:?} initial_sessions={}",
+            owner_session_id,
+            state.sessions.len()
+        ));
         let sessions = state.sessions;
         let sessions = state.sessions;
         let window_positions = state.window_positions;
         let window_positions = state.window_positions;
 
 
         let mut initial_size = DEFAULT_SIZE;
         let mut initial_size = DEFAULT_SIZE;
         let mut position = "bottom-right".to_string();
         let mut position = "bottom-right".to_string();
+        let mut gif_pack = "default".to_string();
+        let mut loop_style = "classic".to_string();
+        let mut speed = normalized_speed(f32::NAN);
         let has_modern_config = state.config.is_some();
         let has_modern_config = state.config.is_some();
-        let applied_config = config_key(state.config.as_ref());
-        apply_config(applied_config.as_ref(), &mut position, &mut initial_size);
+        let applied_config = config_key(config_for_owner(
+            &sessions,
+            owner_session_id.as_deref(),
+            state.config.as_ref(),
+        ));
+        apply_config(
+            applied_config.as_ref(),
+            &mut position,
+            &mut initial_size,
+            &mut gif_pack,
+            &mut loop_style,
+            &mut speed,
+        );
 
 
         let rx = start_watcher(state_path.clone());
         let rx = start_watcher(state_path.clone());
 
 
         Self {
         Self {
             state_path,
             state_path,
+            owner_session_id,
             sessions,
             sessions,
             gifs: Gifs::new(),
             gifs: Gifs::new(),
             rx,
             rx,
             registered: false,
             registered: false,
             size: initial_size,
             size: initial_size,
+            gif_pack,
+            loop_style,
+            speed,
             screen: primary_size(),
             screen: primary_size(),
             position,
             position,
             has_modern_config,
             has_modern_config,
             applied_config,
             applied_config,
             applied_geometry: None,
             applied_geometry: None,
+            last_logged_selection: None,
             window_positions,
             window_positions,
             project_keys: std::collections::BTreeMap::new(),
             project_keys: std::collections::BTreeMap::new(),
             drag_project_key: None,
             drag_project_key: None,
@@ -243,14 +357,33 @@ impl CompanionApp {
             while self.rx.try_recv().is_ok() {}
             while self.rx.try_recv().is_ok() {}
             let state = read_state(&self.state_path);
             let state = read_state(&self.state_path);
             self.sessions = state.sessions;
             self.sessions = state.sessions;
+            let owned_config = config_for_owner(
+                &self.sessions,
+                self.owner_session_id.as_deref(),
+                state.config.as_ref(),
+            );
+            crate::log::debug(format!(
+                "state update owner={:?} sessions={} global_config={:?} owned_config={:?}",
+                self.owner_session_id,
+                self.sessions.len(),
+                state.config,
+                owned_config
+            ));
             self.window_positions = state.window_positions;
             self.window_positions = state.window_positions;
             self.project_keys
             self.project_keys
                 .retain(|cwd, _| self.sessions.iter().any(|session| &session.cwd == cwd));
                 .retain(|cwd, _| self.sessions.iter().any(|session| &session.cwd == cwd));
             self.has_modern_config = state.config.is_some();
             self.has_modern_config = state.config.is_some();
-            let next_config = config_key(state.config.as_ref());
+            let next_config = config_key(owned_config);
             let config_changed = self.applied_config != next_config;
             let config_changed = self.applied_config != next_config;
             if config_changed {
             if config_changed {
-                apply_config(next_config.as_ref(), &mut self.position, &mut self.size);
+                apply_config(
+                    next_config.as_ref(),
+                    &mut self.position,
+                    &mut self.size,
+                    &mut self.gif_pack,
+                    &mut self.loop_style,
+                    &mut self.speed,
+                );
                 self.applied_config = next_config;
                 self.applied_config = next_config;
             }
             }
             return config_changed;
             return config_changed;
@@ -295,7 +428,6 @@ impl eframe::App for CompanionApp {
         }
         }
 
 
         if !self.registered {
         if !self.registered {
-            self.gifs.register(ctx);
             ctx.data_mut(|d| d.insert_temp(egui::Id::new(SIZE_KEY), self.size));
             ctx.data_mut(|d| d.insert_temp(egui::Id::new(SIZE_KEY), self.size));
             self.registered = true;
             self.registered = true;
         } else if config_changed {
         } else if config_changed {
@@ -306,9 +438,20 @@ impl eframe::App for CompanionApp {
 
 
         self.size = ctx.data(|d| d.get_temp(egui::Id::new(SIZE_KEY)).unwrap_or(self.size));
         self.size = ctx.data(|d| d.get_temp(egui::Id::new(SIZE_KEY)).unwrap_or(self.size));
 
 
-        let Some(selected_idx) = choose_session(&self.sessions) else {
+        let Some(selected_idx) =
+            choose_owned_session(&self.sessions, self.owner_session_id.as_deref())
+        else {
+            if self.owner_session_id.is_some() {
+                crate::log::debug(format!(
+                    "close owner session missing owner={:?} sessions={}",
+                    self.owner_session_id,
+                    self.sessions.len()
+                ));
+                ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+                return;
+            }
             egui::CentralPanel::default()
             egui::CentralPanel::default()
-                .frame(egui::Frame::none().fill(egui::Color32::TRANSPARENT))
+                .frame(egui::Frame::none().fill(egui::Color32::BLACK))
                 .show(ctx, |ui| {
                 .show(ctx, |ui| {
                     ui.centered_and_justified(|ui| {
                     ui.centered_and_justified(|ui| {
                         ui.label("No active sessions");
                         ui.label("No active sessions");
@@ -319,18 +462,54 @@ impl eframe::App for CompanionApp {
         };
         };
 
 
         let session = self.sessions[selected_idx].clone();
         let session = self.sessions[selected_idx].clone();
+        let selection_log_key = format!(
+            "{}|{}|{}|{:?}",
+            session.session_id, session.cwd, session.status, session.active_agents
+        );
+        if self.last_logged_selection.as_ref() != Some(&selection_log_key) {
+            crate::log::debug(format!(
+                "selected owner={:?} idx={} session_id={} cwd={} status={} agents={:?}",
+                self.owner_session_id,
+                selected_idx,
+                session.session_id,
+                session.cwd,
+                session.status,
+                session.active_agents
+            ));
+            self.last_logged_selection = Some(selection_log_key);
+        }
         let project_key = self.project_key_for(&session.cwd);
         let project_key = self.project_key_for(&session.cwd);
         let saved_position = self.window_positions.get(&project_key).copied();
         let saved_position = self.window_positions.get(&project_key).copied();
-        let agent_uris: Vec<String> = if session.active_agents.is_empty() {
-            vec![self.gifs.uri("intro")]
+        let time_seconds = ctx.input(|input| input.time);
+        let agent_frames: Vec<AnimationFrame> = if session.active_agents.is_empty() {
+            self.gifs
+                .frame(
+                    ctx,
+                    "intro",
+                    &self.gif_pack,
+                    self.speed,
+                    &self.loop_style,
+                    time_seconds,
+                )
+                .into_iter()
+                .collect()
         } else {
         } else {
             session
             session
                 .active_agents
                 .active_agents
                 .iter()
                 .iter()
-                .map(|agent| self.gifs.uri(agent))
+                .filter_map(|agent| {
+                    self.gifs.frame(
+                        ctx,
+                        agent,
+                        &self.gif_pack,
+                        self.speed,
+                        &self.loop_style,
+                        time_seconds,
+                    )
+                })
                 .collect()
                 .collect()
         };
         };
-        let n = agent_uris.len().max(1);
+        let n = agent_frames.len().max(1);
         let (cols, rows) = grid_dims(n);
         let (cols, rows) = grid_dims(n);
         let [win_w, win_h] = window_size(self.size, cols, rows);
         let [win_w, win_h] = window_size(self.size, cols, rows);
 
 
@@ -350,7 +529,26 @@ impl eframe::App for CompanionApp {
             ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(win_w, win_h)));
             ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(win_w, win_h)));
             let pos = saved_position
             let pos = saved_position
                 .map(|pos| restore_window_position([pos.x, pos.y], self.screen, [win_w, win_h]))
                 .map(|pos| restore_window_position([pos.x, pos.y], self.screen, [win_w, win_h]))
-                .unwrap_or_else(|| place_window(&self.position, self.screen, [win_w, win_h]));
+                .unwrap_or_else(|| {
+                    stack_window_position(
+                        place_window(&self.position, self.screen, [win_w, win_h]),
+                        &self.position,
+                        selected_idx,
+                        self.screen,
+                        [win_w, win_h],
+                    )
+                });
+            crate::log::debug(format!(
+                "geometry owner={:?} session_id={} saved_position={:?} pos={:?} win=({}, {}) screen={:?} selected_idx={}",
+                self.owner_session_id,
+                session.session_id,
+                saved_position,
+                pos,
+                win_w,
+                win_h,
+                self.screen,
+                selected_idx
+            ));
             ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(
             ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(
                 pos[0], pos[1],
                 pos[0], pos[1],
             )));
             )));
@@ -396,16 +594,16 @@ impl eframe::App for CompanionApp {
         egui::CentralPanel::default()
         egui::CentralPanel::default()
             .frame(
             .frame(
                 egui::Frame::none()
                 egui::Frame::none()
-                    .fill(egui::Color32::TRANSPARENT)
+                    .fill(egui::Color32::BLACK)
                     .inner_margin(egui::Margin::ZERO),
                     .inner_margin(egui::Margin::ZERO),
             )
             )
             .show(ctx, |ui| {
             .show(ctx, |ui| {
                 ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
                 ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
-                render_session(ui, ctx, &session, &agent_uris, self.size, win_w, win_h);
+                render_session(ui, ctx, &session, &agent_frames, self.size, win_w, win_h);
             });
             });
 
 
         render_size_picker(ctx);
         render_size_picker(ctx);
-        ctx.request_repaint_after(Duration::from_millis(50));
+        ctx.request_repaint_after(Duration::from_millis(16));
     }
     }
 }
 }
 
 
@@ -445,7 +643,7 @@ fn render_session(
     ui: &mut egui::Ui,
     ui: &mut egui::Ui,
     ctx: &egui::Context,
     ctx: &egui::Context,
     session: &SessionInfo,
     session: &SessionInfo,
-    agent_uris: &[String],
+    agent_frames: &[AnimationFrame],
     current_size: f32,
     current_size: f32,
     win_w: f32,
     win_w: f32,
     win_h: f32,
     win_h: f32,
@@ -458,16 +656,14 @@ fn render_session(
         .unwrap_or("unknown")
         .unwrap_or("unknown")
         .to_string();
         .to_string();
 
 
-    let n = agent_uris.len().max(1);
+    let n = agent_frames.len().max(1);
     let (cols, rows) = grid_dims(n);
     let (cols, rows) = grid_dims(n);
     let rects = cell_rects(n, cols, rows, current_size);
     let rects = cell_rects(n, cols, rows, current_size);
 
 
-    for (i, uri) in agent_uris.iter().enumerate() {
+    for (i, frame) in agent_frames.iter().enumerate() {
         if let Some(&cell) = rects.get(i) {
         if let Some(&cell) = rects.get(i) {
-            ui.put(
-                cell,
-                egui::Image::new(uri).fit_to_exact_size(egui::vec2(current_size, current_size)),
-            );
+            ui.painter()
+                .image(frame.texture_id, cell, frame.uv, egui::Color32::WHITE);
         }
         }
     }
     }
 
 
@@ -637,6 +833,7 @@ mod tests {
             status: status.to_string(),
             status: status.to_string(),
             pid: Some(1),
             pid: Some(1),
             active_agent: None,
             active_agent: None,
+            config: None,
         }
         }
     }
     }
 
 
@@ -668,12 +865,12 @@ mod tests {
     }
     }
 
 
     #[test]
     #[test]
-    fn falls_back_to_first_retained_session() {
+    fn falls_back_to_newest_retained_session() {
         let sessions = vec![
         let sessions = vec![
             session("first", "idle", &["intro"]),
             session("first", "idle", &["intro"]),
             session("second", "idle", &["intro"]),
             session("second", "idle", &["intro"]),
         ];
         ];
-        assert_eq!(choose_session(&sessions), Some(0));
+        assert_eq!(choose_session(&sessions), Some(1));
     }
     }
 
 
     #[test]
     #[test]
@@ -832,12 +1029,18 @@ mod tests {
             enabled: true,
             enabled: true,
             position: "top-left".into(),
             position: "top-left".into(),
             size: "large".into(),
             size: "large".into(),
+            gif_pack: "default".into(),
+            loop_style: "classic".into(),
+            speed: 1.0,
         };
         };
         assert_eq!(
         assert_eq!(
             config_key(Some(&cfg)),
             config_key(Some(&cfg)),
             Some(ConfigKey {
             Some(ConfigKey {
                 position: "top-left".into(),
                 position: "top-left".into(),
                 size: "large".into(),
                 size: "large".into(),
+                gif_pack: "default".into(),
+                loop_style: "classic".into(),
+                speed_bits: 1.0f32.to_bits(),
             })
             })
         );
         );
         assert_eq!(config_key(None), None);
         assert_eq!(config_key(None), None);
@@ -848,18 +1051,30 @@ mod tests {
         let previous = Some(ConfigKey {
         let previous = Some(ConfigKey {
             position: "bottom-right".into(),
             position: "bottom-right".into(),
             size: "medium".into(),
             size: "medium".into(),
+            gif_pack: "default".into(),
+            loop_style: "classic".into(),
+            speed_bits: 1.0f32.to_bits(),
         });
         });
         let unchanged = Some(ConfigKey {
         let unchanged = Some(ConfigKey {
             position: "bottom-right".into(),
             position: "bottom-right".into(),
             size: "medium".into(),
             size: "medium".into(),
+            gif_pack: "default".into(),
+            loop_style: "classic".into(),
+            speed_bits: 1.0f32.to_bits(),
         });
         });
         let moved = Some(ConfigKey {
         let moved = Some(ConfigKey {
             position: "top-left".into(),
             position: "top-left".into(),
             size: "medium".into(),
             size: "medium".into(),
+            gif_pack: "default".into(),
+            loop_style: "classic".into(),
+            speed_bits: 1.0f32.to_bits(),
         });
         });
         let resized = Some(ConfigKey {
         let resized = Some(ConfigKey {
             position: "bottom-right".into(),
             position: "bottom-right".into(),
             size: "large".into(),
             size: "large".into(),
+            gif_pack: "default".into(),
+            loop_style: "classic".into(),
+            speed_bits: 1.0f32.to_bits(),
         });
         });
 
 
         assert_eq!(previous, unchanged);
         assert_eq!(previous, unchanged);
@@ -871,17 +1086,43 @@ mod tests {
     fn apply_config_updates_size_only_for_config_changes() {
     fn apply_config_updates_size_only_for_config_changes() {
         let mut position = "bottom-right".to_string();
         let mut position = "bottom-right".to_string();
         let mut size = 200.0;
         let mut size = 200.0;
+        let mut gif_pack = "default".to_string();
+        let mut loop_style = "classic".to_string();
+        let mut speed = 1.0;
         let cfg = ConfigKey {
         let cfg = ConfigKey {
             position: "top-left".into(),
             position: "top-left".into(),
             size: "small".into(),
             size: "small".into(),
+            gif_pack: "default".into(),
+            loop_style: "smooth".into(),
+            speed_bits: 2.0f32.to_bits(),
         };
         };
 
 
-        apply_config(Some(&cfg), &mut position, &mut size);
+        apply_config(
+            Some(&cfg),
+            &mut position,
+            &mut size,
+            &mut gif_pack,
+            &mut loop_style,
+            &mut speed,
+        );
         assert_eq!(position, "top-left");
         assert_eq!(position, "top-left");
         assert_eq!(size, 80.0);
         assert_eq!(size, 80.0);
-
-        apply_config(None, &mut position, &mut size);
+        assert_eq!(gif_pack, "default");
+        assert_eq!(loop_style, "smooth");
+        assert_eq!(speed, 2.0);
+
+        apply_config(
+            None,
+            &mut position,
+            &mut size,
+            &mut gif_pack,
+            &mut loop_style,
+            &mut speed,
+        );
         assert_eq!(position, "bottom-right");
         assert_eq!(position, "bottom-right");
         assert_eq!(size, 120.0);
         assert_eq!(size, 120.0);
+        assert_eq!(gif_pack, "default");
+        assert_eq!(loop_style, "classic");
+        assert_eq!(speed, 1.0);
     }
     }
 }
 }

+ 175 - 26
companion/src/gifs.rs

@@ -1,39 +1,188 @@
-use egui::Context;
+use egui::{ColorImage, Context, Rect, TextureHandle, TextureId, TextureOptions};
 use std::collections::HashMap;
 use std::collections::HashMap;
 
 
+const DEFAULT_SPEED: f32 = 1.0;
+const BASE_SPEED_MULTIPLIER: f32 = 2.0;
+const FRAME_RATE: f32 = 24.0;
+const FRAME_COUNT: usize = 72;
+const SHEET_COLS: usize = 12;
+const SHEET_ROWS: usize = 6;
+
 pub struct Gifs {
 pub struct Gifs {
-    map: HashMap<&'static str, &'static [u8]>,
+    sheets: HashMap<&'static str, &'static [u8]>,
+    textures: HashMap<&'static str, TextureHandle>,
+}
+
+#[derive(Clone, Copy)]
+pub struct AnimationFrame {
+    pub texture_id: TextureId,
+    pub uv: Rect,
 }
 }
 
 
 impl Gifs {
 impl Gifs {
     pub fn new() -> Self {
     pub fn new() -> Self {
-        let mut map: HashMap<&'static str, &'static [u8]> = HashMap::new();
-        map.insert("council", include_bytes!("../gifs/council.gif"));
-        map.insert("councillor", include_bytes!("../gifs/council.gif"));
-        map.insert("designer", include_bytes!("../gifs/designer.gif"));
-        map.insert("explorer", include_bytes!("../gifs/explorer.gif"));
-        map.insert("fixer", include_bytes!("../gifs/fixer.gif"));
-        map.insert("input", include_bytes!("../gifs/question.gif"));
-        map.insert("intro", include_bytes!("../gifs/intro.gif"));
-        map.insert("librarian", include_bytes!("../gifs/librarian.gif"));
-        map.insert("oracle", include_bytes!("../gifs/oracle.gif"));
-        map.insert("orchestrator", include_bytes!("../gifs/orchestrator.gif"));
-        Self { map }
-    }
-
-    pub fn register(&self, ctx: &Context) {
-        egui_extras::install_image_loaders(ctx);
-        for (name, bytes) in &self.map {
-            ctx.include_bytes(format!("bytes://{name}.gif"), *bytes);
+        let mut sheets: HashMap<&'static str, &'static [u8]> = HashMap::new();
+        sheets.insert("council", include_bytes!("../animations/council.jpg"));
+        sheets.insert("councillor", include_bytes!("../animations/council.jpg"));
+        sheets.insert("designer", include_bytes!("../animations/designer.jpg"));
+        sheets.insert("explorer", include_bytes!("../animations/explorer.jpg"));
+        sheets.insert("fixer", include_bytes!("../animations/fixer.jpg"));
+        sheets.insert("input", include_bytes!("../animations/question.jpg"));
+        sheets.insert("intro", include_bytes!("../animations/intro.jpg"));
+        sheets.insert("librarian", include_bytes!("../animations/librarian.jpg"));
+        sheets.insert("oracle", include_bytes!("../animations/oracle.jpg"));
+        sheets.insert(
+            "orchestrator",
+            include_bytes!("../animations/orchestrator.jpg"),
+        );
+        Self {
+            sheets,
+            textures: HashMap::new(),
+        }
+    }
+
+    pub fn frame(
+        &mut self,
+        ctx: &Context,
+        agent: &str,
+        gif_pack: &str,
+        speed: f32,
+        loop_style: &str,
+        time_seconds: f64,
+    ) -> Option<AnimationFrame> {
+        let name = self.resolve_name(agent, gif_pack);
+        self.ensure_texture(ctx, name)?;
+        let texture = self.textures.get(name)?;
+        Some(AnimationFrame {
+            texture_id: texture.id(),
+            uv: frame_uv(frame_index(time_seconds, speed, loop_style)),
+        })
+    }
+
+    fn ensure_texture(&mut self, ctx: &Context, name: &'static str) -> Option<()> {
+        if self.textures.contains_key(name) {
+            return Some(());
+        }
+        let bytes = *self.sheets.get(name)?;
+        let started = std::time::Instant::now();
+        match decode_sprite_sheet(bytes) {
+            Ok(image) => {
+                let texture = ctx.load_texture(
+                    format!("companion-animation-{name}"),
+                    image,
+                    TextureOptions::LINEAR,
+                );
+                crate::log::debug(format!(
+                    "animation lazy-load name={} bytes={} elapsed_ms={}",
+                    name,
+                    bytes.len(),
+                    started.elapsed().as_millis()
+                ));
+                self.textures.insert(name, texture);
+                Some(())
+            }
+            Err(err) => {
+                crate::log::debug(format!("animation decode failed name={} err={}", name, err));
+                None
+            }
         }
         }
     }
     }
 
 
-    pub fn uri(&self, agent: &str) -> String {
-        let name = if self.map.contains_key(agent) {
-            agent
+    fn resolve_name(&self, agent: &str, gif_pack: &str) -> &'static str {
+        if gif_pack != "default" {
+            return "orchestrator";
+        }
+        self.sheets
+            .get_key_value(agent)
+            .map(|(name, _)| *name)
+            .unwrap_or("orchestrator")
+    }
+}
+
+pub fn normalized_speed(speed: f32) -> f32 {
+    if speed.is_finite() {
+        speed.clamp(0.25, 4.0)
+    } else {
+        DEFAULT_SPEED
+    }
+}
+
+fn normalized_loop_style(loop_style: &str) -> &str {
+    if loop_style == "smooth" {
+        "smooth"
+    } else {
+        "classic"
+    }
+}
+
+fn frame_index(time_seconds: f64, speed: f32, loop_style: &str) -> usize {
+    let effective_speed = normalized_speed(speed) * BASE_SPEED_MULTIPLIER;
+    let tick =
+        (time_seconds.max(0.0) * FRAME_RATE as f64 * effective_speed as f64).floor() as usize;
+    if normalized_loop_style(loop_style) == "smooth" {
+        let period = FRAME_COUNT * 2 - 2;
+        let phase = tick % period;
+        if phase < FRAME_COUNT {
+            phase
         } else {
         } else {
-            "orchestrator"
-        };
-        format!("bytes://{name}.gif")
+            period - phase
+        }
+    } else {
+        tick % FRAME_COUNT
+    }
+}
+
+fn frame_uv(index: usize) -> Rect {
+    let col = index % SHEET_COLS;
+    let row = index / SHEET_COLS;
+    let w = 1.0 / SHEET_COLS as f32;
+    let h = 1.0 / SHEET_ROWS as f32;
+    Rect::from_min_max(
+        egui::pos2(col as f32 * w, row as f32 * h),
+        egui::pos2((col + 1) as f32 * w, (row + 1) as f32 * h),
+    )
+}
+
+fn decode_sprite_sheet(bytes: &[u8]) -> Result<ColorImage, image::ImageError> {
+    let image = image::load_from_memory(bytes)?.to_rgba8();
+    let size = [image.width() as usize, image.height() as usize];
+    Ok(ColorImage::from_rgba_unmultiplied(size, image.as_raw()))
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{frame_index, frame_uv, normalized_speed, FRAME_COUNT};
+
+    #[test]
+    fn speed_is_clamped_and_defaults_fast() {
+        assert_eq!(normalized_speed(f32::NAN), 1.0);
+        assert_eq!(normalized_speed(0.1), 0.25);
+        assert_eq!(normalized_speed(9.0), 4.0);
+    }
+
+    #[test]
+    fn classic_loop_wraps_forward() {
+        assert_eq!(frame_index(0.0, 1.0, "classic"), 0);
+        assert_eq!(frame_index(1.5, 1.0, "classic"), 0);
+        assert_eq!(frame_index(1.5 + 1.0 / 48.0, 1.0, "classic"), 1);
+    }
+
+    #[test]
+    fn smooth_loop_ping_pongs_without_duplicate_endpoints() {
+        assert_eq!(frame_index(0.0, 1.0, "smooth"), 0);
+        assert_eq!(
+            frame_index((FRAME_COUNT - 1) as f64 / 48.0, 1.0, "smooth"),
+            71
+        );
+        assert_eq!(frame_index(FRAME_COUNT as f64 / 48.0, 1.0, "smooth"), 70);
+    }
+
+    #[test]
+    fn frame_uv_maps_sprite_grid_cells() {
+        let uv = frame_uv(13);
+        assert!(uv.min.x > 0.0);
+        assert!(uv.min.y > 0.0);
+        assert!(uv.max.x <= 1.0);
+        assert!(uv.max.y <= 1.0);
     }
     }
 }
 }

+ 41 - 0
companion/src/log.rs

@@ -0,0 +1,41 @@
+use std::io::Write;
+use std::path::PathBuf;
+
+fn log_path() -> PathBuf {
+    let base = std::env::var("XDG_DATA_HOME")
+        .ok()
+        .filter(|s| !s.is_empty())
+        .map(PathBuf::from)
+        .unwrap_or_else(|| {
+            dirs::home_dir()
+                .unwrap_or_else(|| PathBuf::from("."))
+                .join(".local")
+                .join("share")
+        });
+    base.join("opencode").join("log").join(format!(
+        "oh-my-opencode-slim-companion.{}.log",
+        std::process::id()
+    ))
+}
+
+pub fn debug(message: impl AsRef<str>) {
+    if std::env::var("OH_MY_OPENCODE_SLIM_COMPANION_DEBUG")
+        .ok()
+        .as_deref()
+        != Some("1")
+    {
+        return;
+    }
+
+    let path = log_path();
+    if let Some(parent) = path.parent() {
+        let _ = std::fs::create_dir_all(parent);
+    }
+    if let Ok(mut file) = std::fs::OpenOptions::new()
+        .create(true)
+        .append(true)
+        .open(path)
+    {
+        let _ = writeln!(file, "{}", message.as_ref());
+    }
+}

+ 24 - 5
companion/src/main.rs

@@ -2,16 +2,35 @@
 
 
 mod app;
 mod app;
 mod gifs;
 mod gifs;
+mod log;
 mod niri;
 mod niri;
 mod screen;
 mod screen;
 mod singleton;
 mod singleton;
 mod state;
 mod state;
 
 
-use singleton::acquire;
-
 fn main() -> eframe::Result {
 fn main() -> eframe::Result {
-    // Exit immediately if another instance is already running
-    if !acquire() {
+    let Some(owner_session_id) = std::env::var("OH_MY_OPENCODE_SLIM_COMPANION_SESSION_ID")
+        .ok()
+        .filter(|session_id| !session_id.trim().is_empty())
+    else {
+        log::debug(format!(
+            "exit missing owner_session_id pid={}",
+            std::process::id()
+        ));
+        return Ok(());
+    };
+
+    log::debug(format!(
+        "start pid={} owner_session_id={}",
+        std::process::id(),
+        owner_session_id
+    ));
+
+    if !singleton::acquire(&owner_session_id) {
+        log::debug(format!(
+            "exit duplicate owner_session_id={}",
+            owner_session_id
+        ));
         return Ok(());
         return Ok(());
     }
     }
 
 
@@ -20,7 +39,7 @@ fn main() -> eframe::Result {
             .with_title("oh-my-opencode-slim-companion")
             .with_title("oh-my-opencode-slim-companion")
             .with_app_id("oh-my-opencode-slim-companion")
             .with_app_id("oh-my-opencode-slim-companion")
             .with_decorations(false)
             .with_decorations(false)
-            .with_transparent(true)
+            .with_transparent(false)
             .with_always_on_top()
             .with_always_on_top()
             .with_active(false)
             .with_active(false)
             .with_inner_size([120.0, 120.0]),
             .with_inner_size([120.0, 120.0]),

+ 66 - 10
companion/src/singleton.rs

@@ -1,6 +1,16 @@
 use std::path::PathBuf;
 use std::path::PathBuf;
 
 
-fn lock_path() -> PathBuf {
+fn lock_path(owner_session_id: &str) -> PathBuf {
+    let safe_owner = owner_session_id
+        .chars()
+        .map(|ch| {
+            if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
+                ch
+            } else {
+                '_'
+            }
+        })
+        .collect::<String>();
     let base = std::env::var("XDG_DATA_HOME")
     let base = std::env::var("XDG_DATA_HOME")
         .ok()
         .ok()
         .filter(|s| !s.is_empty())
         .filter(|s| !s.is_empty())
@@ -14,24 +24,70 @@ fn lock_path() -> PathBuf {
     base.join("opencode")
     base.join("opencode")
         .join("storage")
         .join("storage")
         .join("oh-my-opencode-slim")
         .join("oh-my-opencode-slim")
-        .join("companion.pid")
+        .join(format!("companion.{safe_owner}.pid"))
 }
 }
 
 
 /// Returns true if this process should continue running.
 /// Returns true if this process should continue running.
-/// Returns false if another companion instance is already alive.
-pub fn acquire() -> bool {
-    let path = lock_path();
+/// Returns false if another companion instance for the same OpenCode session is
+/// already alive.
+pub fn acquire(owner_session_id: &str) -> bool {
+    let path = lock_path(owner_session_id);
+    if let Some(parent) = path.parent() {
+        let _ = std::fs::create_dir_all(parent);
+    }
 
 
-    if let Ok(content) = std::fs::read_to_string(&path) {
-        if let Ok(pid) = content.trim().parse::<u32>() {
-            if pid != std::process::id() && is_alive(pid) {
+    for _ in 0..2 {
+        match std::fs::OpenOptions::new()
+            .write(true)
+            .create_new(true)
+            .open(&path)
+        {
+            Ok(mut file) => {
+                use std::io::Write;
+                let _ = write!(file, "{}", std::process::id());
+                crate::log::debug(format!(
+                    "lock acquired owner_session_id={} pid={} path={}",
+                    owner_session_id,
+                    std::process::id(),
+                    path.display()
+                ));
+                return true;
+            }
+            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
+                let existing_pid = std::fs::read_to_string(&path)
+                    .ok()
+                    .and_then(|content| content.trim().parse::<u32>().ok());
+                if existing_pid.is_some_and(|pid| pid != std::process::id() && is_alive(pid)) {
+                    crate::log::debug(format!(
+                        "lock duplicate owner_session_id={} existing_pid={:?} current_pid={}",
+                        owner_session_id,
+                        existing_pid,
+                        std::process::id()
+                    ));
+                    return false;
+                }
+                crate::log::debug(format!(
+                    "lock stale owner_session_id={} existing_pid={:?} current_pid={} path={}",
+                    owner_session_id,
+                    existing_pid,
+                    std::process::id(),
+                    path.display()
+                ));
+                let _ = std::fs::remove_file(&path);
+            }
+            Err(err) => {
+                crate::log::debug(format!(
+                    "lock error owner_session_id={} err={} path={}",
+                    owner_session_id,
+                    err,
+                    path.display()
+                ));
                 return false;
                 return false;
             }
             }
         }
         }
     }
     }
 
 
-    let _ = std::fs::write(&path, std::process::id().to_string());
-    true
+    false
 }
 }
 
 
 #[cfg(unix)]
 #[cfg(unix)]

+ 20 - 0
companion/src/state.rs

@@ -11,6 +11,24 @@ pub struct CompanionConfigState {
     pub enabled: bool,
     pub enabled: bool,
     pub position: String,
     pub position: String,
     pub size: String,
     pub size: String,
+    #[serde(default = "default_gif_pack", rename = "gifPack")]
+    pub gif_pack: String,
+    #[serde(default = "default_loop_style", rename = "loopStyle")]
+    pub loop_style: String,
+    #[serde(default = "default_speed")]
+    pub speed: f32,
+}
+
+fn default_gif_pack() -> String {
+    "default".to_string()
+}
+
+fn default_loop_style() -> String {
+    "classic".to_string()
+}
+
+fn default_speed() -> f32 {
+    1.0
 }
 }
 
 
 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
@@ -42,6 +60,8 @@ pub struct SessionInfo {
     pub status: String,
     pub status: String,
     #[serde(default)]
     #[serde(default)]
     pub pid: Option<u32>,
     pub pid: Option<u32>,
+    #[serde(default)]
+    pub config: Option<CompanionConfigState>,
 }
 }
 
 
 pub fn state_file_path() -> PathBuf {
 pub fn state_file_path() -> PathBuf {

+ 61 - 5
docs/companion.md

@@ -10,13 +10,18 @@ You can enable the companion by adding a `companion` section to your setting con
 {
 {
   "companion": {
   "companion": {
     "enabled": true,
     "enabled": true,
+    "binaryPath": "/path/to/oh-my-opencode-slim-companion",
     "position": "bottom-right",
     "position": "bottom-right",
-    "size": "medium"
+    "size": "medium",
+    "gifPack": "default",
+    "loopStyle": "classic",
+    "speed": 1,
+    "debug": false
   }
   }
 }
 }
 ```
 ```
 
 
-### Supported Position & Size Values
+### Supported Values
 
 
 - **`companion.position`**:
 - **`companion.position`**:
   - `bottom-right` (default)
   - `bottom-right` (default)
@@ -29,6 +34,28 @@ You can enable the companion by adding a `companion` section to your setting con
   - `medium` (120px) (default)
   - `medium` (120px) (default)
   - `large` (160px)
   - `large` (160px)
 
 
+- **`companion.gifPack`**:
+  - `default` (default) — the bundled companion animation set generated from
+    the MP4 sources in `companion/VIDEOS/`.
+
+- **`companion.loopStyle`**:
+  - `classic` (default) — forward playback that loops back to the first frame.
+  - `smooth` — ping-pong playback that reverses direction at the end for a
+    smoother transition.
+
+- **`companion.speed`**: optional animation playback speed multiplier from `0.25` to
+  `4`. The default is `1`. Values above `1` play faster; values below `1`
+  play slower.
+
+- **`companion.debug`**: set to `true` to enable verbose native companion debug
+  logs while troubleshooting window/session behavior. Logs are written under
+  `$XDG_DATA_HOME/opencode/log/` or `~/.local/share/opencode/log/`.
+
+- **`companion.binaryPath`**: optional path to a custom companion binary. When
+  set, the runtime launches this binary instead of the default install path.
+  Custom binaries are user-managed and are not replaced by automatic companion
+  updates.
+
 ### Remembered Window Position
 ### Remembered Window Position
 
 
 You can drag the companion window to a custom location. The companion remembers
 You can drag the companion window to a custom location. The companion remembers
@@ -105,7 +132,26 @@ If `XDG_DATA_HOME` is unset, this resolves to:
 ~/.local/share/opencode/storage/oh-my-opencode-slim/bin/oh-my-opencode-slim-companion
 ~/.local/share/opencode/storage/oh-my-opencode-slim/bin/oh-my-opencode-slim-companion
 ```
 ```
 
 
-If the binary is not located in this directory, the plugin runtime will not start the companion window.
+If the binary is not located in this directory, set `companion.binaryPath` to
+the binary you want the plugin runtime to launch.
+
+When Companion is enabled and uses the default install path, the plugin keeps
+the native binary aligned with the companion version bundled by the installed
+plugin package. The updater writes install metadata beside the binary so future
+starts can skip unnecessary downloads. Existing `companion-v0.1.2` installs that
+predate metadata are migrated in place without re-downloading.
+
+Startup checks run before the Companion is spawned, but they use a short timeout
+so OpenCode startup is not blocked by a slow network. Plugin auto-update also
+tries to update the Companion binary after the new package is installed. If a
+download fails, the plugin update still succeeds and Companion update is retried
+on the next OpenCode restart. The updater uses a lock to prevent concurrent
+OpenCode processes from replacing the same binary, and stale locks from crashed
+updates are cleaned up automatically.
+
+Automatic native updates only use release archives listed in the packaged
+companion manifest, and every archive must have a matching SHA256 checksum.
+Custom binaries configured with `companion.binaryPath` are never overwritten.
 
 
 ---
 ---
 
 
@@ -120,7 +166,10 @@ plan:
    plugin can ship beta updates without rebuilding native binaries every time.
    plugin can ship beta updates without rebuilding native binaries every time.
 3. **OS/Arch Detection**: `--companion=yes` selects the archive for the current
 3. **OS/Arch Detection**: `--companion=yes` selects the archive for the current
    target.
    target.
-4. **No R2 Required**: GitHub Releases are the source of truth. R2 can be added
+4. **Manifest + Checksums**: the plugin ships
+   `src/companion/companion-manifest.json`, which names the companion release
+   and SHA256 checksum for each supported archive.
+5. **No R2 Required**: GitHub Releases are the source of truth. R2 can be added
    later as a mirror if download volume becomes a problem.
    later as a mirror if download volume becomes a problem.
 
 
 Current release assets are named:
 Current release assets are named:
@@ -203,7 +252,14 @@ gh release view companion-v0.1.2
 ```
 ```
 
 
 Confirm the release contains the archive names expected by the installer for the
 Confirm the release contains the archive names expected by the installer for the
-targets you built.
+targets you built. Then update `src/companion/companion-manifest.json` with the
+new version, tag, and each asset's SHA256 digest. GitHub release asset metadata
+includes `digest: sha256:<hash>`, which can be copied into the manifest without
+the `sha256:` prefix.
+
+The runtime updater has a matching manifest constant in
+`src/companion/updater.ts`; tests assert the JSON manifest and runtime constant
+stay in sync.
 
 
 ### 4. Install with the plugin installer
 ### 4. Install with the plugin installer
 
 

+ 1 - 0
docs/configuration.md

@@ -146,6 +146,7 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `interview.port` | integer | `0` | Interview server port (0–65535). `0` = OS-assigned random port (per-session mode). Any value > 0 enables [dashboard mode](interview.md#dashboard-mode) |
 | `interview.port` | integer | `0` | Interview server port (0–65535). `0` = OS-assigned random port (per-session mode). Any value > 0 enables [dashboard mode](interview.md#dashboard-mode) |
 | `interview.dashboard` | boolean | `false` | Enable [dashboard mode](interview.md#dashboard-mode) on the default port (43211). Setting `port` > 0 also enables dashboard mode. If both are set, `port` takes precedence |
 | `interview.dashboard` | boolean | `false` | Enable [dashboard mode](interview.md#dashboard-mode) on the default port (43211). Setting `port` > 0 also enables dashboard mode. If both are set, `port` takes precedence |
 | `companion.enabled` | boolean | `false` | Enable/disable the floating window Rust companion |
 | `companion.enabled` | boolean | `false` | Enable/disable the floating window Rust companion |
+| `companion.binaryPath` | string | — | Optional path to a custom companion binary to launch instead of the default install path |
 | `companion.position` | string | `"bottom-right"` | The initial corner position of the companion window: `bottom-right`, `bottom-left`, `top-right`, or `top-left` |
 | `companion.position` | string | `"bottom-right"` | The initial corner position of the companion window: `bottom-right`, `bottom-left`, `top-right`, or `top-left` |
 | `companion.size` | string | `"medium"` | The default size preset of the companion window: `small` (80px), `medium` (120px), or `large` (160px) |
 | `companion.size` | string | `"medium"` | The default size preset of the companion window: `small` (80px), `medium` (120px), or `large` (160px) |
 
 

+ 30 - 0
oh-my-opencode-slim.schema.json

@@ -416,6 +416,11 @@
         "enabled": {
         "enabled": {
           "type": "boolean"
           "type": "boolean"
         },
         },
+        "binaryPath": {
+          "description": "Path to a custom companion binary to launch.",
+          "type": "string",
+          "minLength": 1
+        },
         "position": {
         "position": {
           "type": "string",
           "type": "string",
           "enum": [
           "enum": [
@@ -432,6 +437,31 @@
             "medium",
             "medium",
             "large"
             "large"
           ]
           ]
+        },
+        "gifPack": {
+          "description": "Bundled companion animation pack to use.",
+          "type": "string",
+          "enum": [
+            "default"
+          ]
+        },
+        "loopStyle": {
+          "description": "Companion animation playback style: classic loops or smooth ping-pong playback.",
+          "type": "string",
+          "enum": [
+            "classic",
+            "smooth"
+          ]
+        },
+        "speed": {
+          "description": "Companion animation playback speed multiplier. Defaults to 1.",
+          "type": "number",
+          "minimum": 0.25,
+          "maximum": 4
+        },
+        "debug": {
+          "description": "Enable verbose native companion debug logs.",
+          "type": "boolean"
         }
         }
       }
       }
     },
     },

+ 1 - 0
package.json

@@ -40,6 +40,7 @@
   "homepage": "https://github.com/alvinunreal/oh-my-opencode-slim#readme",
   "homepage": "https://github.com/alvinunreal/oh-my-opencode-slim#readme",
   "files": [
   "files": [
     "dist",
     "dist",
+    "src/companion/companion-manifest.json",
     "src/skills",
     "src/skills",
     "oh-my-opencode-slim.schema.json",
     "oh-my-opencode-slim.schema.json",
     "README.md",
     "README.md",

+ 1 - 0
scripts/verify-release-artifact.ts

@@ -30,6 +30,7 @@ const packagedRequiredFiles = [
   'dist/index.d.ts',
   'dist/index.d.ts',
   'dist/cli/index.js',
   'dist/cli/index.js',
   'oh-my-opencode-slim.schema.json',
   'oh-my-opencode-slim.schema.json',
+  'src/companion/companion-manifest.json',
   'src/skills/simplify/SKILL.md',
   'src/skills/simplify/SKILL.md',
   'src/skills/codemap/SKILL.md',
   'src/skills/codemap/SKILL.md',
   'src/skills/clonedeps/SKILL.md',
   'src/skills/clonedeps/SKILL.md',

+ 23 - 130
src/cli/companion.ts

@@ -1,50 +1,12 @@
 import {
 import {
-  chmodSync,
-  copyFileSync,
-  existsSync,
-  mkdirSync,
-  mkdtempSync,
-  renameSync,
-  rmSync,
-  writeFileSync,
-} from 'node:fs';
-import { homedir, tmpdir } from 'node:os';
-import * as path from 'node:path';
+  COMPANION_MANIFEST,
+  ensureCompanionVersion,
+  getCompanionBinaryPath,
+  getCompanionTarget,
+} from '../companion/updater';
 import type { ConfigMergeResult, InstallConfig } from './types';
 import type { ConfigMergeResult, InstallConfig } from './types';
 
 
-const COMPANION_VERSION = '0.1.2';
-const COMPANION_TAG = 'companion-v0.1.2';
-const GITHUB_REPO = 'alvinunreal/oh-my-opencode-slim';
-
-export function getCompanionTarget(): string | null {
-  const p = process.platform;
-  const a = process.arch;
-  if (p === 'darwin') {
-    if (a === 'arm64') return 'aarch64-apple-darwin';
-  } else if (p === 'linux') {
-    if (a === 'x64') return 'x86_64-unknown-linux-gnu';
-    if (a === 'arm64') return 'aarch64-unknown-linux-gnu';
-  } else if (p === 'win32') {
-    if (a === 'x64') return 'x86_64-pc-windows-msvc';
-  }
-  return null;
-}
-
-export function getCompanionBinaryPath(): string {
-  const xdg = process.env.XDG_DATA_HOME?.trim();
-  const base =
-    xdg && path.isAbsolute(xdg) ? xdg : path.join(homedir(), '.local', 'share');
-  return path.join(
-    base,
-    'opencode',
-    'storage',
-    'oh-my-opencode-slim',
-    'bin',
-    process.platform === 'win32'
-      ? 'oh-my-opencode-slim-companion.exe'
-      : 'oh-my-opencode-slim-companion',
-  );
-}
+export { getCompanionBinaryPath, getCompanionTarget };
 
 
 export async function installCompanion(
 export async function installCompanion(
   config: InstallConfig,
   config: InstallConfig,
@@ -60,10 +22,9 @@ export async function installCompanion(
     };
     };
   }
   }
 
 
-  const isWindows = process.platform === 'win32';
-  const ext = isWindows ? 'zip' : 'tar.gz';
-  const archiveName = `oh-my-opencode-slim-companion-v${COMPANION_VERSION}-${target}.${ext}`;
-  const downloadUrl = `https://github.com/${GITHUB_REPO}/releases/download/${COMPANION_TAG}/${archiveName}`;
+  const ext = process.platform === 'win32' ? 'zip' : 'tar.gz';
+  const archiveName = `oh-my-opencode-slim-companion-v${COMPANION_MANIFEST.version}-${target}.${ext}`;
+  const downloadUrl = `https://github.com/${COMPANION_MANIFEST.repo}/releases/download/${COMPANION_MANIFEST.tag}/${archiveName}`;
 
 
   if (config.dryRun) {
   if (config.dryRun) {
     console.log(`  [dry-run] Detected companion target: ${target}`);
     console.log(`  [dry-run] Detected companion target: ${target}`);
@@ -75,91 +36,23 @@ export async function installCompanion(
     };
     };
   }
   }
 
 
-  let buffer: ArrayBuffer;
-  try {
-    const res = await fetch(downloadUrl);
-    if (!res.ok) {
-      return {
-        success: false,
-        configPath: finalBinaryPath,
-        error: `Failed to download companion binary (HTTP ${res.status}): ${res.statusText}`,
-      };
-    }
-    buffer = await res.arrayBuffer();
-  } catch (err) {
-    return {
-      success: false,
-      configPath: finalBinaryPath,
-      error: `Failed to fetch companion archive: ${err instanceof Error ? err.message : String(err)}`,
-    };
-  }
-
-  let tempDir = '';
-  try {
-    tempDir = mkdtempSync(path.join(tmpdir(), 'companion-install-'));
-    const archivePath = path.join(tempDir, archiveName);
-    writeFileSync(archivePath, Buffer.from(buffer));
-
-    const extractedDir = path.join(tempDir, 'extracted');
-    mkdirSync(extractedDir, { recursive: true });
-
-    if (isWindows) {
-      const { extractZip } = await import('../utils/zip-extractor');
-      await extractZip(archivePath, extractedDir);
-    } else {
-      const { crossSpawn } = await import('../utils/compat');
-      const proc = crossSpawn(['tar', '-xzf', archivePath, '-C', extractedDir]);
-      const exitCode = await proc.exited;
-      if (exitCode !== 0) {
-        const stderr = await proc.stderr();
-        return {
-          success: false,
-          configPath: finalBinaryPath,
-          error: `Archive extraction failed (tar exited with ${exitCode}): ${stderr}`,
-        };
-      }
-    }
-
-    const binaryName = isWindows
-      ? 'oh-my-opencode-slim-companion.exe'
-      : 'oh-my-opencode-slim-companion';
-    const extractedBinaryPath = path.join(extractedDir, binaryName);
-
-    if (!existsSync(extractedBinaryPath)) {
-      return {
-        success: false,
-        configPath: finalBinaryPath,
-        error: `Binary ${binaryName} not found in extracted archive`,
-      };
-    }
-
-    const binDir = path.dirname(finalBinaryPath);
-    mkdirSync(binDir, { recursive: true });
-
-    const tmpFinalPath = `${finalBinaryPath}.tmp`;
-    copyFileSync(extractedBinaryPath, tmpFinalPath);
-
-    if (!isWindows) {
-      chmodSync(tmpFinalPath, 0o755);
-    }
-
-    renameSync(tmpFinalPath, finalBinaryPath);
-
+  const result = await ensureCompanionVersion({
+    config: { enabled: true },
+    manifest: COMPANION_MANIFEST,
+  });
+  if (result.status === 'installed' || result.status === 'current') {
     return {
     return {
       success: true,
       success: true,
       configPath: finalBinaryPath,
       configPath: finalBinaryPath,
     };
     };
-  } catch (err) {
-    return {
-      success: false,
-      configPath: finalBinaryPath,
-      error: `Failed to install companion: ${err instanceof Error ? err.message : String(err)}`,
-    };
-  } finally {
-    if (tempDir) {
-      try {
-        rmSync(tempDir, { recursive: true, force: true });
-      } catch {}
-    }
   }
   }
+
+  return {
+    success: false,
+    configPath: finalBinaryPath,
+    error:
+      result.status === 'failed'
+        ? result.error
+        : `Companion install skipped: ${result.reason}`,
+  };
 }
 }

+ 4 - 0
src/cli/providers.ts

@@ -150,6 +150,10 @@ export function generateLiteConfig(
       enabled: true,
       enabled: true,
       position: 'bottom-right',
       position: 'bottom-right',
       size: 'medium',
       size: 'medium',
+      gifPack: 'default',
+      loopStyle: 'classic',
+      speed: 1,
+      debug: false,
     };
     };
   }
   }
 
 

+ 11 - 0
src/companion/companion-manifest.json

@@ -0,0 +1,11 @@
+{
+  "version": "0.1.2",
+  "tag": "companion-v0.1.2",
+  "repo": "alvinunreal/oh-my-opencode-slim",
+  "checksums": {
+    "oh-my-opencode-slim-companion-v0.1.2-aarch64-apple-darwin.tar.gz": "6f66fdfe895ab39b96ddf1f395c4547cb428cfe68c94a1a3a9f11c1ab46863c2",
+    "oh-my-opencode-slim-companion-v0.1.2-aarch64-unknown-linux-gnu.tar.gz": "af979907a429904db4449a79e0d8d99edf994ce373d298284b440549d1eb507b",
+    "oh-my-opencode-slim-companion-v0.1.2-x86_64-pc-windows-msvc.zip": "fca87451bcc3a3f5eb5ab6dcd83999bb35e2a4f30d5a666c427b7bb892ac1eb0",
+    "oh-my-opencode-slim-companion-v0.1.2-x86_64-unknown-linux-gnu.tar.gz": "e6352a4e6c71773617ca2831af2102b011d3114c0fb8d40635b6af8076b757bc"
+  }
+}

+ 35 - 2
src/companion/manager.test.ts

@@ -2,7 +2,11 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
 import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
 import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
 import * as os from 'node:os';
 import * as os from 'node:os';
 import * as path from 'node:path';
 import * as path from 'node:path';
-import { CompanionManager, stateFilePath } from './manager';
+import {
+  CompanionManager,
+  resolveCompanionBinaryPath,
+  stateFilePath,
+} from './manager';
 
 
 // Point writes at a temp dir so tests don't touch the real state file.
 // Point writes at a temp dir so tests don't touch the real state file.
 const TEST_DIR = path.join(os.tmpdir(), `companion-test-${process.pid}`);
 const TEST_DIR = path.join(os.tmpdir(), `companion-test-${process.pid}`);
@@ -262,14 +266,22 @@ describe('CompanionManager', () => {
       enabled: true,
       enabled: true,
       position: 'bottom-right',
       position: 'bottom-right',
       size: 'medium',
       size: 'medium',
+      gifPack: 'default',
+      loopStyle: 'classic',
+      speed: 1,
+      debug: false,
     });
     });
   });
   });
 
 
-  it('supports custom position and size', () => {
+  it('supports custom position, size, and animation settings', () => {
     const m = make('test-custom', '/path', {
     const m = make('test-custom', '/path', {
       enabled: true,
       enabled: true,
       position: 'top-left',
       position: 'top-left',
       size: 'large',
       size: 'large',
+      gifPack: 'default',
+      loopStyle: 'smooth',
+      speed: 1.5,
+      debug: true,
     });
     });
     m.onLoad();
     m.onLoad();
     const state = readState();
     const state = readState();
@@ -277,9 +289,30 @@ describe('CompanionManager', () => {
       enabled: true,
       enabled: true,
       position: 'top-left',
       position: 'top-left',
       size: 'large',
       size: 'large',
+      gifPack: 'default',
+      loopStyle: 'smooth',
+      speed: 1.5,
+      debug: true,
     });
     });
   });
   });
 
 
+  it('resolves a configured companion binary path', () => {
+    const customBin = path.join(TEST_DIR, 'custom-companion');
+    writeFileSync(customBin, '#!/bin/sh\n');
+
+    expect(resolveCompanionBinaryPath({ binaryPath: customBin })).toBe(
+      customBin,
+    );
+  });
+
+  it('returns null when configured companion binary path does not exist', () => {
+    expect(
+      resolveCompanionBinaryPath({
+        binaryPath: path.join(TEST_DIR, 'missing-companion'),
+      }),
+    ).toBeNull();
+  });
+
   it('methods are no-ops when disabled', () => {
   it('methods are no-ops when disabled', () => {
     const m = new CompanionManager('test-noop', '/path', {
     const m = new CompanionManager('test-noop', '/path', {
       enabled: false,
       enabled: false,

+ 50 - 18
src/companion/manager.ts

@@ -18,6 +18,7 @@ interface CompanionSession {
   active_agents: string[];
   active_agents: string[];
   status: string;
   status: string;
   pid: number;
   pid: number;
+  config?: CompanionState['config'];
 }
 }
 
 
 interface CompanionState {
 interface CompanionState {
@@ -28,6 +29,10 @@ interface CompanionState {
     enabled: boolean;
     enabled: boolean;
     position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
     position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
     size: 'small' | 'medium' | 'large';
     size: 'small' | 'medium' | 'large';
+    gifPack: 'default';
+    loopStyle: 'classic' | 'smooth';
+    speed: number;
+    debug: boolean;
   };
   };
 }
 }
 
 
@@ -46,7 +51,7 @@ export function stateFilePath(): string {
   );
   );
 }
 }
 
 
-function binaryPath(): string | null {
+function defaultBinaryPath(): string {
   const xdg = process.env.XDG_DATA_HOME?.trim();
   const xdg = process.env.XDG_DATA_HOME?.trim();
   const base =
   const base =
     xdg && path.isAbsolute(xdg)
     xdg && path.isAbsolute(xdg)
@@ -56,7 +61,7 @@ function binaryPath(): string | null {
     os.platform() === 'win32'
     os.platform() === 'win32'
       ? 'oh-my-opencode-slim-companion.exe'
       ? 'oh-my-opencode-slim-companion.exe'
       : 'oh-my-opencode-slim-companion';
       : 'oh-my-opencode-slim-companion';
-  const bin = path.join(
+  return path.join(
     base,
     base,
     'opencode',
     'opencode',
     'storage',
     'storage',
@@ -64,6 +69,13 @@ function binaryPath(): string | null {
     'bin',
     'bin',
     binaryName,
     binaryName,
   );
   );
+}
+
+export function resolveCompanionBinaryPath(
+  config?: CompanionConfig,
+): string | null {
+  const configured = config?.binaryPath?.trim();
+  const bin = configured || defaultBinaryPath();
   return existsSync(bin) ? bin : null;
   return existsSync(bin) ? bin : null;
 }
 }
 
 
@@ -234,6 +246,17 @@ export class CompanionManager {
         active_agents: this.activeAgents(),
         active_agents: this.activeAgents(),
         status: this.status,
         status: this.status,
         pid: process.pid,
         pid: process.pid,
+        config: this.config
+          ? {
+              enabled: this.config.enabled ?? false,
+              position: this.config.position ?? 'bottom-right',
+              size: this.config.size ?? 'medium',
+              gifPack: this.config.gifPack ?? 'default',
+              loopStyle: this.config.loopStyle ?? 'classic',
+              speed: this.config.speed ?? 1,
+              debug: this.config.debug ?? false,
+            }
+          : undefined,
       };
       };
       writeState((state) => {
       writeState((state) => {
         const idx = state.sessions.findIndex((s) => s.session_id === this.id);
         const idx = state.sessions.findIndex((s) => s.session_id === this.id);
@@ -247,6 +270,10 @@ export class CompanionManager {
             enabled: this.config.enabled ?? false,
             enabled: this.config.enabled ?? false,
             position: this.config.position ?? 'bottom-right',
             position: this.config.position ?? 'bottom-right',
             size: this.config.size ?? 'medium',
             size: this.config.size ?? 'medium',
+            gifPack: this.config.gifPack ?? 'default',
+            loopStyle: this.config.loopStyle ?? 'classic',
+            speed: this.config.speed ?? 1,
+            debug: this.config.debug ?? false,
           };
           };
         }
         }
       });
       });
@@ -257,30 +284,35 @@ export class CompanionManager {
 
 
   private spawnIfAvailable(): void {
   private spawnIfAvailable(): void {
     if (this.config?.enabled !== true) return;
     if (this.config?.enabled !== true) return;
-    const bin = binaryPath();
+    const bin = resolveCompanionBinaryPath(this.config);
     if (!bin) {
     if (!bin) {
-      const xdg = process.env.XDG_DATA_HOME?.trim();
-      const base =
-        xdg && path.isAbsolute(xdg)
-          ? xdg
-          : path.join(os.homedir(), '.local', 'share');
-      const expected = path.join(
-        base,
-        'o‍pencode',
-        'storage',
-        'oh-my-o‍pencode-slim',
-        'bin',
-        'oh-my-o‍pencode-slim-companion',
-      );
+      const expected = this.config.binaryPath?.trim() || defaultBinaryPath();
       log(
       log(
         `[companion] enabled but companion binary not found at expected path: ${expected}. Please install/download the companion binary separately.`,
         `[companion] enabled but companion binary not found at expected path: ${expected}. Please install/download the companion binary separately.`,
       );
       );
       return;
       return;
     }
     }
     try {
     try {
-      const child = spawn(bin, [], { detached: true, stdio: 'ignore' });
+      const child = spawn(bin, [], {
+        detached: true,
+        env: {
+          ...process.env,
+          OH_MY_OPENCODE_SLIM_COMPANION_SESSION_ID: this.id,
+          ...(this.config.debug === true
+            ? { OH_MY_OPENCODE_SLIM_COMPANION_DEBUG: '1' }
+            : {}),
+        },
+        stdio: 'ignore',
+      });
       child.unref();
       child.unref();
-      log('[companion] spawned', bin);
+      log(
+        '[companion] spawned',
+        JSON.stringify({
+          bin,
+          sessionId: this.id,
+          debug: this.config.debug === true,
+        }),
+      );
     } catch (err) {
     } catch (err) {
       log('[companion] spawn failed', String(err));
       log('[companion] spawn failed', String(err));
     }
     }

+ 269 - 0
src/companion/updater.test.ts

@@ -0,0 +1,269 @@
+import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
+import {
+  existsSync,
+  mkdirSync,
+  readFileSync,
+  rmSync,
+  utimesSync,
+  writeFileSync,
+} from 'node:fs';
+import * as os from 'node:os';
+import * as path from 'node:path';
+import {
+  COMPANION_MANIFEST,
+  ensureCompanionVersion,
+  getCompanionBinaryPath,
+  getCompanionTarget,
+  loadCompanionManifestFromPackageRoot,
+} from './updater';
+
+const TEST_DIR = path.join(
+  os.tmpdir(),
+  `companion-updater-test-${process.pid}`,
+);
+const originalXdg = process.env.XDG_DATA_HOME;
+const originalFetch = globalThis.fetch;
+
+describe('companion updater', () => {
+  beforeEach(() => {
+    rmSync(TEST_DIR, { recursive: true, force: true });
+    process.env.XDG_DATA_HOME = TEST_DIR;
+    globalThis.fetch = originalFetch;
+  });
+
+  afterEach(() => {
+    rmSync(TEST_DIR, { recursive: true, force: true });
+    if (originalXdg === undefined) {
+      delete process.env.XDG_DATA_HOME;
+    } else {
+      process.env.XDG_DATA_HOME = originalXdg;
+    }
+    globalThis.fetch = originalFetch;
+  });
+
+  test('skips disabled companion config', async () => {
+    const result = await ensureCompanionVersion({
+      config: { enabled: false },
+    });
+
+    expect(result.status).toBe('skipped');
+    expect(result).toMatchObject({ reason: 'disabled' });
+  });
+
+  test('skips user-managed custom companion binaries', async () => {
+    const result = await ensureCompanionVersion({
+      config: { enabled: true, binaryPath: '/custom/companion' },
+    });
+
+    expect(result.status).toBe('skipped');
+    expect(result).toMatchObject({ reason: 'custom-binary' });
+  });
+
+  test('treats matching installed metadata as current', async () => {
+    const bin = getCompanionBinaryPath();
+    mkdirSync(path.dirname(bin), { recursive: true });
+    writeFileSync(bin, 'binary');
+    writeFileSync(
+      `${bin}.json`,
+      JSON.stringify({
+        version: '0.1.2',
+        tag: 'companion-v0.1.2',
+        target: getCompanionTarget(),
+        installedAt: new Date().toISOString(),
+        archiveName: 'archive.tar.gz',
+      }),
+    );
+
+    const result = await ensureCompanionVersion({
+      config: { enabled: true },
+    });
+
+    expect(result).toMatchObject({
+      status: 'current',
+      binaryPath: bin,
+      version: '0.1.2',
+    });
+  });
+
+  test('dry-run reports the install without downloading', async () => {
+    const result = await ensureCompanionVersion({
+      config: { enabled: true },
+      dryRun: true,
+    });
+
+    expect(result).toMatchObject({
+      status: 'installed',
+      binaryPath: getCompanionBinaryPath(),
+      version: '0.1.2',
+    });
+  });
+
+  test('migrates existing v0.1.2 binaries without metadata', async () => {
+    const bin = getCompanionBinaryPath();
+    mkdirSync(path.dirname(bin), { recursive: true });
+    writeFileSync(bin, 'existing-binary');
+    globalThis.fetch = (() => {
+      throw new Error('should not download existing 0.1.2 binary');
+    }) as unknown as typeof fetch;
+
+    const result = await ensureCompanionVersion({
+      config: { enabled: true },
+    });
+
+    expect(result).toMatchObject({ status: 'current', version: '0.1.2' });
+    expect(existsSync(`${bin}.json`)).toBe(true);
+    expect(JSON.parse(readFileSync(`${bin}.json`, 'utf8'))).toMatchObject({
+      version: '0.1.2',
+      tag: 'companion-v0.1.2',
+      target: getCompanionTarget(),
+    });
+  });
+
+  test('fails closed when archive checksum is missing', async () => {
+    const fetchCalls: string[] = [];
+    globalThis.fetch = ((url: RequestInfo | URL) => {
+      fetchCalls.push(String(url));
+      throw new Error('should not fetch without checksum');
+    }) as unknown as typeof fetch;
+
+    const result = await ensureCompanionVersion({
+      config: { enabled: true },
+      manifest: {
+        version: '0.2.0',
+        tag: 'companion-v0.2.0',
+        repo: 'owner/repo',
+      },
+    });
+
+    expect(result.status).toBe('failed');
+    expect(result).toMatchObject({
+      error: expect.stringContaining('Missing SHA256 checksum'),
+    });
+    expect(fetchCalls).toEqual([]);
+  });
+
+  test('checksum mismatch fails without replacing existing binary', async () => {
+    const bin = getCompanionBinaryPath();
+    mkdirSync(path.dirname(bin), { recursive: true });
+    writeFileSync(bin, 'old-binary');
+    writeFileSync(
+      `${bin}.json`,
+      JSON.stringify({
+        version: '0.1.1',
+        tag: 'companion-v0.1.1',
+        target: getCompanionTarget(),
+        installedAt: new Date().toISOString(),
+        archiveName: 'old.tar.gz',
+      }),
+    );
+    globalThis.fetch = (async () =>
+      new Response(new Uint8Array([1, 2, 3]), {
+        status: 200,
+        statusText: 'OK',
+      })) as unknown as typeof fetch;
+
+    const target = getCompanionTarget() ?? 'unsupported';
+    const result = await ensureCompanionVersion({
+      config: { enabled: true },
+      manifest: {
+        version: '0.2.0',
+        tag: 'companion-v0.2.0',
+        repo: 'owner/repo',
+        checksums: {
+          [archiveName('0.2.0', target)]: 'bad',
+        },
+      },
+    });
+
+    expect(result).toMatchObject({
+      status: 'failed',
+      error: 'Companion archive checksum mismatch',
+    });
+    expect(readFileSync(bin, 'utf8')).toBe('old-binary');
+  });
+
+  test('fails instead of installing without a lock after lock timeout', async () => {
+    const bin = getCompanionBinaryPath();
+    mkdirSync(`${bin}.lock`, { recursive: true });
+    globalThis.fetch = (() => {
+      throw new Error('should not install without lock');
+    }) as unknown as typeof fetch;
+
+    const result = await ensureCompanionVersion({
+      config: { enabled: true },
+      manifest: {
+        version: '0.2.0',
+        tag: 'companion-v0.2.0',
+        repo: 'owner/repo',
+        checksums: {},
+      },
+      lockTimeoutMs: 1,
+    });
+
+    expect(result).toMatchObject({
+      status: 'failed',
+      error: 'Timed out waiting for companion install lock',
+    });
+  });
+
+  test('recovers from stale install locks', async () => {
+    const bin = getCompanionBinaryPath();
+    const lock = `${bin}.lock`;
+    mkdirSync(lock, { recursive: true });
+    const oldDate = new Date(Date.now() - 10 * 60_000);
+    utimesSync(lock, oldDate, oldDate);
+    globalThis.fetch = (() => {
+      throw new Error('should not fetch without checksum');
+    }) as unknown as typeof fetch;
+
+    const result = await ensureCompanionVersion({
+      config: { enabled: true },
+      manifest: {
+        version: '0.2.0',
+        tag: 'companion-v0.2.0',
+        repo: 'owner/repo',
+        checksums: {},
+      },
+      lockTimeoutMs: 100,
+      lockStaleMs: 1,
+    });
+
+    expect(existsSync(lock)).toBe(false);
+    expect(result).toMatchObject({
+      status: 'failed',
+      error: expect.stringContaining('Missing SHA256 checksum'),
+    });
+  });
+
+  test('loads a companion manifest from an installed package root', () => {
+    const packageRoot = path.join(TEST_DIR, 'package');
+    const manifestDir = path.join(packageRoot, 'src', 'companion');
+    mkdirSync(manifestDir, { recursive: true });
+    writeFileSync(
+      path.join(manifestDir, 'companion-manifest.json'),
+      JSON.stringify({
+        version: '0.2.0',
+        tag: 'companion-v0.2.0',
+        repo: 'owner/repo',
+      }),
+    );
+
+    expect(loadCompanionManifestFromPackageRoot(packageRoot)).toEqual({
+      version: '0.2.0',
+      tag: 'companion-v0.2.0',
+      repo: 'owner/repo',
+    });
+  });
+
+  test('bundled JSON manifest matches the runtime manifest constant', () => {
+    const packageRoot = path.resolve(import.meta.dir, '..', '..');
+    expect(loadCompanionManifestFromPackageRoot(packageRoot)).toEqual(
+      COMPANION_MANIFEST,
+    );
+  });
+});
+
+function archiveName(version: string, target: string): string {
+  const ext = process.platform === 'win32' ? 'zip' : 'tar.gz';
+  return `oh-my-opencode-slim-companion-v${version}-${target}.${ext}`;
+}

+ 451 - 0
src/companion/updater.ts

@@ -0,0 +1,451 @@
+import { createHash } from 'node:crypto';
+import {
+  chmodSync,
+  copyFileSync,
+  existsSync,
+  mkdirSync,
+  mkdtempSync,
+  readFileSync,
+  renameSync,
+  rmSync,
+  statSync,
+  writeFileSync,
+} from 'node:fs';
+import { homedir, platform, tmpdir } from 'node:os';
+import * as path from 'node:path';
+import { setTimeout as delay } from 'node:timers/promises';
+import type { CompanionConfig } from '../config/schema';
+import { crossSpawn } from '../utils/compat';
+import { log } from '../utils/logger';
+
+export interface CompanionManifest {
+  version: string;
+  tag: string;
+  repo: string;
+  checksums?: Record<string, string>;
+}
+
+interface CompanionInstallMetadata {
+  version: string;
+  tag: string;
+  target: string;
+  installedAt: string;
+  archiveName: string;
+  checksum?: string;
+}
+
+export type CompanionUpdateResult =
+  | { status: 'installed'; binaryPath: string; version: string }
+  | { status: 'current'; binaryPath: string; version: string }
+  | { status: 'skipped'; reason: string; binaryPath?: string }
+  | { status: 'failed'; error: string; binaryPath: string };
+
+const DOWNLOAD_TIMEOUT_MS = 30_000;
+const LOCK_TIMEOUT_MS = 2_000;
+const STALE_LOCK_MS = 5 * 60_000;
+const FIRST_METADATA_VERSION = '0.1.2';
+
+export const COMPANION_MANIFEST: CompanionManifest = {
+  version: '0.1.2',
+  tag: 'companion-v0.1.2',
+  repo: 'alvinunreal/oh-my-opencode-slim',
+  checksums: {
+    'oh-my-opencode-slim-companion-v0.1.2-aarch64-apple-darwin.tar.gz':
+      '6f66fdfe895ab39b96ddf1f395c4547cb428cfe68c94a1a3a9f11c1ab46863c2',
+    'oh-my-opencode-slim-companion-v0.1.2-aarch64-unknown-linux-gnu.tar.gz':
+      'af979907a429904db4449a79e0d8d99edf994ce373d298284b440549d1eb507b',
+    'oh-my-opencode-slim-companion-v0.1.2-x86_64-pc-windows-msvc.zip':
+      'fca87451bcc3a3f5eb5ab6dcd83999bb35e2a4f30d5a666c427b7bb892ac1eb0',
+    'oh-my-opencode-slim-companion-v0.1.2-x86_64-unknown-linux-gnu.tar.gz':
+      'e6352a4e6c71773617ca2831af2102b011d3114c0fb8d40635b6af8076b757bc',
+  },
+};
+
+export function getCompanionTarget(): string | null {
+  const p = process.platform;
+  const a = process.arch;
+  if (p === 'darwin') {
+    if (a === 'arm64') return 'aarch64-apple-darwin';
+  } else if (p === 'linux') {
+    if (a === 'x64') return 'x86_64-unknown-linux-gnu';
+    if (a === 'arm64') return 'aarch64-unknown-linux-gnu';
+  } else if (p === 'win32') {
+    if (a === 'x64') return 'x86_64-pc-windows-msvc';
+  }
+  return null;
+}
+
+export function getCompanionBinaryPath(): string {
+  const xdg = process.env.XDG_DATA_HOME?.trim();
+  const base =
+    xdg && path.isAbsolute(xdg) ? xdg : path.join(homedir(), '.local', 'share');
+  return path.join(
+    base,
+    'opencode',
+    'storage',
+    'oh-my-opencode-slim',
+    'bin',
+    platform() === 'win32'
+      ? 'oh-my-opencode-slim-companion.exe'
+      : 'oh-my-opencode-slim-companion',
+  );
+}
+
+export function loadCompanionManifestFromPackageRoot(
+  packageRoot: string,
+): CompanionManifest | null {
+  const manifestPath = path.join(
+    packageRoot,
+    'src',
+    'companion',
+    'companion-manifest.json',
+  );
+  try {
+    const parsed = JSON.parse(
+      readFileSync(manifestPath, 'utf8'),
+    ) as Partial<CompanionManifest>;
+    if (parsed.version && parsed.tag && parsed.repo) {
+      return {
+        version: parsed.version,
+        tag: parsed.tag,
+        repo: parsed.repo,
+        checksums: parsed.checksums,
+      };
+    }
+  } catch {}
+  return null;
+}
+
+export async function ensureCompanionVersion(options: {
+  config?: CompanionConfig;
+  manifest?: CompanionManifest;
+  dryRun?: boolean;
+  downloadTimeoutMs?: number;
+  lockTimeoutMs?: number;
+  lockStaleMs?: number;
+}): Promise<CompanionUpdateResult> {
+  const { config, dryRun = false } = options;
+  const manifest = options.manifest ?? COMPANION_MANIFEST;
+  const binaryPath = getCompanionBinaryPath();
+
+  if (config?.enabled !== true) {
+    return { status: 'skipped', reason: 'disabled', binaryPath };
+  }
+
+  if (config.binaryPath?.trim()) {
+    return { status: 'skipped', reason: 'custom-binary', binaryPath };
+  }
+
+  const target = getCompanionTarget();
+  if (!target) {
+    return {
+      status: 'failed',
+      binaryPath,
+      error: `Unsupported platform/architecture: ${process.platform} ${process.arch}`,
+    };
+  }
+
+  const current = readInstallMetadata(binaryPath);
+  if (
+    existsSync(binaryPath) &&
+    !current &&
+    manifest.version === FIRST_METADATA_VERSION
+  ) {
+    const archiveName = companionArchiveName(manifest.version, target);
+    writeInstallMetadata(binaryPath, {
+      version: manifest.version,
+      tag: manifest.tag,
+      target,
+      installedAt: new Date().toISOString(),
+      archiveName,
+      checksum: manifest.checksums?.[archiveName],
+    });
+    return { status: 'current', binaryPath, version: manifest.version };
+  }
+
+  if (
+    existsSync(binaryPath) &&
+    current?.target === target &&
+    compareSemver(current.version, manifest.version) >= 0
+  ) {
+    return { status: 'current', binaryPath, version: current.version };
+  }
+
+  if (dryRun) {
+    return { status: 'installed', binaryPath, version: manifest.version };
+  }
+
+  return withCompanionInstallLock(
+    binaryPath,
+    options.lockTimeoutMs,
+    options.lockStaleMs,
+    async () => {
+      const lockedCurrent = readInstallMetadata(binaryPath);
+      if (
+        existsSync(binaryPath) &&
+        !lockedCurrent &&
+        manifest.version === FIRST_METADATA_VERSION
+      ) {
+        const archiveName = companionArchiveName(manifest.version, target);
+        writeInstallMetadata(binaryPath, {
+          version: manifest.version,
+          tag: manifest.tag,
+          target,
+          installedAt: new Date().toISOString(),
+          archiveName,
+          checksum: manifest.checksums?.[archiveName],
+        });
+        return { status: 'current', binaryPath, version: manifest.version };
+      }
+
+      if (
+        existsSync(binaryPath) &&
+        lockedCurrent?.target === target &&
+        compareSemver(lockedCurrent.version, manifest.version) >= 0
+      ) {
+        return {
+          status: 'current',
+          binaryPath,
+          version: lockedCurrent.version,
+        };
+      }
+
+      return installCompanionArchive(
+        binaryPath,
+        target,
+        manifest,
+        options.downloadTimeoutMs ?? DOWNLOAD_TIMEOUT_MS,
+      );
+    },
+  );
+}
+
+async function installCompanionArchive(
+  finalBinaryPath: string,
+  target: string,
+  manifest: CompanionManifest,
+  downloadTimeoutMs: number,
+): Promise<CompanionUpdateResult> {
+  const isWindows = process.platform === 'win32';
+  const archiveName = companionArchiveName(manifest.version, target, isWindows);
+  const downloadUrl = `https://github.com/${manifest.repo}/releases/download/${manifest.tag}/${archiveName}`;
+  const expectedChecksum = manifest.checksums?.[archiveName];
+  if (!expectedChecksum) {
+    return {
+      status: 'failed',
+      binaryPath: finalBinaryPath,
+      error: `Missing SHA256 checksum for companion archive: ${archiveName}`,
+    };
+  }
+
+  let buffer: ArrayBuffer;
+  const controller = new AbortController();
+  const timeout = setTimeout(() => controller.abort(), downloadTimeoutMs);
+  try {
+    const res = await fetch(downloadUrl, { signal: controller.signal });
+    if (!res.ok) {
+      return {
+        status: 'failed',
+        binaryPath: finalBinaryPath,
+        error: `Failed to download companion binary (HTTP ${res.status}): ${res.statusText}`,
+      };
+    }
+    buffer = await res.arrayBuffer();
+  } catch (err) {
+    return {
+      status: 'failed',
+      binaryPath: finalBinaryPath,
+      error: `Failed to fetch companion archive: ${formatError(err)}`,
+    };
+  } finally {
+    clearTimeout(timeout);
+  }
+
+  const checksum = createHash('sha256')
+    .update(Buffer.from(buffer))
+    .digest('hex');
+  if (checksum !== expectedChecksum) {
+    return {
+      status: 'failed',
+      binaryPath: finalBinaryPath,
+      error: 'Companion archive checksum mismatch',
+    };
+  }
+
+  let tempDir = '';
+  try {
+    tempDir = mkdtempSync(path.join(tmpdir(), 'companion-install-'));
+    const archivePath = path.join(tempDir, archiveName);
+    writeFileSync(archivePath, Buffer.from(buffer));
+
+    const extractedDir = path.join(tempDir, 'extracted');
+    mkdirSync(extractedDir, { recursive: true });
+
+    if (isWindows) {
+      const { extractZip } = await import('../utils/zip-extractor');
+      await extractZip(archivePath, extractedDir);
+    } else {
+      const proc = crossSpawn(['tar', '-xzf', archivePath, '-C', extractedDir]);
+      const exitCode = await proc.exited;
+      if (exitCode !== 0) {
+        const stderr = await proc.stderr();
+        return {
+          status: 'failed',
+          binaryPath: finalBinaryPath,
+          error: `Archive extraction failed (tar exited with ${exitCode}): ${stderr}`,
+        };
+      }
+    }
+
+    const binaryName = isWindows
+      ? 'oh-my-opencode-slim-companion.exe'
+      : 'oh-my-opencode-slim-companion';
+    const extractedBinaryPath = path.join(extractedDir, binaryName);
+
+    if (!existsSync(extractedBinaryPath)) {
+      return {
+        status: 'failed',
+        binaryPath: finalBinaryPath,
+        error: `Binary ${binaryName} not found in extracted archive`,
+      };
+    }
+
+    const binDir = path.dirname(finalBinaryPath);
+    mkdirSync(binDir, { recursive: true });
+
+    const tmpFinalPath = `${finalBinaryPath}.tmp`;
+    copyFileSync(extractedBinaryPath, tmpFinalPath);
+
+    if (!isWindows) {
+      chmodSync(tmpFinalPath, 0o755);
+    }
+
+    renameSync(tmpFinalPath, finalBinaryPath);
+    writeInstallMetadata(finalBinaryPath, {
+      version: manifest.version,
+      tag: manifest.tag,
+      target,
+      installedAt: new Date().toISOString(),
+      archiveName,
+      checksum,
+    });
+
+    return {
+      status: 'installed',
+      binaryPath: finalBinaryPath,
+      version: manifest.version,
+    };
+  } catch (err) {
+    return {
+      status: 'failed',
+      binaryPath: finalBinaryPath,
+      error: `Failed to install companion: ${formatError(err)}`,
+    };
+  } finally {
+    if (tempDir) {
+      try {
+        rmSync(tempDir, { recursive: true, force: true });
+      } catch {}
+    }
+  }
+}
+
+function readInstallMetadata(
+  binaryPath: string,
+): CompanionInstallMetadata | null {
+  try {
+    const parsed = JSON.parse(
+      readFileSync(metadataPath(binaryPath), 'utf8'),
+    ) as Partial<CompanionInstallMetadata> | null;
+    if (parsed?.version && parsed.tag && parsed.target) {
+      return parsed as CompanionInstallMetadata;
+    }
+  } catch {}
+  return null;
+}
+
+function writeInstallMetadata(
+  binaryPath: string,
+  metadata: CompanionInstallMetadata,
+): void {
+  writeFileSync(metadataPath(binaryPath), JSON.stringify(metadata, null, 2));
+}
+
+function metadataPath(binaryPath: string): string {
+  return `${binaryPath}.json`;
+}
+
+async function withCompanionInstallLock(
+  binaryPath: string,
+  timeoutMs: number | undefined,
+  staleMs: number | undefined,
+  run: () => Promise<CompanionUpdateResult>,
+): Promise<CompanionUpdateResult> {
+  const lock = `${binaryPath}.lock`;
+  const deadline = Date.now() + (timeoutMs ?? LOCK_TIMEOUT_MS);
+  const staleAfterMs = staleMs ?? STALE_LOCK_MS;
+  mkdirSync(path.dirname(binaryPath), { recursive: true });
+  while (Date.now() <= deadline) {
+    try {
+      mkdirSync(lock);
+      try {
+        return await run();
+      } finally {
+        try {
+          rmSync(lock, { recursive: true, force: true });
+        } catch {}
+      }
+    } catch (err) {
+      const code = (err as NodeJS.ErrnoException).code;
+      if (code !== 'EEXIST') throw err;
+      try {
+        const ageMs = Date.now() - statSync(lock).mtimeMs;
+        if (ageMs > staleAfterMs) {
+          rmSync(lock, { recursive: true, force: true });
+          log('[companion] removed stale install lock', lock);
+          continue;
+        }
+      } catch (statErr) {
+        const statCode = (statErr as NodeJS.ErrnoException).code;
+        if (statCode !== 'ENOENT') throw statErr;
+      }
+      await delay(25);
+    }
+  }
+  log('[companion] install lock timed out', lock);
+  return {
+    status: 'failed',
+    binaryPath,
+    error: 'Timed out waiting for companion install lock',
+  };
+}
+
+function companionArchiveName(
+  version: string,
+  target: string,
+  isWindows = process.platform === 'win32',
+): string {
+  const ext = isWindows ? 'zip' : 'tar.gz';
+  return `oh-my-opencode-slim-companion-v${version}-${target}.${ext}`;
+}
+
+function compareSemver(a: string, b: string): number {
+  const left = parseSemver(a);
+  const right = parseSemver(b);
+  if (!left || !right) return a.localeCompare(b);
+  for (let i = 0; i < 3; i++) {
+    const diff = left[i] - right[i];
+    if (diff !== 0) return diff;
+  }
+  return 0;
+}
+
+function parseSemver(version: string): [number, number, number] | null {
+  const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/);
+  if (!match) return null;
+  return [Number(match[1]), Number(match[2]), Number(match[3])];
+}
+
+function formatError(err: unknown): string {
+  return err instanceof Error ? err.message : String(err);
+}

+ 5 - 0
src/config/loader.ts

@@ -322,8 +322,13 @@ export function loadPluginConfig(
   if (config.companion) {
   if (config.companion) {
     config.companion = {
     config.companion = {
       enabled: config.companion.enabled ?? false,
       enabled: config.companion.enabled ?? false,
+      binaryPath: config.companion.binaryPath,
       position: config.companion.position ?? 'bottom-right',
       position: config.companion.position ?? 'bottom-right',
       size: config.companion.size ?? 'medium',
       size: config.companion.size ?? 'medium',
+      gifPack: config.companion.gifPack ?? 'default',
+      loopStyle: config.companion.loopStyle ?? 'classic',
+      speed: config.companion.speed ?? 1,
+      debug: config.companion.debug ?? false,
     };
     };
   }
   }
 
 

+ 25 - 0
src/config/schema.ts

@@ -189,10 +189,35 @@ export type FailoverConfig = z.infer<typeof FailoverConfigSchema>;
 
 
 export const CompanionConfigSchema = z.object({
 export const CompanionConfigSchema = z.object({
   enabled: z.boolean().optional(),
   enabled: z.boolean().optional(),
+  binaryPath: z
+    .string()
+    .min(1)
+    .optional()
+    .describe('Path to a custom companion binary to launch.'),
   position: z
   position: z
     .enum(['bottom-right', 'bottom-left', 'top-right', 'top-left'])
     .enum(['bottom-right', 'bottom-left', 'top-right', 'top-left'])
     .optional(),
     .optional(),
   size: z.enum(['small', 'medium', 'large']).optional(),
   size: z.enum(['small', 'medium', 'large']).optional(),
+  gifPack: z
+    .enum(['default'])
+    .optional()
+    .describe('Bundled companion animation pack to use.'),
+  loopStyle: z
+    .enum(['classic', 'smooth'])
+    .optional()
+    .describe(
+      'Companion animation playback style: classic loops or smooth ping-pong playback.',
+    ),
+  speed: z
+    .number()
+    .min(0.25)
+    .max(4)
+    .optional()
+    .describe('Companion animation playback speed multiplier. Defaults to 1.'),
+  debug: z
+    .boolean()
+    .optional()
+    .describe('Enable verbose native companion debug logs.'),
 });
 });
 
 
 export type CompanionConfig = z.infer<typeof CompanionConfigSchema>;
 export type CompanionConfig = z.infer<typeof CompanionConfigSchema>;

+ 124 - 0
src/hooks/auto-update-checker/index.test.ts

@@ -29,6 +29,15 @@ const skillSyncMocks = {
   })),
   })),
 };
 };
 
 
+const companionUpdaterMocks = {
+  ensureCompanionVersion: mock(async () => ({
+    status: 'current' as const,
+    binaryPath: '/tmp/companion',
+    version: '0.1.2',
+  })),
+  loadCompanionManifestFromPackageRoot: mock(() => null),
+};
+
 const crossSpawnMock = mock((_command: string[]) => ({
 const crossSpawnMock = mock((_command: string[]) => ({
   exited: Promise.resolve(0),
   exited: Promise.resolve(0),
   exitCode: 0,
   exitCode: 0,
@@ -48,6 +57,8 @@ mock.module('./cache', () => cacheMocks);
 
 
 mock.module('./skill-sync', () => skillSyncMocks);
 mock.module('./skill-sync', () => skillSyncMocks);
 
 
+mock.module('../../companion/updater', () => companionUpdaterMocks);
+
 mock.module('../../utils/compat', () => ({
 mock.module('../../utils/compat', () => ({
   crossSpawn: crossSpawnMock,
   crossSpawn: crossSpawnMock,
   crossWrite: mock(() => Promise.resolve()),
   crossWrite: mock(() => Promise.resolve()),
@@ -131,6 +142,19 @@ describe('auto-update-checker/index', () => {
       skippedExisting: [],
       skippedExisting: [],
       failed: [],
       failed: [],
     }));
     }));
+
+    companionUpdaterMocks.ensureCompanionVersion.mockReset();
+    companionUpdaterMocks.ensureCompanionVersion.mockImplementation(
+      async () => ({
+        status: 'current' as const,
+        binaryPath: '/tmp/companion',
+        version: '0.1.2',
+      }),
+    );
+    companionUpdaterMocks.loadCompanionManifestFromPackageRoot.mockReset();
+    companionUpdaterMocks.loadCompanionManifestFromPackageRoot.mockImplementation(
+      () => null,
+    );
   });
   });
 
 
   afterEach(() => {
   afterEach(() => {
@@ -250,6 +274,106 @@ describe('auto-update-checker/index', () => {
     });
     });
   });
   });
 
 
+  test('updates enabled companion after plugin auto-update', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
+    companionUpdaterMocks.loadCompanionManifestFromPackageRoot.mockImplementation(
+      () => ({
+        version: '0.2.0',
+        tag: 'companion-v0.2.0',
+        repo: 'owner/repo',
+      }),
+    );
+    companionUpdaterMocks.ensureCompanionVersion.mockImplementation(
+      async () => ({
+        status: 'installed' as const,
+        binaryPath: '/tmp/companion',
+        version: '0.2.0',
+      }),
+    );
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never, {
+      companion: { enabled: true },
+    });
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(
+      companionUpdaterMocks.loadCompanionManifestFromPackageRoot,
+    ).toHaveBeenCalledWith('/tmp/opencode/node_modules/oh-my-opencode-slim');
+    expect(companionUpdaterMocks.ensureCompanionVersion).toHaveBeenCalledWith({
+      config: { enabled: true },
+      manifest: {
+        version: '0.2.0',
+        tag: 'companion-v0.2.0',
+        repo: 'owner/repo',
+      },
+    });
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim Updated!',
+        message:
+          'v0.9.1 → v0.9.11\nCompanion updated.\nRestart OpenCode to apply.',
+        variant: 'success',
+        duration: 8000,
+      },
+    });
+  });
+
+  test('keeps plugin update successful when companion update fails', async () => {
+    checkerMocks.findPluginEntry.mockImplementation(() => ({
+      pinnedVersion: null,
+      isPinned: false,
+    }));
+    checkerMocks.getCachedVersion.mockImplementation(() => '0.9.1');
+    checkerMocks.getLatestCompatibleVersion.mockImplementation(async () => ({
+      latestVersion: '0.9.11',
+      latestMajorVersion: null,
+      blockedByMajor: false,
+    }));
+    companionUpdaterMocks.ensureCompanionVersion.mockImplementation(
+      async () => ({
+        status: 'failed' as const,
+        binaryPath: '/tmp/companion',
+        error: 'network down',
+      }),
+    );
+
+    const { createAutoUpdateCheckerHook } = await import(
+      `./index?test=${importCounter++}`
+    );
+    const { ctx, showToast } = createCtx();
+
+    const hook = createAutoUpdateCheckerHook(ctx as never, {
+      companion: { enabled: true },
+    });
+    hook.event({ event: { type: 'session.created', properties: {} } });
+    await waitForCalls(showToast);
+
+    expect(showToast).toHaveBeenCalledWith({
+      body: {
+        title: 'OMO-Slim Updated!',
+        message:
+          'v0.9.1 → v0.9.11\nCompanion update will retry on restart.\nRestart OpenCode to apply.',
+        variant: 'success',
+        duration: 8000,
+      },
+    });
+  });
+
   test('still reports update success when bundled skill sync has failures', async () => {
   test('still reports update success when bundled skill sync has failures', async () => {
     checkerMocks.findPluginEntry.mockImplementation(() => ({
     checkerMocks.findPluginEntry.mockImplementation(() => ({
       pinnedVersion: null,
       pinnedVersion: null,

+ 55 - 6
src/hooks/auto-update-checker/index.ts

@@ -1,5 +1,9 @@
 import * as path from 'node:path';
 import * as path from 'node:path';
 import type { PluginInput } from '@opencode-ai/plugin';
 import type { PluginInput } from '@opencode-ai/plugin';
+import {
+  ensureCompanionVersion,
+  loadCompanionManifestFromPackageRoot,
+} from '../../companion/updater';
 import { crossSpawn } from '../../utils/compat';
 import { crossSpawn } from '../../utils/compat';
 import { log } from '../../utils/logger';
 import { log } from '../../utils/logger';
 import { preparePackageUpdate, resolveInstallContext } from './cache';
 import { preparePackageUpdate, resolveInstallContext } from './cache';
@@ -24,7 +28,7 @@ export function createAutoUpdateCheckerHook(
   ctx: PluginInput,
   ctx: PluginInput,
   options: AutoUpdateCheckerOptions = {},
   options: AutoUpdateCheckerOptions = {},
 ) {
 ) {
-  const { autoUpdate = true } = options;
+  const { autoUpdate = true, companion } = options;
 
 
   let hasChecked = false;
   let hasChecked = false;
 
 
@@ -48,7 +52,7 @@ export function createAutoUpdateCheckerHook(
           return;
           return;
         }
         }
 
 
-        runBackgroundUpdateCheck(ctx, autoUpdate).catch((err) => {
+        runBackgroundUpdateCheck(ctx, autoUpdate, companion).catch((err) => {
           log('[auto-update-checker] Background update check failed:', err);
           log('[auto-update-checker] Background update check failed:', err);
         });
         });
       }, 0);
       }, 0);
@@ -64,6 +68,7 @@ export function createAutoUpdateCheckerHook(
 async function runBackgroundUpdateCheck(
 async function runBackgroundUpdateCheck(
   ctx: PluginInput,
   ctx: PluginInput,
   autoUpdate: boolean,
   autoUpdate: boolean,
+  companion: AutoUpdateCheckerOptions['companion'],
 ): Promise<void> {
 ): Promise<void> {
   const pluginInfo = findPluginEntry(ctx.directory);
   const pluginInfo = findPluginEntry(ctx.directory);
   if (!pluginInfo) {
   if (!pluginInfo) {
@@ -166,8 +171,10 @@ async function runBackgroundUpdateCheck(
 
 
   if (installSuccess) {
   if (installSuccess) {
     let installedSkills: string[] = [];
     let installedSkills: string[] = [];
+    let companionUpdated = false;
+    let companionWillRetry = false;
+    const packageRoot = path.join(installDir, 'node_modules', PACKAGE_NAME);
     try {
     try {
-      const packageRoot = path.join(installDir, 'node_modules', PACKAGE_NAME);
       const syncResult = syncBundledSkillsFromPackage(packageRoot);
       const syncResult = syncBundledSkillsFromPackage(packageRoot);
       installedSkills = syncResult.installed;
       installedSkills = syncResult.installed;
       if (syncResult.failed.length > 0) {
       if (syncResult.failed.length > 0) {
@@ -184,12 +191,54 @@ async function runBackgroundUpdateCheck(
       log('[auto-update-checker] Skill sync failed silently:', err);
       log('[auto-update-checker] Skill sync failed silently:', err);
     }
     }
 
 
-    let message = `v${currentVersion} → v${latestVersion}\nRestart OpenCode to apply.`;
+    if (companion?.enabled === true) {
+      try {
+        const manifest = loadCompanionManifestFromPackageRoot(packageRoot);
+        const companionResult = await ensureCompanionVersion({
+          config: companion,
+          manifest: manifest ?? undefined,
+        });
+        if (companionResult.status === 'installed') {
+          companionUpdated = true;
+        } else if (companionResult.status === 'failed') {
+          companionWillRetry = true;
+          log(
+            '[auto-update-checker] Companion update failed; will retry on restart:',
+            companionResult.error,
+          );
+        } else if (companionResult.status === 'skipped') {
+          log(
+            '[auto-update-checker] Companion update skipped:',
+            companionResult.reason,
+          );
+        }
+      } catch (err) {
+        companionWillRetry = true;
+        log(
+          '[auto-update-checker] Companion update failed silently; will retry on restart:',
+          err,
+        );
+      }
+    }
+
+    const messageLines = [`v${currentVersion} → v${latestVersion}`];
     if (installedSkills.length > 0) {
     if (installedSkills.length > 0) {
-      message = `v${currentVersion} → v${latestVersion}\nAdded bundled skills: ${installedSkills.join(', ')}\nRestart OpenCode to apply.`;
+      messageLines.push(`Added bundled skills: ${installedSkills.join(', ')}`);
     }
     }
+    if (companionUpdated) {
+      messageLines.push('Companion updated.');
+    } else if (companionWillRetry) {
+      messageLines.push('Companion update will retry on restart.');
+    }
+    messageLines.push('Restart OpenCode to apply.');
 
 
-    showToast(ctx, 'OMO-Slim Updated!', message, 'success', 8000);
+    showToast(
+      ctx,
+      'OMO-Slim Updated!',
+      messageLines.join('\n'),
+      'success',
+      8000,
+    );
     log(
     log(
       `[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`,
       `[auto-update-checker] Update installed: ${currentVersion} → ${latestVersion}`,
     );
     );

+ 3 - 0
src/hooks/auto-update-checker/types.ts

@@ -1,3 +1,5 @@
+import type { CompanionConfig } from '../../config/schema';
+
 export interface NpmDistTags {
 export interface NpmDistTags {
   latest: string;
   latest: string;
   [key: string]: string;
   [key: string]: string;
@@ -28,6 +30,7 @@ export interface PackageJson {
 
 
 export interface AutoUpdateCheckerOptions {
 export interface AutoUpdateCheckerOptions {
   autoUpdate?: boolean;
   autoUpdate?: boolean;
+  companion?: CompanionConfig;
 }
 }
 
 
 export interface PluginEntryInfo {
 export interface PluginEntryInfo {

+ 22 - 1
src/index.ts

@@ -2,6 +2,7 @@ import type { Plugin } from '@opencode-ai/plugin';
 import { createAgents, getAgentConfigs, getDisabledAgents } from './agents';
 import { createAgents, getAgentConfigs, getDisabledAgents } from './agents';
 import { buildOrchestratorPrompt } from './agents/orchestrator';
 import { buildOrchestratorPrompt } from './agents/orchestrator';
 import { CompanionManager } from './companion/manager';
 import { CompanionManager } from './companion/manager';
+import { ensureCompanionVersion } from './companion/updater';
 import {
 import {
   type AgentOverrideConfig,
   type AgentOverrideConfig,
   deepMerge,
   deepMerge,
@@ -84,7 +85,9 @@ async function appLog(
 /** Minimum expected registrations for a healthy plugin load. */
 /** Minimum expected registrations for a healthy plugin load. */
 const HEALTH_CHECK = {
 const HEALTH_CHECK = {
   minAgents: 5,
   minAgents: 5,
-  minTools: 5,
+  // Default tool set when council and ACP agents are not configured:
+  // cancel_task, webfetch, ast_grep_search, ast_grep_replace.
+  minTools: 4,
   minMcps: 1,
   minMcps: 1,
 } as const;
 } as const;
 
 
@@ -255,6 +258,7 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     // Initialize auto-update checker hook
     // Initialize auto-update checker hook
     autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
     autoUpdateChecker = createAutoUpdateCheckerHook(ctx, {
       autoUpdate: config.autoUpdate ?? true,
       autoUpdate: config.autoUpdate ?? true,
+      companion: config.companion,
     });
     });
 
 
     // Initialize phase reminder hook for workflow compliance
     // Initialize phase reminder hook for workflow compliance
@@ -375,6 +379,23 @@ const OhMyOpenCodeLite: Plugin = async (ctx) => {
     }
     }
   });
   });
 
 
+  if (config.companion?.enabled === true) {
+    try {
+      const companionResult = await ensureCompanionVersion({
+        config: config.companion,
+        downloadTimeoutMs: 3_000,
+        lockTimeoutMs: 500,
+      });
+      if (companionResult.status === 'installed') {
+        log('[companion] updated before startup', companionResult.version);
+      } else if (companionResult.status === 'failed') {
+        log('[companion] startup update failed', companionResult.error);
+      }
+    } catch (err) {
+      log('[companion] startup update failed', String(err));
+    }
+  }
+
   companionManager.onLoad();
   companionManager.onLoad();
 
 
   return {
   return {