diff --git a/assets/icons/agents/qwen.svg b/assets/icons/agents/qwen.svg
new file mode 100644
index 00000000..efb2e4f3
--- /dev/null
+++ b/assets/icons/agents/qwen.svg
@@ -0,0 +1,6 @@
+
diff --git a/crates/tty7-core/src/core/agent_hooks.rs b/crates/tty7-core/src/core/agent_hooks.rs
index 93f3837a..545fe1c2 100644
--- a/crates/tty7-core/src/core/agent_hooks.rs
+++ b/crates/tty7-core/src/core/agent_hooks.rs
@@ -43,9 +43,12 @@ fn effective_agent(agent: &str, ran_by_grok: bool) -> &str {
}
fn effective_event<'a>(agent: &str, event: &'a str, stdin_json: &str) -> Option<&'a str> {
- if matches!(agent, "copilot" | "grok") && event == "notification" {
+ if matches!(agent, "copilot" | "grok" | "droid" | "gemini") && event == "notification" {
let blocks = stdin_json.contains("elicitation_dialog")
- || (agent == "copilot" && stdin_json.contains("permission_prompt"));
+ || (matches!(agent, "copilot" | "droid") && stdin_json.contains("permission_prompt"))
+ // Gemini's only notification kind so far, but naming it keeps a
+ // future non-blocking one from being read as a block.
+ || (agent == "gemini" && stdin_json.contains("ToolPermission"));
return blocks.then_some("permission-request");
}
Some(event)
@@ -63,6 +66,8 @@ fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec {
("session_id", "sessionId"),
("message", "message"),
("cwd", "cwd"),
+ // Goose spells the working directory its own way.
+ ("cwd", "working_dir"),
] {
if let Some(v) = payload
.get(key)
@@ -214,10 +219,14 @@ pub enum HookAgent {
Pi,
Grok,
OhMyPi,
+ Gemini,
+ Droid,
+ Qwen,
+ Goose,
}
impl HookAgent {
- pub const ALL: [HookAgent; 7] = [
+ pub const ALL: [HookAgent; 11] = [
HookAgent::Claude,
HookAgent::Codex,
HookAgent::Copilot,
@@ -225,6 +234,10 @@ impl HookAgent {
HookAgent::Pi,
HookAgent::Grok,
HookAgent::OhMyPi,
+ HookAgent::Gemini,
+ HookAgent::Droid,
+ HookAgent::Qwen,
+ HookAgent::Goose,
];
/// The hooks behind a detected agent process, if it has any.
@@ -241,17 +254,36 @@ impl HookAgent {
CLIAgent::Pi => Some(HookAgent::Pi),
CLIAgent::Grok => Some(HookAgent::Grok),
CLIAgent::OhMyPi => Some(HookAgent::OhMyPi),
- CLIAgent::Gemini
- | CLIAgent::Aider
+ CLIAgent::Gemini => Some(HookAgent::Gemini),
+ CLIAgent::Droid => Some(HookAgent::Droid),
+ CLIAgent::Qwen => Some(HookAgent::Qwen),
+ CLIAgent::Goose => Some(HookAgent::Goose),
+ CLIAgent::Aider
| CLIAgent::Amp
| CLIAgent::Cursor
- | CLIAgent::Goose
- | CLIAgent::Droid
| CLIAgent::Auggie
| CLIAgent::Hermes
| CLIAgent::Vibe
- | CLIAgent::Antigravity
- | CLIAgent::Qwen => None,
+ | CLIAgent::Antigravity => None,
+ }
+ }
+
+ /// The events this agent's hooks merge into a shared JSON config, if that
+ /// is how it takes them. `None` means the agent owns a generated file
+ /// instead — see [`owned_file_content`].
+ fn hook_map_events(self) -> Option<&'static [(&'static str, &'static str)]> {
+ match self {
+ HookAgent::Claude => Some(CLAUDE_HOOK_EVENTS),
+ HookAgent::Codex => Some(CODEX_HOOK_EVENTS),
+ HookAgent::Gemini => Some(GEMINI_HOOK_EVENTS),
+ HookAgent::Droid => Some(DROID_HOOK_EVENTS),
+ HookAgent::Qwen => Some(QWEN_HOOK_EVENTS),
+ HookAgent::Copilot
+ | HookAgent::OpenCode
+ | HookAgent::Pi
+ | HookAgent::Grok
+ | HookAgent::OhMyPi
+ | HookAgent::Goose => None,
}
}
@@ -264,6 +296,10 @@ impl HookAgent {
HookAgent::Pi => "pi",
HookAgent::Grok => "grok",
HookAgent::OhMyPi => "omp",
+ HookAgent::Gemini => "gemini",
+ HookAgent::Droid => "droid",
+ HookAgent::Qwen => "qwen",
+ HookAgent::Goose => "goose",
}
}
@@ -276,6 +312,10 @@ impl HookAgent {
HookAgent::Pi => "Pi",
HookAgent::Grok => "Grok Build",
HookAgent::OhMyPi => "Oh My Pi",
+ HookAgent::Gemini => "Gemini",
+ HookAgent::Droid => "Droid",
+ HookAgent::Qwen => "Qwen Code",
+ HookAgent::Goose => "Goose",
}
}
@@ -297,6 +337,15 @@ impl HookAgent {
HookAgent::OhMyPi => {
target.under_home(&[".omp", "agent", "extensions", "tty7", "index.ts"])
}
+ HookAgent::Gemini => target.under_home(&[".gemini", "settings.json"]),
+ HookAgent::Droid => target.under_home(&[".factory", "settings.json"]),
+ HookAgent::Qwen => target.under_home(&[".qwen", "settings.json"]),
+ // The Open Plugins layout, which Goose implements rather than
+ // inventing its own: any `.agents/plugins//hooks/hooks.json`
+ // is picked up at startup.
+ HookAgent::Goose => {
+ target.under_home(&[".agents", "plugins", "tty7", "hooks", "hooks.json"])
+ }
}
}
@@ -449,20 +498,13 @@ pub enum HooksState {
pub fn hooks_state(target: &HookTarget, agent: HookAgent) -> HooksState {
let path = agent.target_path(target);
- match agent {
- HookAgent::Claude => hook_map_state(target, &path, agent, CLAUDE_HOOK_EVENTS),
- HookAgent::Codex => hook_map_state(target, &path, agent, CODEX_HOOK_EVENTS),
- HookAgent::Copilot
- | HookAgent::OpenCode
- | HookAgent::Pi
- | HookAgent::Grok
- | HookAgent::OhMyPi => {
- let Some(expected) = owned_file_content(target, agent) else {
- return HooksState::NotInstalled;
- };
- owned_file_state(target, &path, &expected, &agent.marker())
- }
+ if let Some(events) = agent.hook_map_events() {
+ return hook_map_state(target, &path, agent, events);
}
+ let Some(expected) = owned_file_content(target, agent) else {
+ return HooksState::NotInstalled;
+ };
+ owned_file_state(target, &path, &expected, &agent.marker())
}
/// What an install or uninstall actually did.
@@ -489,43 +531,30 @@ pub enum HookOutcome {
pub fn install_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result {
let path = agent.target_path(target);
- match agent {
- HookAgent::Claude => {
- hook_map_install(target, &path, agent, CLAUDE_HOOK_EVENTS)?;
- Ok(HookOutcome::Installed)
+ if let Some(events) = agent.hook_map_events() {
+ hook_map_install(target, &path, agent, events)?;
+ if agent != HookAgent::Codex {
+ return Ok(HookOutcome::Installed);
}
- HookAgent::Codex => {
- hook_map_install(target, &path, agent, CODEX_HOOK_EVENTS)?;
- if !target.is_local() {
- return Ok(HookOutcome::InstalledEnableCodexThere);
- }
- Ok(match enable_codex_hooks_feature() {
- Ok(()) => HookOutcome::Installed,
- Err(e) => HookOutcome::InstalledCodexEnableFailed(e.to_string()),
- })
- }
- HookAgent::Copilot
- | HookAgent::OpenCode
- | HookAgent::Pi
- | HookAgent::Grok
- | HookAgent::OhMyPi => {
- let content = owned_file_content(target, agent)
- .ok_or_else(|| anyhow::anyhow!("{agent:?} has no owned file"))?;
- owned_file_install(target, &path, &content, &agent.marker())?;
- Ok(HookOutcome::Installed)
+ if !target.is_local() {
+ return Ok(HookOutcome::InstalledEnableCodexThere);
}
+ return Ok(match enable_codex_hooks_feature() {
+ Ok(()) => HookOutcome::Installed,
+ Err(e) => HookOutcome::InstalledCodexEnableFailed(e.to_string()),
+ });
}
+ let content = owned_file_content(target, agent)
+ .ok_or_else(|| anyhow::anyhow!("{agent:?} has no owned file"))?;
+ owned_file_install(target, &path, &content, &agent.marker())?;
+ Ok(HookOutcome::Installed)
}
pub fn uninstall_hooks(target: &HookTarget, agent: HookAgent) -> anyhow::Result {
let path = agent.target_path(target);
- match agent {
- HookAgent::Claude | HookAgent::Codex => hook_map_uninstall(target, &path, agent),
- HookAgent::Copilot
- | HookAgent::OpenCode
- | HookAgent::Pi
- | HookAgent::Grok
- | HookAgent::OhMyPi => owned_file_uninstall(target, &path, &agent.marker()),
+ match agent.hook_map_events() {
+ Some(_) => hook_map_uninstall(target, &path, agent),
+ None => owned_file_uninstall(target, &path, &agent.marker()),
}
}
@@ -600,6 +629,40 @@ const CODEX_HOOK_EVENTS: &[(&str, &str)] = &[
("Stop", "stop"),
];
+/// Gemini names the turn boundaries after the agent rather than the user, and
+/// omitting `matcher` matches everything (`hookPlanner.ts`, `!entry.matcher`),
+/// so the bare entries [`hook_map_install`] already writes are enough.
+const GEMINI_HOOK_EVENTS: &[(&str, &str)] = &[
+ ("SessionStart", "session-start"),
+ ("BeforeAgent", "prompt-submit"),
+ ("Notification", "notification"),
+ ("AfterTool", "tool-complete"),
+ ("AfterAgent", "stop"),
+ ("SessionEnd", "session-end"),
+];
+
+const DROID_HOOK_EVENTS: &[(&str, &str)] = &[
+ ("SessionStart", "session-start"),
+ ("UserPromptSubmit", "prompt-submit"),
+ ("Notification", "notification"),
+ ("PostToolUse", "tool-complete"),
+ ("Stop", "stop"),
+ ("SessionEnd", "session-end"),
+];
+
+/// Qwen is the only agent here with a first-class permission event, so it needs
+/// none of the notification sniffing in [`effective_event`] — and it gets no
+/// `Notification` hook at all, which would only muddy a status the dedicated
+/// event already reports precisely.
+const QWEN_HOOK_EVENTS: &[(&str, &str)] = &[
+ ("SessionStart", "session-start"),
+ ("UserPromptSubmit", "prompt-submit"),
+ ("PermissionRequest", "permission-request"),
+ ("PostToolUse", "tool-complete"),
+ ("Stop", "stop"),
+ ("SessionEnd", "session-end"),
+];
+
const GROK_HOOK_TIMEOUT_SECS: u32 = 10;
const GROK_HOOK_EVENTS: &[(&str, &str, Option<&str>)] = &[
@@ -809,7 +872,12 @@ fn owned_file_content(target: &HookTarget, agent: HookAgent) -> Option {
HookAgent::OpenCode => opencode_plugin_js(target),
HookAgent::Pi | HookAgent::OhMyPi => pi_extension_ts(target, agent),
HookAgent::Grok => grok_hooks_json(target),
- HookAgent::Claude | HookAgent::Codex => None,
+ HookAgent::Goose => goose_hooks_json(target),
+ HookAgent::Claude
+ | HookAgent::Codex
+ | HookAgent::Gemini
+ | HookAgent::Droid
+ | HookAgent::Qwen => None,
}
}
@@ -862,10 +930,26 @@ fn owned_file_uninstall(
));
}
target.host.remove(path, false)?;
- if let Some(parent) = path.parent()
- && parent.file_name().is_some_and(|n| n == "tty7")
- {
- let _ = target.host.remove(parent, false);
+ // Take the directories tty7 generated with it, innermost first, stopping at
+ // the one named after tty7. `remove` is not recursive, so a directory still
+ // holding someone else's file simply survives the attempt. Goose nests one
+ // level deeper than the rest (`.../tty7/hooks/hooks.json`), which is why
+ // this walks rather than checking a single parent.
+ let mut dir = path.parent();
+ while let Some(d) = dir {
+ if d.file_name().is_some_and(|n| n == "tty7") {
+ let _ = target.host.remove(d, false);
+ break;
+ }
+ if !d
+ .parent()
+ .and_then(|p| p.file_name())
+ .is_some_and(|n| n == "tty7")
+ {
+ break;
+ }
+ let _ = target.host.remove(d, false);
+ dir = d.parent();
}
Ok(HookOutcome::Removed)
}
@@ -909,6 +993,33 @@ fn grok_hooks_json(target: &HookTarget) -> Option {
serde_json::to_string_pretty(&serde_json::json!({ "hooks": hooks })).ok()
}
+/// Goose has no permission hook — `PreToolUse` fires on every call, approved or
+/// not, so there is nothing here that could report a blocked turn. The four
+/// events it does have still carry the pane from idle to working to done.
+const GOOSE_HOOK_EVENTS: &[(&str, &str)] = &[
+ ("SessionStart", "session-start"),
+ ("UserPromptSubmit", "prompt-submit"),
+ ("PostToolUse", "tool-complete"),
+ ("Stop", "stop"),
+ ("SessionEnd", "session-end"),
+];
+
+fn goose_hooks_json(target: &HookTarget) -> Option {
+ let mut hooks = serde_json::Map::new();
+ for (event, sentinel) in GOOSE_HOOK_EVENTS {
+ hooks.insert(
+ (*event).to_string(),
+ serde_json::json!([{
+ "hooks": [{
+ "type": "command",
+ "command": target.hook_command(HookAgent::Goose, sentinel),
+ }]
+ }]),
+ );
+ }
+ serde_json::to_string_pretty(&serde_json::json!({ "hooks": hooks })).ok()
+}
+
fn opencode_plugin_js(target: &HookTarget) -> Option {
let prefix = serde_json::to_string(&format!(
"{} ",
@@ -1121,6 +1232,10 @@ mod tests {
let mut events: Vec<&str> = CLAUDE_HOOK_EVENTS
.iter()
.chain(CODEX_HOOK_EVENTS)
+ .chain(GEMINI_HOOK_EVENTS)
+ .chain(DROID_HOOK_EVENTS)
+ .chain(QWEN_HOOK_EVENTS)
+ .chain(GOOSE_HOOK_EVENTS)
.map(|(_, e)| *e)
.chain(GROK_HOOK_EVENTS.iter().map(|(_, e, _)| *e))
.collect();
@@ -1140,6 +1255,139 @@ mod tests {
}
}
+ #[test]
+ fn the_new_hook_agents_target_the_paths_their_clis_read() {
+ let host = FakeRemote::shared();
+ let t = HookTarget::remote(&*host, PathBuf::from("/home/me"));
+
+ for (agent, want) in [
+ (HookAgent::Gemini, "/home/me/.gemini/settings.json"),
+ (HookAgent::Droid, "/home/me/.factory/settings.json"),
+ (HookAgent::Qwen, "/home/me/.qwen/settings.json"),
+ (
+ HookAgent::Goose,
+ "/home/me/.agents/plugins/tty7/hooks/hooks.json",
+ ),
+ ] {
+ assert_eq!(
+ agent.target_path(&t),
+ PathBuf::from(want),
+ "{} writes somewhere its CLI does not read",
+ agent.slug()
+ );
+ }
+
+ let dir = std::env::temp_dir().join(format!("tty7-new-hooks-{}", std::process::id()));
+ let _ = std::fs::remove_dir_all(&dir);
+ std::fs::create_dir_all(&dir).unwrap();
+ let real = HookTarget::remote(&*host, dir.clone());
+
+ for agent in [
+ HookAgent::Gemini,
+ HookAgent::Droid,
+ HookAgent::Qwen,
+ HookAgent::Goose,
+ ] {
+ assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled);
+ install_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug()));
+ assert_eq!(
+ hooks_state(&real, agent),
+ HooksState::Installed,
+ "{} does not read back what it wrote",
+ agent.slug()
+ );
+ let written = std::fs::read_to_string(agent.target_path(&real)).unwrap();
+ assert!(
+ written.contains(&format!("agent-hook {}", agent.slug())),
+ "{} wrote a config without its own emitter",
+ agent.slug()
+ );
+ uninstall_hooks(&real, agent).unwrap_or_else(|e| panic!("{}: {e}", agent.slug()));
+ assert_eq!(hooks_state(&real, agent), HooksState::NotInstalled);
+ }
+
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+
+ /// Qwen is the one agent that reports a blocked turn outright, so it must
+ /// not also carry the `Notification` hook the others need — that event fires
+ /// for non-blocking alerts too and would strand the pane on "waiting".
+ #[test]
+ fn qwen_reports_permission_requests_natively() {
+ assert!(
+ QWEN_HOOK_EVENTS
+ .iter()
+ .any(|(hook, tty7)| *hook == "PermissionRequest" && *tty7 == "permission-request")
+ );
+ assert!(
+ !QWEN_HOOK_EVENTS
+ .iter()
+ .any(|(hook, _)| *hook == "Notification")
+ );
+ assert_eq!(
+ effective_event("qwen", "permission-request", "{}"),
+ Some("permission-request")
+ );
+ }
+
+ #[test]
+ fn gemini_and_droid_notifications_filter_to_permission_requests() {
+ assert_eq!(
+ effective_event(
+ "gemini",
+ "notification",
+ r#"{"notification_type":"ToolPermission"}"#
+ ),
+ Some("permission-request")
+ );
+ assert_eq!(
+ effective_event(
+ "droid",
+ "notification",
+ r#"{"notification_type":"permission_prompt"}"#
+ ),
+ Some("permission-request")
+ );
+ // A non-blocking alert must not strand the pane on "waiting".
+ for agent in ["gemini", "droid"] {
+ assert_eq!(
+ effective_event(
+ agent,
+ "notification",
+ r#"{"notification_type":"auth_success"}"#
+ ),
+ None,
+ "{agent} reported an idle notification as a block"
+ );
+ }
+ }
+
+ #[test]
+ fn uninstalling_goose_takes_its_generated_plugin_dirs_with_it() {
+ let root = std::env::temp_dir().join(format!("tty7-goose-test-{}", std::process::id()));
+ let plugins = root.join("plugins");
+ let plugin = plugins.join("tty7");
+ let hooks_dir = plugin.join("hooks");
+ std::fs::create_dir_all(&hooks_dir).unwrap();
+ let path = hooks_dir.join("hooks.json");
+
+ let host = local_host();
+ let t = HookTarget::local(&*host).expect("home resolves in tests");
+ let content = goose_hooks_json(&t).expect("goose content builds");
+ assert!(content.contains("agent-hook goose"));
+ let marker = "agent-hook goose";
+
+ owned_file_install(&t, &path, &content, marker).expect("install");
+ owned_file_uninstall(&t, &path, marker).expect("uninstall");
+
+ assert!(!path.exists());
+ assert!(!hooks_dir.exists(), "the generated hooks/ dir goes too");
+ assert!(!plugin.exists(), "and the tty7 plugin dir above it");
+ assert!(plugins.exists(), "but never the shared plugins/ dir");
+
+ let _ = std::fs::remove_dir_all(&root);
+ }
+
#[test]
fn copilot_notifications_filter_to_permission_requests() {
assert_eq!(
diff --git a/crates/tty7-core/src/core/cli_agent.rs b/crates/tty7-core/src/core/cli_agent.rs
index 8a2d9161..577dbf28 100644
--- a/crates/tty7-core/src/core/cli_agent.rs
+++ b/crates/tty7-core/src/core/cli_agent.rs
@@ -62,7 +62,11 @@ impl CLIAgent {
CLIAgent::Auggie => &["auggie"],
CLIAgent::Hermes => &["hermes"],
CLIAgent::Vibe => &["vibe", "vibe-acp"],
- CLIAgent::Antigravity => &["agy", "antigravity"],
+ // `agy` only. The `antigravity` binary the IDE installs is a
+ // launcher shim in the shape of VS Code's `code`, not the terminal
+ // agent — and the name also collides with `python3 -m antigravity`,
+ // the standard way to trigger Python's own easter egg.
+ CLIAgent::Antigravity => &["agy"],
CLIAgent::Grok => &["grok"],
CLIAgent::Qwen => &["qwen", "qwen-code"],
// Oh My Pi is a fork of Pi, but it ships one binary of its own and
@@ -137,7 +141,16 @@ impl CLIAgent {
CLIAgent::Gemini => Some(format!("gemini{flags} --resume {session_id}")),
CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id}")),
CLIAgent::Amp => Some(format!("amp threads continue {session_id}{flags}")),
+ CLIAgent::Auggie => Some(format!("auggie{flags} --resume {session_id}")),
+ CLIAgent::Hermes => Some(format!("hermes chat{flags} --resume {session_id}")),
+ CLIAgent::Qwen => Some(format!("qwen{flags} --resume {session_id}")),
+ CLIAgent::Goose => Some(format!(
+ "goose session{flags} --resume --session-id {session_id}"
+ )),
+ CLIAgent::Vibe => Some(format!("vibe{flags} --resume {session_id}")),
+ CLIAgent::Antigravity => Some(format!("agy{flags} --conversation {session_id}")),
CLIAgent::Cursor => Some(format!("cursor-agent{flags} --resume {session_id}")),
+ CLIAgent::Droid => Some(format!("droid{flags} --resume {session_id}")),
CLIAgent::Copilot => Some(format!("copilot{flags} --resume {session_id}")),
CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id}")),
CLIAgent::Pi => Some(format!("pi{flags} --session {session_id}")),
@@ -149,6 +162,12 @@ impl CLIAgent {
fn opts_out_of_sessions(self, argv: &[String]) -> bool {
let ephemeral: &[&str] = match self {
CLIAgent::Pi | CLIAgent::OhMyPi => &["--no-session"],
+ // "Do not save conversation history" — nothing is persisted, so
+ // there is no session left to resume from.
+ CLIAgent::Auggie => &["--dont-save-session"],
+ // "If false, chat history is not saved and --continue/--resume
+ // will not work" — the yargs negation of `--chat-recording`.
+ CLIAgent::Qwen => &["--no-chat-recording"],
_ => &[],
};
argv.iter().any(|t| ephemeral.contains(&t.as_str()))
@@ -167,6 +186,16 @@ impl CLIAgent {
CLIAgent::Grok => Some(format!("grok{flags} --resume {session_id} --fork-session")),
CLIAgent::OpenCode => Some(format!("opencode{flags} --session {session_id} --fork")),
CLIAgent::OhMyPi => Some(format!("omp{flags} --fork {session_id}")),
+ // Droid forks with a standalone flag rather than resume-plus-a-switch.
+ CLIAgent::Droid => Some(format!("droid{flags} --fork {session_id}")),
+ // `fork` is missing from `amp threads --help`, but the subcommand is
+ // real — `amp threads fork --help` prints its own usage.
+ CLIAgent::Amp => Some(format!("amp threads fork {session_id}{flags}")),
+ CLIAgent::Qwen => Some(format!("qwen{flags} --resume {session_id} --fork-session")),
+ // Goose forks by adding a switch to the same resume invocation.
+ CLIAgent::Goose => Some(format!(
+ "goose session{flags} --resume --fork --session-id {session_id}"
+ )),
_ => None,
}
}
@@ -177,7 +206,11 @@ impl CLIAgent {
| CLIAgent::Codex
| CLIAgent::Grok
| CLIAgent::OpenCode
- | CLIAgent::OhMyPi => Some("Fork Session"),
+ | CLIAgent::OhMyPi
+ | CLIAgent::Droid
+ | CLIAgent::Amp
+ | CLIAgent::Qwen
+ | CLIAgent::Goose => Some("Fork Session"),
_ => None,
}
}
@@ -225,6 +258,33 @@ impl CLIAgent {
}
}
+ // Agents that reach their session through subcommands leave `stale`
+ // nothing to drop — `amp threads continue ` names the thread with a
+ // positional argument, and `goose session --resume` hides the flags one
+ // level down. Either way the prefix has to come off here, because the
+ // "a bare token must follow a flag" check below would otherwise reject
+ // the tail wholesale and take every launch flag down with it. The
+ // replacement command spells the subcommand out again itself.
+ let (groups, verbs): (&[&str], &[&str]) = match self {
+ CLIAgent::Amp => (
+ &["threads", "t"],
+ &["continue", "c", "fork", "f", "handoff", "h"],
+ ),
+ CLIAgent::Auggie => (&["session"], &["resume", "continue"]),
+ CLIAgent::Goose => (&["session", "s"], &[]),
+ CLIAgent::Hermes => (&["chat"], &[]),
+ _ => (&[], &[]),
+ };
+ if tail.first().is_some_and(|t| groups.contains(t)) {
+ tail.remove(0);
+ if tail.first().is_some_and(|t| verbs.contains(t)) {
+ tail.remove(0);
+ if tail.first().is_some_and(|t| !t.starts_with('-')) {
+ tail.remove(0);
+ }
+ }
+ }
+
let stale: &[&str] = match self {
CLIAgent::Claude => &[
"--resume",
@@ -235,8 +295,39 @@ impl CLIAgent {
"--from-pr",
"--fork-session",
],
- CLIAgent::Gemini | CLIAgent::Cursor => &["--resume", "-r"],
- CLIAgent::Copilot => &["--resume", "-r", "--continue", "-c"],
+ // `--session-id` and `--session-file` name a session too, and Gemini
+ // rejects them outright alongside `--resume`.
+ CLIAgent::Gemini => &["--resume", "-r", "--session-id", "--session-file"],
+ CLIAgent::Cursor => &["--resume", "-r", "--continue"],
+ CLIAgent::Copilot | CLIAgent::Auggie | CLIAgent::Hermes => {
+ &["--resume", "-r", "--continue", "-c"]
+ }
+ // `--session-id` names a *new* session and Qwen rejects it
+ // alongside `--resume`, so it is as stale as the resume flags.
+ CLIAgent::Qwen => &[
+ "--resume",
+ "-r",
+ "--continue",
+ "-c",
+ "--fork-session",
+ "--session-id",
+ ],
+ CLIAgent::Droid => &["--resume", "-r", "--fork", "--session-id", "-s"],
+ // `--session-id`/`--id`, `-n`/`--name` and the legacy `--path` are
+ // one mutually-exclusive clap group in Goose; any of them surviving
+ // next to the `--session-id` this command appends is a parse error.
+ CLIAgent::Goose => &[
+ "--resume",
+ "-r",
+ "--fork",
+ "--session-id",
+ "--id",
+ "--name",
+ "-n",
+ "--path",
+ ],
+ CLIAgent::Vibe => &["--resume", "--continue", "-c"],
+ CLIAgent::Antigravity => &["--conversation", "--continue", "-c"],
CLIAgent::OpenCode => &["--session", "-s", "--continue", "-c", "--fork"],
CLIAgent::Codex => &["--last"],
CLIAgent::Pi => &[
@@ -308,20 +399,20 @@ impl CLIAgent {
CLIAgent::Claude => 0xD97757,
CLIAgent::Codex => 0x000000,
CLIAgent::Gemini => 0x4285F4,
- CLIAgent::Aider => 0x14B8A6,
+ CLIAgent::Aider => 0x14B014,
CLIAgent::Amp => 0xF34E3F,
CLIAgent::OpenCode => 0x6E56CF,
CLIAgent::Copilot => 0x8957E5,
CLIAgent::Cursor => 0x9AA0A6,
- CLIAgent::Goose => 0x9A8CFF,
- CLIAgent::Droid => 0xF59E0B,
+ CLIAgent::Goose => 0x3ECC5F,
+ CLIAgent::Droid => 0xEF6F2E,
CLIAgent::Pi => 0x0EA5E9,
CLIAgent::Auggie => 0x16A34A,
CLIAgent::Hermes => 0x8B5CF6,
- CLIAgent::Vibe => 0xFF7000,
- CLIAgent::Antigravity => 0x2563EB,
+ CLIAgent::Vibe => 0xFA520F,
+ CLIAgent::Antigravity => 0x3186FF,
CLIAgent::Grok => 0x000000,
- CLIAgent::Qwen => 0x7C3AED,
+ CLIAgent::Qwen => 0x6D44E8,
CLIAgent::OhMyPi => 0xF97316,
}
}
@@ -340,12 +431,12 @@ impl CLIAgent {
CLIAgent::Grok => "icons/agents/grok.svg",
CLIAgent::Pi => "icons/agents/pi.svg",
CLIAgent::OhMyPi => "icons/agents/omp.svg",
+ CLIAgent::Qwen => "icons/agents/qwen.svg",
CLIAgent::Aider
| CLIAgent::Auggie
| CLIAgent::Hermes
| CLIAgent::Vibe
- | CLIAgent::Antigravity
- | CLIAgent::Qwen => "icons/bot.svg",
+ | CLIAgent::Antigravity => "icons/bot.svg",
}
}
@@ -746,7 +837,7 @@ mod tests {
.collect();
assert_eq!(
fallback,
- ["aider", "auggie", "hermes", "vibe", "antigravity", "qwen"]
+ ["aider", "auggie", "hermes", "vibe", "antigravity"]
);
assert!(
!fallback.contains(&"omp"),
@@ -1349,14 +1440,36 @@ mod tests {
CLIAgent::OpenCode.fork_command("s-1", None).as_deref(),
Some("opencode --session s-1 --fork")
);
+ assert_eq!(
+ CLIAgent::Droid.fork_command("session-abc", None).as_deref(),
+ Some("droid --fork session-abc")
+ );
+ assert_eq!(
+ CLIAgent::Qwen.fork_command("q-1", None).as_deref(),
+ Some("qwen --resume q-1 --fork-session")
+ );
+ assert_eq!(
+ CLIAgent::Goose.fork_command("20260213_9", None).as_deref(),
+ Some("goose session --resume --fork --session-id 20260213_9")
+ );
+ // Undocumented in `amp threads --help`, but `amp threads fork --help`
+ // prints its own usage, so the subcommand is real.
+ assert_eq!(
+ CLIAgent::Amp.fork_command("T-abc", None).as_deref(),
+ Some("amp threads fork T-abc")
+ );
+ // Cursor and Antigravity fork only from inside a running TUI (`/fork`),
+ // which is not something a launch command line can reach.
for agent in [
CLIAgent::Gemini,
CLIAgent::Copilot,
CLIAgent::Cursor,
- CLIAgent::Amp,
CLIAgent::Aider,
- CLIAgent::Qwen,
+ CLIAgent::Auggie,
+ CLIAgent::Hermes,
+ CLIAgent::Vibe,
+ CLIAgent::Antigravity,
] {
assert_eq!(
agent.fork_command("abc", None),
@@ -1468,6 +1581,157 @@ mod tests {
);
}
+ #[test]
+ fn newly_wired_agents_resume_the_way_their_own_cli_spells_it() {
+ let argv = |parts: &[&str]| parts.iter().map(|s| s.to_string()).collect::>();
+
+ for (agent, id, want) in [
+ (CLIAgent::Droid, "session-abc", "droid --resume session-abc"),
+ (CLIAgent::Qwen, "q-1", "qwen --resume q-1"),
+ (CLIAgent::Auggie, "a-1", "auggie --resume a-1"),
+ (
+ CLIAgent::Goose,
+ "20260213_9",
+ "goose session --resume --session-id 20260213_9",
+ ),
+ (
+ CLIAgent::Hermes,
+ "20260812_213234_5de948",
+ "hermes chat --resume 20260812_213234_5de948",
+ ),
+ (CLIAgent::Vibe, "v-1", "vibe --resume v-1"),
+ (
+ CLIAgent::Antigravity,
+ "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ "agy --conversation a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ ),
+ ] {
+ assert_eq!(
+ agent.resume_command(id, None).as_deref(),
+ Some(want),
+ "{} resumes with the wrong command",
+ agent.slug()
+ );
+ }
+
+ // A subcommand-addressed session leaves nothing in `stale` to strip, so
+ // the prefix has to be dropped structurally — otherwise the launch flags
+ // go down with it.
+ assert_eq!(
+ CLIAgent::Amp
+ .resume_command(
+ "T-2",
+ Some(&argv(&[
+ "amp",
+ "threads",
+ "continue",
+ "T-1",
+ "--dangerously-allow-all",
+ ]))
+ )
+ .as_deref(),
+ Some("amp threads continue T-2 --dangerously-allow-all")
+ );
+ assert_eq!(
+ CLIAgent::Goose
+ .resume_command(
+ "20260213_9",
+ Some(&argv(&["goose", "session", "--resume", "--name", "old"]))
+ )
+ .as_deref(),
+ Some("goose session --resume --session-id 20260213_9")
+ );
+ assert_eq!(
+ CLIAgent::Auggie
+ .resume_command(
+ "a-2",
+ Some(&argv(&["auggie", "session", "resume", "a-1", "--verbose"]))
+ )
+ .as_deref(),
+ Some("auggie --verbose --resume a-2")
+ );
+ assert_eq!(
+ CLIAgent::Droid
+ .resume_command(
+ "s-2",
+ Some(&argv(&["droid", "--fork", "s-1", "--auto", "low"]))
+ )
+ .as_deref(),
+ Some("droid --auto low --resume s-2")
+ );
+
+ // `--id` is an alias of `--session-id` and `-n` of `--name`, and the
+ // three share one exclusive clap group — any of them surviving next to
+ // the `--session-id` the command appends would fail to parse.
+ assert_eq!(
+ CLIAgent::Goose
+ .resume_command(
+ "20260213_9",
+ Some(&argv(&["goose", "s", "--resume", "--id", "20260101_1"]))
+ )
+ .as_deref(),
+ Some("goose session --resume --session-id 20260213_9")
+ );
+ assert_eq!(
+ CLIAgent::Goose
+ .resume_command(
+ "20260213_9",
+ Some(&argv(&["goose", "session", "-r", "-n", "old"]))
+ )
+ .as_deref(),
+ Some("goose session --resume --session-id 20260213_9")
+ );
+ // Qwen rejects `--session-id` alongside `--resume`; Vibe spells
+ // `--continue` as `-c` too.
+ assert_eq!(
+ CLIAgent::Qwen
+ .resume_command("q-2", Some(&argv(&["qwen", "--session-id", "old"])))
+ .as_deref(),
+ Some("qwen --resume q-2")
+ );
+ assert_eq!(
+ CLIAgent::Vibe
+ .resume_command("v-2", Some(&argv(&["vibe", "-c"])))
+ .as_deref(),
+ Some("vibe --resume v-2")
+ );
+
+ // Nothing was persisted, so there is nothing to resume or fork.
+ for id in ["a-1"] {
+ assert_eq!(
+ CLIAgent::Auggie
+ .resume_command(id, Some(&argv(&["auggie", "--dont-save-session"]))),
+ None
+ );
+ }
+ // "If false, chat history is not saved and --continue/--resume will
+ // not work" — so neither resume nor fork is offered.
+ let no_recording = argv(&["qwen", "--no-chat-recording"]);
+ assert_eq!(
+ CLIAgent::Qwen.resume_command("q-1", Some(&no_recording)),
+ None
+ );
+ assert_eq!(
+ CLIAgent::Qwen.fork_command("q-1", Some(&no_recording)),
+ None
+ );
+ }
+
+ /// `python3 -m antigravity` opens an xkcd comic. It is the standard way to
+ /// trigger Python's easter egg, and the interpreter branch used to read that
+ /// module name as an agent.
+ #[test]
+ fn the_python_easter_egg_is_not_a_coding_agent() {
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["python3", "-m", "antigravity"])),
+ None
+ );
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["agy"])),
+ Some(CLIAgent::Antigravity)
+ );
+ }
+
#[test]
fn status_metadata_is_consistent() {
assert_eq!(AgentStatus::Idle.dot_rgb(), None);
diff --git a/src/ui/assets.rs b/src/ui/assets.rs
index e42038e2..5f89789f 100644
--- a/src/ui/assets.rs
+++ b/src/ui/assets.rs
@@ -59,6 +59,7 @@ fn agent_icon(path: &str) -> Option<&'static [u8]> {
"icons/agents/grok.svg" => include_bytes!("../../assets/icons/agents/grok.svg"),
"icons/agents/pi.svg" => include_bytes!("../../assets/icons/agents/pi.svg"),
"icons/agents/omp.svg" => include_bytes!("../../assets/icons/agents/omp.svg"),
+ "icons/agents/qwen.svg" => include_bytes!("../../assets/icons/agents/qwen.svg"),
_ => return None,
};
Some(bytes)
diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs
index 4f99d30a..9142dabe 100644
--- a/src/ui/i18n/en.rs
+++ b/src/ui/i18n/en.rs
@@ -707,6 +707,10 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsAgentPi => "Pi",
L10nKey::SettingsAgentGrokBuild => "Grok Build",
L10nKey::SettingsAgentOhMyPi => "Oh My Pi",
+ L10nKey::SettingsAgentGemini => "Gemini",
+ L10nKey::SettingsAgentDroid => "Droid",
+ L10nKey::SettingsAgentQwenCode => "Qwen Code",
+ L10nKey::SettingsAgentGoose => "Goose",
L10nKey::SettingsSearchAboutKeywords => "version license credits build update check github",
L10nKey::SettingsSearchAppHttpProxyKeywords => {
"proxy http https socks socks5 clash v2ray network download update"
@@ -787,6 +791,12 @@ pub fn translate_en(key: L10nKey) -> &'static str {
"alt keyboard modifier escape macos option meta option acts as meta"
}
L10nKey::SettingsSearchOhMyPiKeywords => "agent integration extension install omp oh my pi",
+ L10nKey::SettingsSearchGeminiKeywords => "agent integration hooks install gemini google",
+ L10nKey::SettingsSearchDroidKeywords => "agent integration hooks install droid factory",
+ L10nKey::SettingsSearchQwenCodeKeywords => {
+ "agent integration hooks install qwen code qwen-code"
+ }
+ L10nKey::SettingsSearchGooseKeywords => "agent integration hooks plugin install goose",
L10nKey::SettingsSearchPiKeywords => "agent integration extension install pi",
L10nKey::SettingsSearchPortForwardingKeywords => {
"ssh tunnel local remote dynamic socks forward rule"
diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs
index 4dbe2818..c14d72cc 100644
--- a/src/ui/i18n/ja.rs
+++ b/src/ui/i18n/ja.rs
@@ -716,6 +716,10 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsAgentPi => "Pi",
L10nKey::SettingsAgentGrokBuild => "Grok Build",
L10nKey::SettingsAgentOhMyPi => "Oh My Pi",
+ L10nKey::SettingsAgentGemini => "Gemini",
+ L10nKey::SettingsAgentDroid => "Droid",
+ L10nKey::SettingsAgentQwenCode => "Qwen Code",
+ L10nKey::SettingsAgentGoose => "Goose",
L10nKey::SettingsSearchAboutKeywords => {
"バージョン ライセンス クレジット ビルド 更新 確認 github about version license credits update check"
}
@@ -832,6 +836,18 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchOhMyPiKeywords => {
"エージェント 統合 拡張 インストール omp oh my pi agent integration extension install"
}
+ L10nKey::SettingsSearchGeminiKeywords => {
+ "エージェント 統合 フック インストール gemini google agent integration hooks install"
+ }
+ L10nKey::SettingsSearchDroidKeywords => {
+ "エージェント 統合 フック インストール droid factory agent integration hooks install"
+ }
+ L10nKey::SettingsSearchQwenCodeKeywords => {
+ "エージェント 統合 フック インストール qwen code agent integration hooks install"
+ }
+ L10nKey::SettingsSearchGooseKeywords => {
+ "エージェント 統合 フック プラグイン インストール goose agent integration hooks plugin install"
+ }
L10nKey::SettingsSearchPiKeywords => {
"エージェント 統合 拡張 インストール pi agent integration extension install"
}
diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs
index 4e17e6b0..2122034b 100644
--- a/src/ui/i18n/mod.rs
+++ b/src/ui/i18n/mod.rs
@@ -564,6 +564,10 @@ l10n_keys! {
SettingsAgentPi,
SettingsAgentGrokBuild,
SettingsAgentOhMyPi,
+ SettingsAgentGemini,
+ SettingsAgentDroid,
+ SettingsAgentQwenCode,
+ SettingsAgentGoose,
SettingsSearchAppHttpProxyKeywords,
SettingsSearchAboutKeywords,
SettingsSearchAutoDownloadKeywords,
@@ -587,11 +591,14 @@ l10n_keys! {
SettingsSearchDetectUrlsKeywords,
SettingsSearchDiffPreviewFromCountsKeywords,
SettingsSearchDimInactivePanesKeywords,
+ SettingsSearchDroidKeywords,
SettingsSearchFocusFollowsMouseKeywords,
SettingsSearchFontFamilyKeywords,
SettingsSearchFontLigaturesKeywords,
SettingsSearchFontSizeKeywords,
SettingsSearchForwardSshLoopbackLinksKeywords,
+ SettingsSearchGeminiKeywords,
+ SettingsSearchGooseKeywords,
SettingsSearchGrokBuildKeywords,
SettingsSearchHideMouseWhileTypingKeywords,
SettingsSearchHistorySearchKeywords,
@@ -611,6 +618,7 @@ l10n_keys! {
SettingsSearchPiKeywords,
SettingsSearchPortForwardingKeywords,
SettingsSearchProgramKeywords,
+ SettingsSearchQwenCodeKeywords,
SettingsSearchRememberWindowSizeKeywords,
SettingsSearchReportMouseToAppsKeywords,
SettingsSearchRestoreLastLayoutKeywords,
@@ -1497,10 +1505,14 @@ mod tests {
L10nKey::SettingsAgentClaudeCode,
L10nKey::SettingsAgentCodex,
L10nKey::SettingsAgentCopilotCli,
+ L10nKey::SettingsAgentDroid,
+ L10nKey::SettingsAgentGemini,
+ L10nKey::SettingsAgentGoose,
L10nKey::SettingsAgentGrokBuild,
L10nKey::SettingsAgentOhMyPi,
L10nKey::SettingsAgentOpencode,
L10nKey::SettingsAgentPi,
+ L10nKey::SettingsAgentQwenCode,
// Windows names its backdrop materials, and Japanese Windows keeps
// those names in Latin script — so does this list. Chinese does
// translate them (云母 / 亚克力), which is what Microsoft's own
diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs
index aeb8e41d..b32c0a1e 100644
--- a/src/ui/i18n/zh.rs
+++ b/src/ui/i18n/zh.rs
@@ -625,6 +625,10 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsAgentPi => "Pi",
L10nKey::SettingsAgentGrokBuild => "Grok Build",
L10nKey::SettingsAgentOhMyPi => "Oh My Pi",
+ L10nKey::SettingsAgentGemini => "Gemini",
+ L10nKey::SettingsAgentDroid => "Droid",
+ L10nKey::SettingsAgentQwenCode => "Qwen Code",
+ L10nKey::SettingsAgentGoose => "Goose",
L10nKey::SettingsSearchAboutKeywords => {
"关于 版本 许可证 致谢 构建 更新 检查 github about version license credits update"
}
@@ -739,6 +743,18 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsSearchOhMyPiKeywords => {
"Oh My Pi agent 集成 扩展 安装 omp oh my pi agent integration extension install"
}
+ L10nKey::SettingsSearchGeminiKeywords => {
+ "Gemini agent 集成 钩子 安装 gemini google agent integration hooks install"
+ }
+ L10nKey::SettingsSearchDroidKeywords => {
+ "Droid agent 集成 钩子 安装 droid factory agent integration hooks install"
+ }
+ L10nKey::SettingsSearchQwenCodeKeywords => {
+ "Qwen Code 通义千问 agent 集成 钩子 安装 qwen code agent integration hooks install"
+ }
+ L10nKey::SettingsSearchGooseKeywords => {
+ "Goose agent 集成 钩子 插件 安装 goose agent integration hooks plugin install"
+ }
L10nKey::SettingsSearchPiKeywords => {
"Pi agent 集成 扩展 安装 pi agent integration extension install"
}
diff --git a/src/ui/settings.rs b/src/ui/settings.rs
index 1c0b8f46..709f47c9 100644
--- a/src/ui/settings.rs
+++ b/src/ui/settings.rs
@@ -608,6 +608,26 @@ fn settings_search_entries() -> &'static [SearchEntry] {
title: SettingsAgentOhMyPi,
keywords: SettingsSearchOhMyPiKeywords,
},
+ SearchEntry {
+ section: Agents,
+ title: SettingsAgentGemini,
+ keywords: SettingsSearchGeminiKeywords,
+ },
+ SearchEntry {
+ section: Agents,
+ title: SettingsAgentDroid,
+ keywords: SettingsSearchDroidKeywords,
+ },
+ SearchEntry {
+ section: Agents,
+ title: SettingsAgentQwenCode,
+ keywords: SettingsSearchQwenCodeKeywords,
+ },
+ SearchEntry {
+ section: Agents,
+ title: SettingsAgentGoose,
+ keywords: SettingsSearchGooseKeywords,
+ },
SearchEntry {
section: WindowTabs,
title: SettingsStartupWindow,