diff --git a/README.md b/README.md index 0e848f8e..a00d1edb 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ Native builds for macOS, Windows, and Linux on [**Releases**](https://github.com | | | |---|---| -| **Agent-aware** | per-pane detection (26 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · reopen any past session from Search Everywhere · one-key launch · tab sidebar grouped by repository, with pinned groups | +| **Agent-aware** | per-pane detection (28 CLIs) · status dot · notifications · branch + diff · tray icon when input is needed · resume after reboot · reopen any past session from Search Everywhere · one-key launch · tab sidebar grouped by repository, with pinned groups | | **CLI + Skills** | bundled `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` streams a command and exits with its code · `split` · `send` · `wait --until free` · `capture` | | **Editor-grade input** | ghost suggestions from history · explained tab completion · syntax highlighting · multi-line editing · click places the caret · ⌃ R fuzzy history | | **Window** | tabs & splits · ⌘ P Search Everywhere · ⌘ F scrollback search · hibernate a tab to free its memory · IME | @@ -71,7 +71,7 @@ that tells tty7 which session to fork. **Past sessions** are read from the agent own history files and listed in Search Everywhere, where ⏎ resumes one.
-The full support matrix, all twenty-six +The full support matrix, all twenty-eight | Agent | Detected | Status · resume | Fork | Past sessions | |---|:-:|:-:|:-:|:-:| @@ -95,12 +95,14 @@ own history files and listed in Search Everywhere, where ⏎ resumes | **Crush** | ✓ | ✓ | | | | **Antigravity** | ✓ | ✓ | | | | **Cursor** | ✓ | ✓ | | ✓ | +| **Empryo** | ✓ | ✓ | | | +| **Muse Code** | ✓ | ✓ | | | +| **jcode** | ✓ | ✓ | | | | Aider | ✓ | | | | | Amp | ✓ | | | | | Auggie | ✓ | | | | | Hermes | ✓ | | | | | Vibe | ✓ | | | | -| Empryo | ✓ | | | |
diff --git a/README.zh-CN.md b/README.zh-CN.md index 748c5242..8d0960a6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -51,7 +51,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/ | | | |---|---| -| **Agent 感知** | 逐 pane 识别 26 个 CLI agent · 状态点 · 通知 · 分支 + diff · 需要输入时托盘图标提醒 · 重启后续上会话 · 在随处搜索里找回任意历史会话 · 一键启动 · 侧边栏按仓库分组,可置顶 | +| **Agent 感知** | 逐 pane 识别 28 个 CLI agent · 状态点 · 通知 · 分支 + diff · 需要输入时托盘图标提醒 · 重启后续上会话 · 在随处搜索里找回任意历史会话 · 一键启动 · 侧边栏按仓库分组,可置顶 | | **CLI + Skills** | 安装包自带 `tty7` CLI · [agent skill](skills/tty7/SKILL.md) · `run` 转发命令输出并原样返回退出码 · `split` · `send` · `wait --until free` · `capture` | | **编辑器级输入** | 基于历史的内联补全建议 · Tab 补全附带说明 · 语法高亮 · 多行编辑 · 点击定位光标 · ⌃ R 模糊搜索历史 | | **窗口** | 标签页与分屏 · ⌘ P 随处搜索 · ⌘ F 回滚搜索 · 休眠标签页以释放内存 · 输入法 | @@ -68,7 +68,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/ **历史会话**从 agent 自己的历史文件读取,列在随处搜索里,⏎ 即可续上。
-26 个 agent 的完整支持矩阵 +28 个 agent 的完整支持矩阵 | Agent | 识别 | 状态 · 重启恢复 | Fork | 历史会话 | |---|:-:|:-:|:-:|:-:| @@ -92,12 +92,14 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/ | **Crush** | ✓ | ✓ | | | | **Antigravity** | ✓ | ✓ | | | | **Cursor** | ✓ | ✓ | | ✓ | +| **Empryo** | ✓ | ✓ | | | +| **Muse Code** | ✓ | ✓ | | | +| **jcode** | ✓ | ✓ | | | | Aider | ✓ | | | | | Amp | ✓ | | | | | Auggie | ✓ | | | | | Hermes | ✓ | | | | | Vibe | ✓ | | | | -| Empryo | ✓ | | | |
diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs index 92dd93ce..38bdd7b9 100644 --- a/crates/tty7-core/src/core/agent_hooks.rs +++ b/crates/tty7-core/src/core/agent_hooks.rs @@ -21,6 +21,7 @@ pub fn run_agent_hook(agent: &str, event: &str) { if !std::io::stdin().is_terminal() { let _ = std::io::stdin().take(MAX_STDIN).read_to_string(&mut input); } + let input = hook_payload(agent, input, |k| std::env::var(k).ok()); let Some(event) = effective_event(agent, event, &input) else { return; }; @@ -65,6 +66,20 @@ fn detach_console() { #[cfg(unix)] fn detach_console() {} +/// The event's payload: what the agent wrote on stdin, or — for jcode, which +/// leaves stdin closed and describes the event in its environment instead — +/// the JSON mirror it exports as `JCODE_HOOK_PAYLOAD` (`session_id`, `cwd`, +/// and the event's own fields). +fn hook_payload(agent: &str, stdin: String, env: impl Fn(&str) -> Option) -> String { + if agent == "jcode" + && stdin.trim().is_empty() + && let Some(payload) = env("JCODE_HOOK_PAYLOAD").filter(|p| !p.trim().is_empty()) + { + return payload; + } + stdin +} + fn effective_agent(agent: &str, ran_by_grok: bool) -> &str { if ran_by_grok { "grok" } else { agent } } @@ -602,10 +617,13 @@ pub enum HookAgent { PrimeAgent, Antigravity, QoderCLICn, + Empryo, + Jcode, + Muse, } impl HookAgent { - pub const ALL: [HookAgent; 20] = [ + pub const ALL: [HookAgent; 23] = [ HookAgent::Claude, HookAgent::Codex, HookAgent::TraeCode, @@ -626,6 +644,9 @@ impl HookAgent { HookAgent::Cursor, HookAgent::PrimeAgent, HookAgent::Antigravity, + HookAgent::Empryo, + HookAgent::Jcode, + HookAgent::Muse, ]; /// The hooks behind a detected agent process, if it has any. @@ -645,6 +666,9 @@ impl HookAgent { CLIAgent::OhMyPi => Some(HookAgent::OhMyPi), CLIAgent::PrimeAgent => Some(HookAgent::PrimeAgent), CLIAgent::Antigravity => Some(HookAgent::Antigravity), + CLIAgent::Empryo => Some(HookAgent::Empryo), + CLIAgent::Jcode => Some(HookAgent::Jcode), + CLIAgent::Muse => Some(HookAgent::Muse), CLIAgent::Gemini => Some(HookAgent::Gemini), CLIAgent::Droid => Some(HookAgent::Droid), CLIAgent::Qwen => Some(HookAgent::Qwen), @@ -659,8 +683,7 @@ impl HookAgent { | CLIAgent::Amp | CLIAgent::Auggie | CLIAgent::Hermes - | CLIAgent::Vibe - | CLIAgent::Empryo => None, + | CLIAgent::Vibe => None, } } @@ -676,6 +699,8 @@ impl HookAgent { HookAgent::Droid => Some(DROID_HOOK_EVENTS), HookAgent::Qwen => Some(QWEN_HOOK_EVENTS), HookAgent::QoderCLI | HookAgent::QoderCLICn => Some(QODER_HOOK_EVENTS), + HookAgent::Empryo => Some(EMPRYO_HOOK_EVENTS), + HookAgent::Jcode => Some(JCODE_HOOK_EVENTS), HookAgent::Crush => Some(CRUSH_HOOK_EVENTS), HookAgent::CodeBuddy => Some(CODEBUDDY_HOOK_EVENTS), HookAgent::Cursor => Some(CURSOR_HOOK_EVENTS), @@ -687,7 +712,8 @@ impl HookAgent { | HookAgent::PrimeAgent | HookAgent::Antigravity | HookAgent::Goose - | HookAgent::Kimi => None, + | HookAgent::Kimi + | HookAgent::Muse => None, } } @@ -744,6 +770,9 @@ impl HookAgent { HookAgent::OhMyPi => "omp", HookAgent::PrimeAgent => "prime-agent", HookAgent::Antigravity => "antigravity", + HookAgent::Empryo => "empryo", + HookAgent::Jcode => "jcode", + HookAgent::Muse => "muse", HookAgent::Gemini => "gemini", HookAgent::Droid => "droid", HookAgent::Qwen => "qwen", @@ -769,6 +798,9 @@ impl HookAgent { HookAgent::OhMyPi => "Oh My Pi", HookAgent::PrimeAgent => "Prime Agent", HookAgent::Antigravity => "Antigravity", + HookAgent::Empryo => "Empryo", + HookAgent::Jcode => "jcode", + HookAgent::Muse => "Muse Code", HookAgent::Gemini => "Gemini", HookAgent::Droid => "Droid", HookAgent::Qwen => "Qwen Code", @@ -801,6 +833,17 @@ impl HookAgent { target.under_home(&[".prime", "agent", "extensions", "tty7", "index.ts"]) } HookAgent::Antigravity => target.under_home(&[".gemini", "config", "hooks.json"]), + HookAgent::Empryo => target.under_home(&[".empryo", "hooks.json"]), + HookAgent::Jcode => target.jcode_config_path(), + HookAgent::Muse => target.under_home(&[ + ".local", + "share", + "tty7", + "agent-hooks", + "muse", + ".muse-plugin", + "plugin.json", + ]), HookAgent::Grok => target.under_home(&[".grok", "hooks", OWNED_FILE_STEM_JSON]), HookAgent::OhMyPi => { target.under_home(&[".omp", "agent", "extensions", "tty7", "index.ts"]) @@ -939,6 +982,15 @@ impl<'a> HookTarget<'a> { self.under_home(&[".config"]) } + fn jcode_config_path(&self) -> PathBuf { + if self.is_local() + && let Some(dir) = std::env::var_os("JCODE_HOME").filter(|d| !d.is_empty()) + { + return PathBuf::from(dir).join("config.toml"); + } + self.under_home(&[".jcode", "config.toml"]) + } + fn kimi_config_path(&self) -> PathBuf { if self.is_local() && let Some(dir) = std::env::var_os("KIMI_CODE_HOME").filter(|d| !d.is_empty()) @@ -1081,9 +1133,15 @@ pub enum HooksState { pub fn hooks_state(target: &HookTarget, agent: HookAgent) -> HooksState { let path = agent.target_path(target); + if agent == HookAgent::Muse { + return muse_hooks_state(target); + } if agent == HookAgent::Antigravity { return named_hook_set_state(target, &path, &antigravity_hook_set(target)); } + if agent == HookAgent::Jcode { + return jcode_hooks_state(target, &path); + } if let Some(events) = agent.toml_hook_events() { return toml_hooks_state(target, &path, agent, events); } @@ -1109,6 +1167,8 @@ pub enum HookOutcome { /// Installed on a remote, where `codex features enable hooks` still has to /// be run once by hand. InstalledEnableCodexThere, + /// Bundle prepared on a remote; its own Muse CLI must install and approve it. + MuseInstallManually(String), /// Installed, but running `codex features enable hooks` here failed. InstalledCodexEnableFailed(String), Removed, @@ -1120,10 +1180,17 @@ pub enum HookOutcome { pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result { let path = agent.target_path(target); + if agent == HookAgent::Muse { + return muse_hooks_install(target, &path); + } if agent == HookAgent::Antigravity { named_hook_set_install(target, &path, antigravity_hook_set(target))?; return Ok(HookOutcome::Installed); } + if agent == HookAgent::Jcode { + jcode_hooks_install(target, &path)?; + return Ok(HookOutcome::Installed); + } if let Some(events) = agent.toml_hook_events() { toml_hooks_install(target, &path, agent, events)?; return Ok(HookOutcome::Installed); @@ -1149,9 +1216,15 @@ pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result anyhow::Result { let path = agent.target_path(target); + if agent == HookAgent::Muse { + return muse_hooks_uninstall(target, &path); + } if agent == HookAgent::Antigravity { return named_hook_set_uninstall(target, &path); } + if agent == HookAgent::Jcode { + return jcode_hooks_uninstall(target, &path); + } if agent.toml_hook_events().is_some() { return toml_hooks_uninstall(target, &path, agent); } @@ -1268,6 +1341,16 @@ const GEMINI_HOOK_EVENTS: &[(&str, &str)] = &[ ("SessionEnd", "session-end"), ]; +/// Empryo uses Claude-compatible lifecycle hooks in ~/.empryo/hooks.json. +const EMPRYO_HOOK_EVENTS: &[(&str, &str)] = &[ + ("SessionStart", "session-start"), + ("UserPromptSubmit", "prompt-submit"), + ("PostToolUse", "tool-complete"), + ("Stop", "stop"), + ("StopFailure", "stop"), + ("SessionEnd", "session-end"), +]; + const DROID_HOOK_EVENTS: &[(&str, &str)] = &[ ("SessionStart", "session-start"), ("UserPromptSubmit", "prompt-submit"), @@ -1290,6 +1373,18 @@ const QWEN_HOOK_EVENTS: &[(&str, &str)] = &[ ("SessionEnd", "session-end"), ]; +/// jcode's lifecycle keys under `[hooks]` in its `config.toml`, each holding +/// one command or an ordered list of them. jcode runs these as detached +/// observers and describes the event in `JCODE_HOOK_*` environment variables +/// rather than on stdin — see [`hook_payload`]. +const JCODE_HOOK_EVENTS: &[(&str, &str)] = &[ + ("turn_start", "prompt-submit"), + ("turn_end", "stop"), + ("session_start", "session-start"), + ("session_end", "session-end"), + ("post_tool", "tool-complete"), +]; + /// Kimi Code's hooks live as `[[hooks]]` entries in its main `config.toml` — /// the same file that holds the user's providers and models — so they go /// through the TOML merge strategy rather than a JSON map or an owned file. @@ -1568,6 +1663,318 @@ fn marker_hook<'a>(entry: &'a serde_json::Value, marker: &str) -> Option<&'a ser .find(|h| ours(h)) } +const MUSE_PLUGIN_ID: &str = "tty7-presence"; +const MUSE_HOOK_EVENTS: &[(&str, &str, &str)] = &[ + ("session-start", "SessionStart", "session-start"), + ("prompt-submit", "UserPromptSubmit", "prompt-submit"), + ( + "permission-request", + "PermissionRequest", + "permission-request", + ), + ("tool-complete", "PostToolUse", "tool-complete"), + ("stop", "Stop", "stop"), + ("stop-failure", "StopFailure", "stop"), + ("interrupt", "Interrupt", "stop"), + ("session-end", "SessionEnd", "session-end"), +]; + +fn muse_plugin_manifest(target: &HookTarget) -> serde_json::Value { + let hooks: Vec<_> = MUSE_HOOK_EVENTS + .iter() + .map(|(id, event, tty7_event)| { + serde_json::json!({ + "id": id, "event": event, + "command": [target.exe.to_string_lossy(), "agent-hook", "muse", tty7_event], + "timeoutMs": 5000, "async": *event == "Interrupt", + }) + }) + .collect(); + serde_json::json!({ + "schemaVersion": 1, "name": MUSE_PLUGIN_ID, "displayName": "tty7 presence", + "version": "1.0.0", "description": "tty7 agent-hook muse presence", + "compat": {"source": "native", "manifestDir": ".muse-plugin"}, + "capabilities": {"hooks": hooks}, + }) +} + +/// Muse's native CLI owns its cache and approval records; do not write those directly. +fn muse_cli(args: &[String]) -> anyhow::Result { + let mut cmd = std::process::Command::new("muse"); + cmd.env("MUSE_NO_AUTO_UPDATE", "1") + .args(["plugins"]) + .args(args) + .arg("--json"); + // Settings and `tty7 doctor` both wait on this; a Muse that hangs (a + // login prompt, a stuck update check) must not take either down with it. + let out = crate::core::proc::output_within( + crate::core::proc::hide_console(&mut cmd), + std::time::Duration::from_secs(30), + )?; + if !out.status.success() { + anyhow::bail!( + "muse plugins {} failed ({}): {}", + args.first().map(String::as_str).unwrap_or(""), + out.status, + String::from_utf8_lossy(&out.stderr).trim() + ); + } + Ok(serde_json::from_slice(&out.stdout)?) +} + +fn muse_inspected_state(target: &HookTarget, inspected: &serde_json::Value) -> HooksState { + if inspected["plugin"]["id"] != MUSE_PLUGIN_ID { + return HooksState::NotInstalled; + } + let expected = muse_plugin_manifest(target); + let complete = inspected["active"] == true + && inspected["valid"] == true + && expected["capabilities"]["hooks"] + .as_array() + .unwrap() + .iter() + .all(|hook| { + let declared = inspected["plugin"]["capabilities"]["hooks"] + .as_array() + .is_some_and(|hooks| { + hooks.iter().any(|installed| { + installed["id"] == hook["id"] + && installed["event"] == hook["event"] + && installed["command"] == hook["command"] + && installed["async"] == hook["async"] + }) + }); + let approved = + inspected["runtime_capabilities"] + .as_array() + .is_some_and(|entries| { + entries.iter().any(|entry| { + entry["candidate"]["kind"] == "hook" + && entry["candidate"]["capability_id"] == hook["id"] + && entry["status"] == "trusted_enabled" + }) + }); + declared && approved + }); + if complete { + HooksState::Installed + } else { + HooksState::Outdated + } +} + +fn muse_hooks_state(target: &HookTarget) -> HooksState { + // A source bundle alone is not an installed plugin. Host has no remote exec API. + if !target.is_local() { + return HooksState::NotInstalled; + } + muse_cli(&["inspect".into(), MUSE_PLUGIN_ID.into()]) + .map(|value| muse_inspected_state(target, &value)) + .unwrap_or(HooksState::NotInstalled) +} + +fn muse_install_commands(bundle: &Path) -> String { + format!( + "muse plugins install \"{}\" --scope user && muse plugins approve {MUSE_PLUGIN_ID} && muse plugins enable {MUSE_PLUGIN_ID}", + bundle.display() + ) +} + +fn muse_install_with( + target: &HookTarget, + path: &Path, + mut run: impl FnMut(&[String]) -> anyhow::Result, +) -> anyhow::Result { + let bundle = path + .parent() + .and_then(Path::parent) + .ok_or_else(|| anyhow::anyhow!("invalid Muse bundle path"))?; + let content = serde_json::to_string_pretty(&muse_plugin_manifest(target))?; + owned_file_install(target, path, &content, &HookAgent::Muse.marker())?; + if !target.is_local() { + return Ok(HookOutcome::MuseInstallManually(muse_install_commands( + bundle, + ))); + } + run(&[ + "install".into(), + bundle.to_string_lossy().into_owned(), + "--scope".into(), + "user".into(), + ])?; + run(&["approve".into(), MUSE_PLUGIN_ID.into()])?; + run(&["enable".into(), MUSE_PLUGIN_ID.into()])?; + let inspected = run(&["inspect".into(), MUSE_PLUGIN_ID.into()])?; + if muse_inspected_state(target, &inspected) != HooksState::Installed { + anyhow::bail!("Muse plugin was not activated; inspect {MUSE_PLUGIN_ID} before retrying"); + } + Ok(HookOutcome::Installed) +} + +fn muse_hooks_install(target: &HookTarget, path: &Path) -> anyhow::Result { + muse_install_with(target, path, muse_cli) +} + +fn muse_hooks_uninstall(target: &HookTarget, path: &Path) -> anyhow::Result { + if !target.is_local() { + return Ok(HookOutcome::MuseInstallManually(format!( + "muse plugins remove {MUSE_PLUGIN_ID}" + ))); + } + if muse_hooks_state(target) != HooksState::NotInstalled { + muse_cli(&["remove".into(), MUSE_PLUGIN_ID.into()])?; + } + owned_file_uninstall(target, path, &HookAgent::Muse.marker()) +} + +/// jcode accepts either one command or an ordered list (HookCommands). +fn jcode_commands(item: &toml_edit::Item) -> anyhow::Result> { + if let Some(command) = item.as_str() { + return Ok(vec![command.to_owned()]); + } + if let Some(array) = item.as_array() { + return array + .iter() + .map(|value| { + value.as_str().map(str::to_owned).ok_or_else(|| { + anyhow::anyhow!("jcode hook commands must be strings; not touching it") + }) + }) + .collect(); + } + anyhow::bail!("jcode hook must be a string or an array of strings; not touching it") +} + +fn set_jcode_commands(table: &mut toml_edit::Table, key: &str, commands: &[String]) { + match commands { + [] => { + table.remove(key); + } + [command] => { + table[key] = toml_edit::value(command.clone()); + } + _ => { + let mut array = toml_edit::Array::new(); + for command in commands { + array.push(command.as_str()); + } + table[key] = toml_edit::value(array); + } + } +} + +fn jcode_hooks_state(target: &HookTarget, path: &Path) -> HooksState { + let Ok(text) = target.read(path) else { + return HooksState::NotInstalled; + }; + let Ok(doc) = text.parse::() else { + return HooksState::NotInstalled; + }; + let Some(table) = doc.get("hooks").and_then(|item| item.as_table()) else { + return HooksState::NotInstalled; + }; + let marker = HookAgent::Jcode.marker(); + let commands: Vec<_> = JCODE_HOOK_EVENTS + .iter() + .map(|(key, _)| { + table + .get(*key) + .map(jcode_commands) + .transpose() + .ok() + .flatten() + .unwrap_or_default() + }) + .collect(); + let ours = commands + .iter() + .flatten() + .any(|command| command.contains(&marker)); + let complete = JCODE_HOOK_EVENTS + .iter() + .zip(&commands) + .all(|((_, event), list)| { + let expected = target.hook_command(HookAgent::Jcode, event); + let marked: Vec<_> = list + .iter() + .filter(|command| command.contains(&marker)) + .collect(); + marked.len() == 1 && marked[0] == &expected + }); + if complete { + HooksState::Installed + } else if ours { + HooksState::Outdated + } else { + HooksState::NotInstalled + } +} + +fn jcode_hooks_install(target: &HookTarget, path: &Path) -> anyhow::Result<()> { + let mut doc: toml_edit::DocumentMut = match target.read(path) { + Ok(text) => text.parse().map_err(|e| { + anyhow::anyhow!( + "{} is not valid TOML ({e}); not touching it", + path.display() + ) + })?, + Err(e) if e.kind() == io::ErrorKind::NotFound => toml_edit::DocumentMut::new(), + Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())), + }; + let hooks = doc.entry("hooks").or_insert(toml_edit::table()); + let table = hooks.as_table_mut().ok_or_else(|| { + anyhow::anyhow!("{} hooks is not a table; not touching it", path.display()) + })?; + let marker = HookAgent::Jcode.marker(); + for (key, event) in JCODE_HOOK_EVENTS { + let mut commands = table + .get(*key) + .map(jcode_commands) + .transpose()? + .unwrap_or_default(); + commands.retain(|command| !command.contains(&marker)); + commands.push(target.hook_command(HookAgent::Jcode, event)); + set_jcode_commands(table, key, &commands); + } + target.write(path, doc.to_string().as_bytes()) +} + +fn jcode_hooks_uninstall(target: &HookTarget, path: &Path) -> anyhow::Result { + let text = match target.read(path) { + Ok(text) => text, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(HookOutcome::NothingInstalled), + Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())), + }; + let mut doc: toml_edit::DocumentMut = text.parse().map_err(|e| { + anyhow::anyhow!( + "{} is not valid TOML ({e}); not touching it", + path.display() + ) + })?; + let Some(table) = doc.get_mut("hooks").and_then(|v| v.as_table_mut()) else { + return Ok(HookOutcome::NoTty7Hooks); + }; + let marker = HookAgent::Jcode.marker(); + let mut removed = false; + for (key, _) in JCODE_HOOK_EVENTS { + let Some(item) = table.get(*key) else { + continue; + }; + let mut commands = jcode_commands(item)?; + let before = commands.len(); + commands.retain(|command| !command.contains(&marker)); + if commands.len() != before { + set_jcode_commands(table, key, &commands); + removed = true; + } + } + if !removed { + return Ok(HookOutcome::NoTty7Hooks); + } + target.write(path, doc.to_string().as_bytes())?; + Ok(HookOutcome::Removed) +} + fn toml_hooks_state( target: &HookTarget, path: &Path, @@ -1873,7 +2280,10 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option { | HookAgent::CodeBuddy | HookAgent::Cursor | HookAgent::Antigravity - | HookAgent::Kimi => None, + | HookAgent::Kimi + | HookAgent::Empryo + | HookAgent::Jcode + | HookAgent::Muse => None, } } @@ -2732,6 +3142,8 @@ mod tests { (HookAgent::Crush, "/home/me/.config/crush/crush.json"), (HookAgent::CodeBuddy, "/home/me/.codebuddy/settings.json"), (HookAgent::Cursor, "/home/me/.cursor/hooks.json"), + (HookAgent::Empryo, "/home/me/.empryo/hooks.json"), + (HookAgent::Jcode, "/home/me/.jcode/config.toml"), ( HookAgent::PrimeAgent, "/home/me/.prime/agent/extensions/tty7/index.ts", @@ -3553,6 +3965,8 @@ mod tests { (HookAgent::Crush, "/home/me/.config/crush/crush.json"), (HookAgent::CodeBuddy, "/home/me/.codebuddy/settings.json"), (HookAgent::Cursor, "/home/me/.cursor/hooks.json"), + (HookAgent::Empryo, "/home/me/.empryo/hooks.json"), + (HookAgent::Jcode, "/home/me/.jcode/config.toml"), ] { assert_eq!( agent.target_path(&target), @@ -3590,6 +4004,244 @@ mod tests { ); } + fn installed_muse_fixture(target: &HookTarget) -> serde_json::Value { + let mut hooks = muse_plugin_manifest(target)["capabilities"]["hooks"].clone(); + for hook in hooks.as_array_mut().unwrap() { + let timeout = hook.as_object_mut().unwrap().remove("timeoutMs").unwrap(); + hook["timeout_ms"] = timeout; + } + let runtime: Vec<_> = MUSE_HOOK_EVENTS + .iter() + .map(|(id, _, _)| { + serde_json::json!({ + "candidate": {"kind": "hook", "capability_id": id}, "status": "trusted_enabled" + }) + }) + .collect(); + serde_json::json!({"active": true, "valid": true, "plugin": {"id": MUSE_PLUGIN_ID, + "capabilities": {"hooks": hooks}}, "runtime_capabilities": runtime}) + } + + #[test] + fn muse_install_uses_native_cli_and_requires_approved_hooks() { + let host = local_host(); + let dir = std::env::temp_dir().join(format!("tty7-muse-install-{}", std::process::id())); + let target = HookTarget { + host: &*host, + home: dir.clone(), + exe: PathBuf::from("/tty7 bin/tty7-app"), + }; + let path = HookAgent::Muse.target_path(&target); + let fixture = installed_muse_fixture(&target); + let mut calls = Vec::new(); + let outcome = muse_install_with(&target, &path, |args| { + calls.push(args.to_vec()); + Ok(if args[0] == "inspect" { + fixture.clone() + } else { + serde_json::json!({}) + }) + }) + .unwrap(); + assert_eq!(outcome, HookOutcome::Installed); + let bundle = path + .parent() + .unwrap() + .parent() + .unwrap() + .to_string_lossy() + .into_owned(); + assert_eq!( + calls, + vec![ + vec![ + "install".to_owned(), + bundle, + "--scope".into(), + "user".into() + ], + vec!["approve".into(), "tty7-presence".into()], + vec!["enable".into(), "tty7-presence".into()], + vec!["inspect".into(), "tty7-presence".into()], + ] + ); + assert_eq!( + muse_inspected_state(&target, &fixture), + HooksState::Installed + ); + let mut unapproved = fixture.clone(); + unapproved["runtime_capabilities"][0]["status"] = "needs_review".into(); + assert_eq!( + muse_inspected_state(&target, &unapproved), + HooksState::Outdated + ); + assert!( + muse_install_with(&target, &path, |args| { + if args[0] == "approve" { + anyhow::bail!("approval failed"); + } + Ok(serde_json::json!({})) + }) + .is_err(), + "a failed approval is not reported as installed" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn muse_remote_bundle_requires_manual_install_and_preserves_session_ids() { + let host = FakeRemote::shared(); + let dir = std::env::temp_dir().join(format!("tty7-muse-remote-{}", std::process::id())); + let target = HookTarget::remote(&*host, dir.clone()); + let path = HookAgent::Muse.target_path(&target); + let outcome = muse_install_with(&target, &path, |_| { + panic!("must not run local Muse for a remote") + }) + .unwrap(); + let HookOutcome::MuseInstallManually(command) = outcome else { + panic!("manual installation required"); + }; + assert!(command.contains("muse plugins install")); + assert!(command.contains("muse plugins approve tty7-presence")); + assert_eq!( + hooks_state(&target, HookAgent::Muse), + HooksState::NotInstalled + ); + let manifest: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + let hooks = manifest["capabilities"]["hooks"].as_array().unwrap(); + assert_eq!(hooks.len(), 8); + let interrupt = hooks + .iter() + .find(|hook| hook["event"] == "Interrupt") + .unwrap(); + assert_eq!( + interrupt["async"], true, + "Muse admits only asynchronous cancellation hooks" + ); + let permission = hooks + .iter() + .find(|hook| hook["event"] == "PermissionRequest") + .unwrap(); + assert_eq!(permission["command"][3], "permission-request"); + let event = round_trip( + "muse", + "session-start", + r#"{"session_id":"muse-session-42","cwd":"/workspace"}"#, + ); + assert_eq!(event.agent, Some(CLIAgent::Muse)); + assert_eq!(event.session_id.as_deref(), Some("muse-session-42")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn empryo_hooks_merge_with_existing_lifecycle_hooks() { + let dir = std::env::temp_dir().join(format!("tty7-empryo-hooks-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let host = FakeRemote::shared(); + let target = HookTarget::remote(&*host, dir.clone()); + let path = HookAgent::Empryo.target_path(&target); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, r#"{"hooks":{"SessionStart":[]}}"#).unwrap(); + assert_eq!( + hooks_state(&target, HookAgent::Empryo), + HooksState::NotInstalled + ); + install_hooks(&target, HookAgent::Empryo).expect("install succeeds"); + assert_eq!( + hooks_state(&target, HookAgent::Empryo), + HooksState::Installed + ); + let root: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert!(root["hooks"]["SessionStart"].as_array().unwrap().len() >= 1); + assert!( + root["hooks"]["SessionEnd"][0]["hooks"][0]["command"] + .as_str() + .unwrap() + .ends_with("agent-hook empryo session-end") + ); + install_hooks(&target, HookAgent::Empryo).expect("reinstall succeeds"); + assert_eq!( + hooks_state(&target, HookAgent::Empryo), + HooksState::Installed + ); + assert_eq!( + uninstall_hooks(&target, HookAgent::Empryo).unwrap(), + HookOutcome::Removed + ); + assert_eq!( + uninstall_hooks(&target, HookAgent::Empryo).unwrap(), + HookOutcome::NoTty7Hooks + ); + assert!(path.exists(), "uninstall preserves the shared config file"); + let root: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); + assert_eq!(root, serde_json::json!({"hooks": {}})); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_jcode_report_reads_its_session_from_the_environment() { + let env = |k: &str| { + (k == "JCODE_HOOK_PAYLOAD").then(|| { + r#"{"event":"session_start","session_id":"j-7","cwd":"/work"}"#.to_string() + }) + }; + let input = hook_payload("jcode", String::new(), env); + let event = round_trip("jcode", "session-start", &input); + assert_eq!(event.agent, Some(CLIAgent::Jcode)); + assert_eq!(event.session_id.as_deref(), Some("j-7")); + assert_eq!(event.cwd.as_deref(), Some(Path::new("/work"))); + // Only jcode is read from there, and stdin still wins when it has + // something to say. + assert_eq!(hook_payload("claude", String::new(), env), ""); + assert_eq!(hook_payload("jcode", "{}".into(), env), "{}"); + } + + #[test] + fn jcode_hooks_use_native_config_lifecycle_keys() { + let dir = std::env::temp_dir().join(format!("tty7-jcode-hooks-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let host = FakeRemote::shared(); + let target = HookTarget::remote(&*host, dir.clone()); + let path = HookAgent::Jcode.target_path(&target); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "[hooks]\nturn_start = \"user-hook\"\n").unwrap(); + install_hooks(&target, HookAgent::Jcode).expect("install succeeds"); + assert_eq!( + hooks_state(&target, HookAgent::Jcode), + HooksState::Installed + ); + let text = std::fs::read_to_string(&path).unwrap(); + let doc: toml_edit::DocumentMut = text.parse().unwrap(); + let commands = jcode_commands(&doc["hooks"]["turn_start"]).unwrap(); + assert_eq!( + commands, + vec![ + "user-hook".to_owned(), + target.hook_command(HookAgent::Jcode, "prompt-submit") + ] + ); + install_hooks(&target, HookAgent::Jcode).expect("reinstall succeeds"); + assert_eq!( + std::fs::read_to_string(&path).unwrap(), + text, + "reinstall adds no duplicates" + ); + assert!(text.contains("agent-hook jcode prompt-submit")); + assert_eq!( + uninstall_hooks(&target, HookAgent::Jcode).unwrap(), + HookOutcome::Removed + ); + assert!( + std::fs::read_to_string(&path) + .unwrap() + .contains("user-hook") + ); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn antigravity_owns_a_named_hook_set_beside_the_users() { let dir = std::env::temp_dir().join(format!("tty7-agy-hooks-{}", std::process::id())); diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs index 888e823f..9956123a 100644 --- a/crates/tty7-core/src/core/cli_agent.rs +++ b/crates/tty7-core/src/core/cli_agent.rs @@ -32,10 +32,12 @@ pub enum CLIAgent { Empryo, PrimeAgent, QoderCLICn, + Muse, + Jcode, } impl CLIAgent { - pub const ALL: [CLIAgent; 26] = [ + pub const ALL: [CLIAgent; 28] = [ CLIAgent::Claude, CLIAgent::Codex, CLIAgent::TraeCode, @@ -62,6 +64,8 @@ impl CLIAgent { CLIAgent::CodeBuddy, CLIAgent::Empryo, CLIAgent::PrimeAgent, + CLIAgent::Muse, + CLIAgent::Jcode, ]; fn aliases(self) -> &'static [&'static str] { @@ -119,6 +123,8 @@ impl CLIAgent { // avatar until it exits is the cost of catching the short name. CLIAgent::CodeBuddy => &["codebuddy", "codebuddy-code", "cbc"], CLIAgent::Empryo => &["empryo"], + CLIAgent::Muse => &["muse"], + CLIAgent::Jcode => &["jcode"], CLIAgent::PrimeAgent => &["prime-agent"], } } @@ -149,6 +155,8 @@ impl CLIAgent { CLIAgent::Crush => "crush", CLIAgent::CodeBuddy => "codebuddy", CLIAgent::Empryo => "empryo", + CLIAgent::Muse => "muse", + CLIAgent::Jcode => "jcode", CLIAgent::PrimeAgent => "prime-agent", CLIAgent::QoderCLICn => "qoderclicn", } @@ -185,6 +193,8 @@ impl CLIAgent { CLIAgent::Crush => "Crush", CLIAgent::CodeBuddy => "CodeBuddy", CLIAgent::Empryo => "Empryo", + CLIAgent::Muse => "Muse Code", + CLIAgent::Jcode => "jcode", CLIAgent::PrimeAgent => "Prime Agent", CLIAgent::QoderCLICn => "Qoder CN CLI", } @@ -226,6 +236,9 @@ impl CLIAgent { CLIAgent::Crush => Some(format!("crush{flags} --session {session_id}")), CLIAgent::CodeBuddy => Some(format!("codebuddy{flags} --resume {session_id}")), CLIAgent::Empryo => Some(format!("empryo{flags} --session {session_id}")), + // Root options may sit on either side of `resume`. + CLIAgent::Muse => Some(format!("muse{flags} resume {session_id}")), + CLIAgent::Jcode => Some(format!("jcode{flags} --resume {session_id}")), CLIAgent::PrimeAgent => Some(format!("prime-agent{flags} --resume {session_id}")), _ => None, } @@ -417,7 +430,7 @@ impl CLIAgent { let named = argv.iter().position(|t| names_self(t))?; let mut tail: Vec<&str> = argv[named + 1..].iter().map(String::as_str).collect(); - if matches!(self, CLIAgent::Codex | CLIAgent::TraeCode) + if matches!(self, CLIAgent::Codex | CLIAgent::TraeCode | CLIAgent::Muse) && matches!(tail.first(), Some(&"resume") | Some(&"fork")) { tail.remove(0); @@ -517,6 +530,9 @@ impl CLIAgent { // empryo resumes with `--session `; `--save-session` only // applies to headless runs and is not a session selector. CLIAgent::Empryo => &["--session"], + // `muse resume --last` picks the newest session instead of the id. + CLIAgent::Muse => &["--last"], + CLIAgent::Jcode => &["--resume"], // `--resume`/`-r` is Kimi's hidden alias for `--session`/`-S`. // `--agent`/`--agent-file` bind the main agent at session creation // and Kimi rejects either next to `--session` outright; resuming @@ -635,6 +651,8 @@ impl CLIAgent { // The blue-violet field Charm ships the Crush heart on. CLIAgent::Crush => 0x6B50FF, CLIAgent::Empryo => 0xE8663D, + CLIAgent::Muse => 0x0866FF, + CLIAgent::Jcode => 0x111111, CLIAgent::PrimeAgent => 0x111111, // The near-black field CodeBuddy's own app icon sits on. CLIAgent::CodeBuddy => 0x1F1F1F, @@ -688,11 +706,20 @@ impl CLIAgent { | CLIAgent::Vibe | CLIAgent::Antigravity | CLIAgent::Empryo - | CLIAgent::PrimeAgent => "icons/bot.svg", + | CLIAgent::PrimeAgent + | CLIAgent::Muse + | CLIAgent::Jcode => "icons/bot.svg", } } fn match_token(token: &str) -> Option { + // Muse Code's `muse` launcher execs a versioned binary, + // `muse-bin-`, so that is the name the process runs under. + let token = if token.starts_with("muse-bin-") { + "muse" + } else { + token + }; CLIAgent::ALL .into_iter() .find(|a| a.aliases().contains(&token)) @@ -1535,7 +1562,9 @@ mod tests { "vibe", "antigravity", "empryo", - "prime-agent" + "prime-agent", + "muse", + "jcode" ] ); assert!( @@ -1552,6 +1581,54 @@ mod tests { } } + #[test] + fn muse_and_jcode_are_detected_and_resume() { + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["muse", "--yolo"])), + Some(CLIAgent::Muse) + ); + // The `muse` launcher execs a versioned binary. + assert_eq!( + CLIAgent::detect_from_argv(&argv(&[ + "/home/me/.local/share/muse/muse-bin-1.4.2", + "--yolo" + ])), + Some(CLIAgent::Muse) + ); + assert_eq!( + CLIAgent::detect_from_argv(&argv(&["/home/me/.jcode/builds/stable/jcode"])), + Some(CLIAgent::Jcode) + ); + assert_eq!(CLIAgent::from_slug("muse"), Some(CLIAgent::Muse)); + assert_eq!(CLIAgent::from_slug("jcode"), Some(CLIAgent::Jcode)); + assert_eq!( + CLIAgent::Muse + .resume_command("s-1", Some(&argv(&["muse", "--yolo"]))) + .as_deref(), + Some("muse --yolo resume s-1") + ); + assert_eq!( + CLIAgent::Muse + .resume_command("s-1", Some(&argv(&["muse", "resume", "old", "--yolo"]))) + .as_deref(), + Some("muse --yolo resume s-1") + ); + assert_eq!( + CLIAgent::Muse + .resume_command("s-1", Some(&argv(&["muse", "resume", "--last", "--yolo"]))) + .as_deref(), + Some("muse --yolo resume s-1") + ); + assert_eq!( + CLIAgent::Jcode + .resume_command("s-1", Some(&argv(&["jcode", "--resume", "old"]))) + .as_deref(), + Some("jcode --resume s-1") + ); + assert_eq!(CLIAgent::Muse.fork_command("s-1", None), None); + assert_eq!(CLIAgent::Jcode.fork_command("s-1", None), None); + } + #[test] fn empryo_and_prime_agent_are_detected_and_resume() { assert_eq!( diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 041c1127..9b89e952 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -2563,12 +2563,13 @@ impl DaemonPane { Ok(()) } - /// This pane as a Codex report sees it, for [`codex_report_target`]. - pub fn codex_candidate(&self) -> CodexCandidate { + /// This pane as a report from `agent`'s shared server sees it, for + /// [`codex_report_target`]. + pub fn codex_candidate(&self, agent: crate::core::cli_agent::CLIAgent) -> CodexCandidate { let st = self.state.lock().unwrap(); CodexCandidate { pane: self.id, - runs_codex: st.alive && st.agent == Some(crate::core::cli_agent::CLIAgent::Codex), + runs_codex: st.alive && st.agent == Some(agent), cwd: st.cwd.clone(), session: st.agent_session.as_ref().and_then(|s| s.session_id.clone()), } @@ -3614,6 +3615,11 @@ pub struct CodexCandidate { /// started (`cleared`) goes to one that had a session before and any other to /// one yet to report, and the named pane breaks a tie that still stands. /// `None` is no pane, or no telling which. +/// +/// jcode is built the same way — its sessions share one background server +/// that runs every session's hooks with the environment of whichever client +/// started it — so its reports take the same road, with `runs_codex` meaning +/// "runs jcode". pub fn codex_report_target( panes: &[CodexCandidate], named: u64, diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 7a196115..dd230aed 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -126,21 +126,22 @@ impl crate::host::server::PaneDirectory for Registry { /// so being up to a minute late to notice costs nothing. const AGENT_STALE_SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60); -/// The pane a Codex hook's report is about, found from the report itself — -/// where a hook runs says nothing for Codex (see -/// [`crate::daemon::pane::codex_report_target`]). `None` for any other agent's -/// report, which goes to the pane the hook named. +/// The pane a Codex or jcode hook's report is about, found from the report +/// itself — where a hook runs says nothing for an agent that runs its hooks in +/// a shared server (see [`crate::daemon::pane::codex_report_target`]). `None` +/// for any other agent's report, which goes to the pane the hook named. fn codex_report_pane( registry: &Registry, named: u64, body: &str, ) -> Option<(Arc, crate::core::cli_agent::AgentEvent)> { let event = crate::core::cli_agent::parse_agent_event_body(body.as_bytes())?; - if event.agent != Some(crate::core::cli_agent::CLIAgent::Codex) { - return None; - } + use crate::core::cli_agent::CLIAgent; + let agent = event + .agent + .filter(|a| matches!(a, CLIAgent::Codex | CLIAgent::Jcode))?; let panes = registry.all(); - let candidates: Vec<_> = panes.iter().map(|p| p.codex_candidate()).collect(); + let candidates: Vec<_> = panes.iter().map(|p| p.codex_candidate(agent)).collect(); let target = crate::daemon::pane::codex_report_target( &candidates, named, diff --git a/docs/agents/overview.mdx b/docs/agents/overview.mdx index 8f6e99a0..af211597 100644 --- a/docs/agents/overview.mdx +++ b/docs/agents/overview.mdx @@ -1,6 +1,6 @@ --- title: "Coding agents" -description: "What tty7 does around Claude Code, Codex, TraeCode, and 21 others — without ever wrapping them." +description: "What tty7 does around Claude Code, Codex, TraeCode, and 25 others — without ever wrapping them." --- tty7 recognises coding agents running in a pane and builds around them. It does @@ -15,7 +15,7 @@ need, and what changed. ## Which agents -Twenty-six CLIs are recognised on sight, by the command running in the pane: +Twenty-eight CLIs are recognised on sight, by the command running in the pane: | Agent | Command | |---|---| @@ -45,6 +45,8 @@ Twenty-six CLIs are recognised on sight, by the command running in the pane: | Oh My Pi | `omp` | | Prime Agent | `prime-agent` | | Empryo | `empryo` | +| Muse Code | `muse`, `muse-bin-` | +| jcode | `jcode` | Detection sees through the usual disguises: a full path, a `.cmd` or `.exe` on Windows, leading environment assignments, and an interpreter in front @@ -67,7 +69,7 @@ If you launch agents through a wrapper script, map its name to an agent in The key is your command's name; the value is one of the slugs above (`claude`, `codex`, `traecli`, `gemini`, `aider`, `amp`, `opencode`, `copilot`, `cursor`, `goose`, `droid`, `pi`, `auggie`, `hermes`, `vibe`, `antigravity`, `grok`, `qwen`, -`omp`, `kimi`, `qodercli`, `qoderclicn`, `crush`, `codebuddy`, `empryo`, `prime-agent`). +`omp`, `kimi`, `qodercli`, `qoderclicn`, `crush`, `codebuddy`, `empryo`, `prime-agent`, `muse`, `jcode`). ## Quick launch diff --git a/docs/agents/status.mdx b/docs/agents/status.mdx index 874e4ab3..2601d5f9 100644 --- a/docs/agents/status.mdx +++ b/docs/agents/status.mdx @@ -14,8 +14,8 @@ the agent say which one it is. | Agent | | |---|---| -| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code · Qoder CLI · Qoder CN CLI · Crush · CodeBuddy · Cursor CLI · Prime Agent · Antigravity | Hooks available | -| Aider · Amp · Auggie · Hermes · Vibe · Empryo | Detected and labelled, but no status channel yet | +| Claude Code · Codex · TraeCode · Copilot CLI · OpenCode · Pi · Grok Build · Oh My Pi · Gemini · Droid · Qwen Code · Goose · Kimi Code · Qoder CLI · Qoder CN CLI · Crush · CodeBuddy · Cursor CLI · Prime Agent · Antigravity · Empryo · Muse Code · jcode | Hooks available | +| Aider · Amp · Auggie · Hermes · Vibe | Detected and labelled, but no status channel yet | Installing writes into that agent's own configuration directory. Once installed the row grows a second **Uninstall** button beside the first, which itself diff --git a/docs/index.mdx b/docs/index.mdx index 7410f346..246ce2d2 100644 --- a/docs/index.mdx +++ b/docs/index.mdx @@ -436,7 +436,7 @@ export const FEATURES = [ p: "Ghost suggestions from your history, tab completion that explains each flag, syntax highlighting, click-to-place caret, real multi-line editing." }, { t: "Agent-aware", href: "/agents/overview", d: "M8 8h8a3 3 0 0 1 3 3v5a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3v-5a3 3 0 0 1 3-3zM12 4v4M9.5 13h.01M14.5 13h.01", - p: "26 coding CLIs recognised on sight. Per-pane status dots, a notification when one needs you, and session resume after a reboot." }, + p: "28 coding CLIs recognised on sight. Per-pane status dots, a notification when one needs you, and session resume after a reboot." }, { t: "Remote work that feels local", href: "/remote/workspaces", d: "M5 4h14a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2zM5 13h14a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2zM7 7.5h.01M7 16.5h.01", p: "A native Rust SSH stack with profiles, SFTP, and port forwarding — plus remote workspaces where files, repos, and panes stay on the far machine." }, diff --git a/src/ui/app.rs b/src/ui/app.rs index 2a9bba62..613ccf56 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -8590,6 +8590,10 @@ impl Tty7App { use crate::core::agent_hooks::HookOutcome as O; match outcome { O::Installed => t(L10nKey::AppAgentHooksInstalled).to_string(), + O::MuseInstallManually(command) => t_fmt( + L10nKey::AppAgentHooksMuseManualInstall, + &[("command", command)], + ), O::InstalledEnableCodexThere => { t(L10nKey::AppAgentHooksInstalledEnableCodexThere).to_string() } diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 2bd1c072..6706d6cb 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -924,6 +924,11 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsAgentCursorCli => "Cursor CLI", L10nKey::SettingsAgentPrimeAgent => "Prime Agent", L10nKey::SettingsAgentAntigravity => "Antigravity", + L10nKey::SettingsAgentEmpryo => "Empryo", + L10nKey::SettingsAgentJcode => "jcode", + L10nKey::SettingsAgentMuse => "Muse Code", + L10nKey::SettingsMuseManualInstall => "Manual install required", + L10nKey::AppAgentHooksMuseManualInstall => "Run on the remote host: {command}", L10nKey::SettingsAgentQoderCn => "Qoder CN CLI", L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github", L10nKey::SettingsSearchAppHttpProxyKeywords => { @@ -1040,6 +1045,9 @@ pub fn translate_en(key: L10nKey) -> &'static str { L10nKey::SettingsSearchPrimeAgentKeywords => { "agent integration extension install prime prime-agent primeintellect" } + L10nKey::SettingsSearchEmpryoKeywords => "agent integration hooks install empryo", + L10nKey::SettingsSearchJcodeKeywords => "agent integration hooks install jcode", + L10nKey::SettingsSearchMuseKeywords => "agent integration hooks plugins install muse meta", L10nKey::SettingsSearchAntigravityKeywords => { "agent integration hooks install antigravity agy google" } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index bc187947..ab562853 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -927,6 +927,11 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentCursorCli => "Cursor CLI", L10nKey::SettingsAgentPrimeAgent => "Prime Agent", L10nKey::SettingsAgentAntigravity => "Antigravity", + L10nKey::SettingsAgentEmpryo => "Empryo", + L10nKey::SettingsAgentJcode => "jcode", + L10nKey::SettingsAgentMuse => "Muse Code", + L10nKey::SettingsMuseManualInstall => "手動インストールが必要", + L10nKey::AppAgentHooksMuseManualInstall => "対象のマシンで実行: {command}", L10nKey::SettingsAgentQoderCn => "Qoder CN CLI", L10nKey::SettingsSearchAboutKeywords => { "バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check" @@ -1089,6 +1094,15 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchPrimeAgentKeywords => { "エージェント 統合 拡張 インストール prime prime-agent primeintellect agent integration extension install" } + L10nKey::SettingsSearchEmpryoKeywords => { + "エージェント 統合 フック インストール empryo agent integration hooks install" + } + L10nKey::SettingsSearchJcodeKeywords => { + "エージェント 統合 フック インストール jcode agent integration hooks install" + } + L10nKey::SettingsSearchMuseKeywords => { + "エージェント 統合 フック プラグイン インストール muse meta agent integration hooks plugins install" + } L10nKey::SettingsSearchAntigravityKeywords => { "エージェント 統合 フック インストール antigravity agy google agent integration hooks install" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 353753da..8c8a6847 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -773,6 +773,10 @@ l10n_keys! { SettingsAgentCursorCli, SettingsAgentPrimeAgent, SettingsAgentAntigravity, + SettingsAgentEmpryo, + SettingsAgentJcode, + SettingsAgentMuse, + SettingsMuseManualInstall, SettingsAgentQoderCn, SettingsSearchAppHttpProxyKeywords, SettingsSearchAboutKeywords, @@ -837,6 +841,9 @@ l10n_keys! { SettingsSearchCursorCliKeywords, SettingsSearchPrimeAgentKeywords, SettingsSearchAntigravityKeywords, + SettingsSearchEmpryoKeywords, + SettingsSearchJcodeKeywords, + SettingsSearchMuseKeywords, SettingsSearchQoderCnKeywords, SettingsSearchRememberWindowSizeKeywords, SettingsSearchReportMouseToAppsKeywords, @@ -1722,6 +1729,7 @@ l10n_keys! { AppAgentHooksOpFailed, AppAgentHooksInstalled, AppAgentHooksInstalledEnableCodexThere, + AppAgentHooksMuseManualInstall, AppAgentHooksInstalledCodexEnableFailed, AppAgentHooksRemoved, AppAgentHooksNothingInstalled, @@ -2059,6 +2067,9 @@ mod tests { L10nKey::SettingsAgentCursorCli, L10nKey::SettingsAgentPrimeAgent, L10nKey::SettingsAgentAntigravity, + L10nKey::SettingsAgentEmpryo, + L10nKey::SettingsAgentJcode, + L10nKey::SettingsAgentMuse, L10nKey::SettingsAgentQoderCn, // Windows names its backdrop materials, and Japanese Windows keeps // those names in Latin script — so does this list. Chinese does diff --git a/src/ui/i18n/ru.rs b/src/ui/i18n/ru.rs index b801289d..44535aa9 100644 --- a/src/ui/i18n/ru.rs +++ b/src/ui/i18n/ru.rs @@ -982,6 +982,11 @@ pub fn translate_ru(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentCursorCli => "Cursor CLI", L10nKey::SettingsAgentPrimeAgent => "Prime Agent", L10nKey::SettingsAgentAntigravity => "Antigravity", + L10nKey::SettingsAgentEmpryo => "Empryo", + L10nKey::SettingsAgentJcode => "jcode", + L10nKey::SettingsAgentMuse => "Muse Code", + L10nKey::SettingsMuseManualInstall => "Требуется ручная установка", + L10nKey::AppAgentHooksMuseManualInstall => "Выполните на удалённом компьютере: {command}", L10nKey::SettingsAgentQoderCn => "Qoder CN CLI", L10nKey::SettingsSearchAppHttpProxyKeywords => { "прокси сеть загрузка обновление proxy http https socks socks5 clash v2ray network download update" @@ -1162,6 +1167,15 @@ pub fn translate_ru(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchAntigravityKeywords => { "агент интеграция хуки установка agent integration hooks install antigravity agy google" } + L10nKey::SettingsSearchEmpryoKeywords => { + "агент интеграция хуки установка empryo agent integration hooks install" + } + L10nKey::SettingsSearchJcodeKeywords => { + "агент интеграция хуки установка jcode agent integration hooks install" + } + L10nKey::SettingsSearchMuseKeywords => { + "агент интеграция хуки плагины установка muse meta agent integration hooks plugins install" + } L10nKey::SettingsSearchQoderCnKeywords => { "агент интеграция хуки установка китайский agent integration hooks install qodercn qoderclicn qoder-cn qoder china" } diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 3523462e..4fdd0ef8 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -805,6 +805,11 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsAgentCursorCli => "Cursor CLI", L10nKey::SettingsAgentPrimeAgent => "Prime Agent", L10nKey::SettingsAgentAntigravity => "Antigravity", + L10nKey::SettingsAgentEmpryo => "Empryo", + L10nKey::SettingsAgentJcode => "jcode", + L10nKey::SettingsAgentMuse => "Muse Code", + L10nKey::SettingsMuseManualInstall => "需要手动安装", + L10nKey::AppAgentHooksMuseManualInstall => "请在目标机器上运行: {command}", L10nKey::SettingsAgentQoderCn => "Qoder CN CLI", L10nKey::SettingsSearchAboutKeywords => { "关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update" @@ -961,6 +966,9 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SettingsSearchPrimeAgentKeywords => { "Prime Agent agent 集成 扩展 安装 prime prime-agent primeintellect" } + L10nKey::SettingsSearchEmpryoKeywords => "Empryo agent 集成 钩子 安装 empryo", + L10nKey::SettingsSearchJcodeKeywords => "jcode agent 集成 钩子 安装 jcode", + L10nKey::SettingsSearchMuseKeywords => "Muse Code agent 集成 钩子 插件 安装 muse meta", L10nKey::SettingsSearchAntigravityKeywords => { "Antigravity agent 集成 钩子 安装 antigravity agy google" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 816038ac..c83e8986 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -654,6 +654,21 @@ fn settings_search_entries() -> &'static [SearchEntry] { title: SettingsAgentAntigravity, keywords: SettingsSearchAntigravityKeywords, }, + SearchEntry { + section: Agents, + title: SettingsAgentEmpryo, + keywords: SettingsSearchEmpryoKeywords, + }, + SearchEntry { + section: Agents, + title: SettingsAgentJcode, + keywords: SettingsSearchJcodeKeywords, + }, + SearchEntry { + section: Agents, + title: SettingsAgentMuse, + keywords: SettingsSearchMuseKeywords, + }, SearchEntry { section: General, title: SettingsStartupWindow, diff --git a/src/ui/settings/agents.rs b/src/ui/settings/agents.rs index 62034039..4042fa3e 100644 --- a/src/ui/settings/agents.rs +++ b/src/ui/settings/agents.rs @@ -31,6 +31,9 @@ fn cli_agent(agent: HookAgent) -> crate::core::cli_agent::CLIAgent { HookAgent::Cursor => C::Cursor, HookAgent::PrimeAgent => C::PrimeAgent, HookAgent::Antigravity => C::Antigravity, + HookAgent::Empryo => C::Empryo, + HookAgent::Jcode => C::Jcode, + HookAgent::Muse => C::Muse, } } @@ -345,8 +348,14 @@ impl Tty7App { let tk = Tk::of(cx); let agent = row.agent; let mark = cli_agent(agent); + let manual_muse = agent == HookAgent::Muse + && self + .active_settings() + .is_some_and(|s| !s.agent_hooks_host.is_local()); let (status, dot) = if busy { (Some(t(L10nKey::SettingsWorking)), tk.k3) + } else if manual_muse { + (Some(t(L10nKey::SettingsMuseManualInstall)), tk.warn) } else { match row.state { HooksState::Installed => (Some(t(L10nKey::SettingsStatusInstalled)), tk.ok),