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),