feat(agents): Muse Code and jcode support, native hooks for Empryo, Muse Code and jcode (#1071)

* feat(agents): recognise Muse Code and jcode

Muse Code's `muse` launcher execs a versioned `muse-bin-<version>`
binary, so detection also maps that name to Muse. Muse resumes with
`muse resume <id>` (root options on either side of the subcommand);
jcode with `jcode --resume <id>`.

* feat(agents): install native Empryo and jcode hooks

* feat(agents): add jcode lifecycle hooks

* fix(agents): preserve jcode hooks and correct Empryo removal contract

* feat(agents): install Muse native plugin hooks

* docs(agents): list Empryo, Muse Code and jcode as hook-backed

* fix(i18n): translate Empryo, Muse Code and jcode search keywords

* fix(agents): read jcode hook payload from env, route its reports like Codex, bound muse CLI calls

jcode runs hooks with stdin closed and puts session_id/cwd in
JCODE_HOOK_PAYLOAD, so reports carried no session and resume never
learned an id. Its shared server also runs every session's hooks with
the first client's TTY7_PANE, so reports go through the same
session/directory routing as Codex. muse plugins calls now time out
after 30s and surface stderr. Adds ru strings for the new keys and
reattaches Kimi's doc comment to KIMI_HOOK_EVENTS.

* docs: count Muse Code and jcode in the README agent totals

---------

Co-authored-by: kalpakprod <307643502+kalpakprod@users.noreply.github.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
kalpakprod
2026-10-03 20:47:25 +08:00
committed by GitHub
co-authored by kalpakprod l0ng-ai
parent 73cd393590
commit d3f7dadd5d
17 changed files with 857 additions and 32 deletions
+5 -3
View File
@@ -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 · <kbd>⌃ R</kbd> fuzzy history |
| **Window** | tabs & splits · <kbd>⌘ P</kbd> Search Everywhere · <kbd>⌘ F</kbd> 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 <kbd>⏎</kbd> resumes one.
<details>
<summary>The full support matrix, all twenty-six</summary>
<summary>The full support matrix, all twenty-eight</summary>
| Agent | Detected | Status · resume | Fork | Past sessions |
|---|:-:|:-:|:-:|:-:|
@@ -95,12 +95,14 @@ own history files and listed in Search Everywhere, where <kbd>⏎</kbd> resumes
| **Crush** | ✓ | ✓ | | |
| **Antigravity** | ✓ | ✓ | | |
| **Cursor** | ✓ | ✓ | | ✓ |
| **Empryo** | ✓ | ✓ | | |
| **Muse Code** | ✓ | ✓ | | |
| **jcode** | ✓ | ✓ | | |
| Aider | ✓ | | | |
| Amp | ✓ | | | |
| Auggie | ✓ | | | |
| Hermes | ✓ | | | |
| Vibe | ✓ | | | |
| Empryo | ✓ | | | |
</details>
+5 -3
View File
@@ -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 补全附带说明 · 语法高亮 · 多行编辑 · 点击定位光标 · <kbd>⌃ R</kbd> 模糊搜索历史 |
| **窗口** | 标签页与分屏 · <kbd>⌘ P</kbd> 随处搜索 · <kbd>⌘ F</kbd> 回滚搜索 · 休眠标签页以释放内存 · 输入法 |
@@ -68,7 +68,7 @@ macOS、Windows、Linux 的原生构建都在 [**Releases**](https://github.com/
**历史会话**从 agent 自己的历史文件读取,列在随处搜索里,<kbd>⏎</kbd> 即可续上。
<details>
<summary>26 个 agent 的完整支持矩阵</summary>
<summary>28 个 agent 的完整支持矩阵</summary>
| 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 | ✓ | | | |
</details>
+657 -5
View File
@@ -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>) -> 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<HookOutcome> {
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<Ho
pub fn uninstall_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result<HookOutcome> {
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<serde_json::Value> {
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<serde_json::Value>,
) -> anyhow::Result<HookOutcome> {
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<HookOutcome> {
muse_install_with(target, path, muse_cli)
}
fn muse_hooks_uninstall(target: &HookTarget, path: &Path) -> anyhow::Result<HookOutcome> {
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<Vec<String>> {
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::<toml_edit::DocumentMut>() 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<HookOutcome> {
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<String> {
| 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()));
+81 -4
View File
@@ -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 <id>`; `--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<CLIAgent> {
// Muse Code's `muse` launcher execs a versioned binary,
// `muse-bin-<version>`, 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!(
+9 -3
View File
@@ -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,
+9 -8
View File
@@ -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<DaemonPane>, 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,
+5 -3
View File
@@ -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-<version>` |
| 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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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." },
+4
View File
@@ -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()
}
+8
View File
@@ -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"
}
+14
View File
@@ -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"
}
+11
View File
@@ -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
+14
View File
@@ -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"
}
+8
View File
@@ -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"
}
+15
View File
@@ -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,
+9
View File
@@ -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),