Wire hooks, resume and fork for the CLI agents that support them (#666)

* feat(agents): hook, resume and fork support for nine more CLI agents

Hooks go from 7 agents to 11. Gemini, Droid and Qwen merge into their
own settings.json the way Claude and Codex already do; Goose gets an
owned file under the Open Plugins layout it implements. Qwen is the only
one of them with a first-class PermissionRequest event, so it needs none
of the notification sniffing the others do -- and deliberately gets no
Notification hook at all, since that event fires for non-blocking alerts
too and would strand a pane on "waiting".

Resume goes from 10 agents to 17, fork from 5 to 9. Amp's `threads fork`
is a real subcommand that is simply missing from `amp threads --help`.

Four detection and replay bugs turned up while checking each CLI:

- `python3 -m antigravity`, the documented way to trigger Python's own
  easter egg, was detected as a coding agent. The `antigravity` binary
  is the IDE's launcher shim anyway, in the shape of VS Code's `code`,
  not the terminal agent -- that one is `agy`.
- Amp lost every launch flag on resume. It names a thread with a
  positional argument, so the stale-flag list had nothing to drop and
  the generic bare-token check rejected the whole tail along with it.
- Gemini could be handed a command line it refuses to start from:
  `--session-id` and `--session-file` are mutually exclusive with
  `--resume` and were never stripped.
- Cursor's `--continue` was not stripped either, leaving it to collide
  with the injected `--resume <id>`.

Brand colours for Aider, Goose, Droid, Vibe, Qwen and Antigravity now
come from first-party sources -- logo SVG fills and site CSS variables
-- rather than approximations. Qwen ships its real mark instead of the
generic bot glyph.

Hooks stay unwired for Aider (no lifecycle mechanism exists at all),
Cursor (its usable events gate permissions, and tty7's silent hook would
read as a failed check and auto-allow the command), Auggie (its command
field takes only script paths, needing generated wrappers, and the
constraint could not be verified without a billed run), and for Hermes,
Amp, Vibe and Antigravity, whose event sets are too thin to report a
blocked turn.

* fix(agents): strip every session-naming alias before replaying launch flags

Goose spells --session-id also as --id, --name as -n, and keeps a legacy
--path, all in one exclusive clap group; Qwen rejects --session-id next
to --resume; Vibe shortens --continue to -c. Any of these surviving a
replay broke the regenerated resume command. Qwen's --no-chat-recording
also persists nothing, so it now opts the pane out of resume and fork
like Auggie's --dont-save-session. The Qwen icon gains the 24x24
width/height every other agent mark carries.
This commit is contained in:
l0ng-ai
2026-08-18 00:42:56 +08:00
committed by GitHub
parent 9c2869a25f
commit 8b5aeb0077
9 changed files with 665 additions and 72 deletions
+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 141.38 140">
<path
fill="#6D44E8"
d="m140.93 85-16.35-28.33-1.93-3.34 8.66-15a3.323 3.323 0 0 0 0-3.34l-9.62-16.67c-.3-.51-.72-.93-1.22-1.22s-1.07-.45-1.67-.45H82.23l-8.66-15a3.33 3.33 0 0 0-2.89-1.67H51.43c-.59 0-1.17.16-1.66.45-.5.29-.92.71-1.22 1.22L32.19 29.98l-1.92 3.33H12.96c-.59 0-1.17.16-1.66.45-.5.29-.93.71-1.22 1.22L.45 51.66a3.323 3.323 0 0 0 0 3.34l18.28 31.67-8.66 15a3.32 3.32 0 0 0 0 3.34l9.62 16.67c.3.51.72.93 1.22 1.22s1.07.45 1.67.45h36.56l8.66 15a3.35 3.35 0 0 0 2.89 1.67h19.25a3.34 3.34 0 0 0 2.89-1.67l18.28-31.67h17.32c.6 0 1.17-.16 1.67-.45s.92-.71 1.22-1.22l9.62-16.67a3.323 3.323 0 0 0 0-3.34ZM51.44 3.33 61.07 20l-9.63 16.66h76.98l-9.62 16.66H45.67l-11.54-20zM57.21 120H22.58l9.63-16.67h19.25l-38.5-66.67h19.25l9.62 16.67L68.78 100l-11.55 20Zm61.59-33.34-9.62-16.67-38.49 66.67-9.63-16.67 9.63-16.66 26.94-46.67h23.1l17.32 30z"
/>
</svg>

After

Width:  |  Height:  |  Size: 956 B

+305 -57
View File
@@ -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<u8> {
("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/<name>/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<HookOutcome> {
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<HookOutcome> {
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<String> {
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<String> {
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<String> {
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<String> {
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!(
+279 -15
View File
@@ -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 <id>` 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::<Vec<_>>();
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);
+1
View File
@@ -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)
+10
View File
@@ -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"
+16
View File
@@ -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"
}
+12
View File
@@ -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
+16
View File
@@ -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"
}
+20
View File
@@ -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,