diff --git a/README.md b/README.md
index 74ce53f6..bad9008e 100644
--- a/README.md
+++ b/README.md
@@ -66,6 +66,19 @@ Download the build for your platform from
- **⌘-click links** · desktop notifications
- **Eight themes** · CJK / IME input
+### CLI coding agents
+
+tty7 recognizes third-party coding agents running in a pane (Claude Code,
+Codex, Gemini CLI, Aider, Amp, OpenCode, and ~10 more) and enriches them — it
+never wraps or replaces the agent.
+
+- **Brand avatars** — the tab chip / sidebar row shows which agent runs where; custom wrappers map in via `agent_commands` in `config.json`
+- **Live status dot** — working (blue) / needs your input (amber) / done (green), driven by agent-reported events over an OSC channel; run *Agent: Install Claude Code Hooks* from the palette to wire Claude Code up
+- **Notifications that matter** — "needs your permission…" the moment an agent blocks on you, and "finished after Ns" per turn, honoring your notification policy
+- **Branch at a glance** — each sidebar row shows its pane's git branch and working-tree diff (`+N −M`), refreshed on `cd` and when a command finishes
+- **Session resume** — panes lost to a reboot re-launch their agent conversation (`claude --resume …`) on restore (`restore_agent_sessions`, on by default)
+- **Context feed** — palette commands send the current selection or the repo's `git diff` to the running agent as a ready-made prompt
+
### SSH connection manager
A native Rust SSH stack (russh) is the **only** path — profiles, credentials,
diff --git a/README.zh-CN.md b/README.zh-CN.md
index ae958bed..90759c0f 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -62,6 +62,17 @@ macOS、Windows、Linux 三平台原生构建,每个 release 一起打出。
- **⌘ 点击打开链接** · 桌面通知
- **8 套主题** · CJK / 输入法输入
+### CLI coding agent
+
+tty7 能识别 pane 里跑着的第三方 coding agent(Claude Code、Codex、Gemini CLI、Aider、Amp、OpenCode 等约 17 个)并为其增强体验 —— 只观察、只加分,绝不包裹或替代 agent 本身。
+
+- **品牌头像** —— 标签 chip / 侧栏行显示每个 pane 跑的是哪个 agent;自定义包装命令可通过 `config.json` 的 `agent_commands` 映射
+- **实时状态点** —— 工作中(蓝)/ 等你输入(琥珀)/ 完成(绿),由 agent 自己上报的 OSC 事件驱动;在命令面板运行 *Agent: Install Claude Code Hooks* 一键接通 Claude Code
+- **真正有用的通知** —— agent 卡在等你批准的那一刻弹 "needs your permission…",每轮结束弹 "finished after Ns",遵循你的通知策略
+- **一眼看分支** —— 侧栏每行显示该 pane 的 git 分支和工作区改动(`+N −M`),`cd` 或命令跑完时自动刷新
+- **会话恢复** —— 重启后无法重连的 pane 会自动续上 agent 对话(`claude --resume …`;`restore_agent_sessions`,默认开启)
+- **上下文回填** —— 面板命令把当前选区或仓库 `git diff` 打包成 prompt 直接喂给正在跑的 agent
+
### SSH 连接管理器
**唯一**路径就是原生 Rust SSH 栈(russh)—— profile、凭据、SFTP 全部内置,绝不 shell 出 `ssh`。没有系统 ssh 兼容模式。
diff --git a/assets/icons/agents/amp.svg b/assets/icons/agents/amp.svg
new file mode 100644
index 00000000..893b466e
--- /dev/null
+++ b/assets/icons/agents/amp.svg
@@ -0,0 +1,12 @@
+
diff --git a/assets/icons/agents/claude.svg b/assets/icons/agents/claude.svg
new file mode 100644
index 00000000..390bb953
--- /dev/null
+++ b/assets/icons/agents/claude.svg
@@ -0,0 +1,10 @@
+
diff --git a/assets/icons/agents/codex.svg b/assets/icons/agents/codex.svg
new file mode 100644
index 00000000..bb97908a
--- /dev/null
+++ b/assets/icons/agents/codex.svg
@@ -0,0 +1,10 @@
+
diff --git a/assets/icons/agents/copilot.svg b/assets/icons/agents/copilot.svg
new file mode 100644
index 00000000..59d7732f
--- /dev/null
+++ b/assets/icons/agents/copilot.svg
@@ -0,0 +1,12 @@
+
\ No newline at end of file
diff --git a/assets/icons/agents/cursor.svg b/assets/icons/agents/cursor.svg
new file mode 100644
index 00000000..b1ebd171
--- /dev/null
+++ b/assets/icons/agents/cursor.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/agents/droid.svg b/assets/icons/agents/droid.svg
new file mode 100644
index 00000000..009471e7
--- /dev/null
+++ b/assets/icons/agents/droid.svg
@@ -0,0 +1,10 @@
+
diff --git a/assets/icons/agents/gemini.svg b/assets/icons/agents/gemini.svg
new file mode 100644
index 00000000..76dcaa5c
--- /dev/null
+++ b/assets/icons/agents/gemini.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/icons/agents/goose.svg b/assets/icons/agents/goose.svg
new file mode 100644
index 00000000..b9eb73f3
--- /dev/null
+++ b/assets/icons/agents/goose.svg
@@ -0,0 +1,10 @@
+
diff --git a/assets/icons/agents/opencode.svg b/assets/icons/agents/opencode.svg
new file mode 100644
index 00000000..cf87c9b7
--- /dev/null
+++ b/assets/icons/agents/opencode.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/icons/git-branch.svg b/assets/icons/git-branch.svg
new file mode 100644
index 00000000..b809d259
--- /dev/null
+++ b/assets/icons/git-branch.svg
@@ -0,0 +1 @@
+
diff --git a/assets/icons/terminal.svg b/assets/icons/terminal.svg
new file mode 100644
index 00000000..47dab739
--- /dev/null
+++ b/assets/icons/terminal.svg
@@ -0,0 +1 @@
+
diff --git a/src/core/agent_hooks.rs b/src/core/agent_hooks.rs
new file mode 100644
index 00000000..8c8b1190
--- /dev/null
+++ b/src/core/agent_hooks.rs
@@ -0,0 +1,420 @@
+//! Agent-side hook integration: the emitter behind `tty7 agent-hook …` and the
+//! installer that wires it into Claude Code's `settings.json`.
+//!
+//! The rich agent-status channel ([`crate::core::cli_agent`]) needs the agent
+//! itself to say what it's doing. For Claude Code that is its hooks system:
+//! each lifecycle hook runs `tty7 agent-hook claude `, which reads the
+//! hook's JSON payload from stdin and writes one sentinel OSC 777 sequence to
+//! the controlling terminal (`/dev/tty`) — where tty7's daemon-side sniffer
+//! picks it up and folds it into the pane's session state. The same idea can
+//! ship as a Claude *plugin*; a hook + our own binary needs no plugin
+//! marketplace and no jq.
+//!
+//! Emission is gated on the `TTY7` environment variable (injected into every
+//! shell tty7 spawns), so hooks installed globally stay silent when Claude
+//! runs in another terminal.
+
+use std::io::Read as _;
+use std::path::PathBuf;
+
+use crate::core::cli_agent::AGENT_EVENT_SENTINEL;
+
+/// The env var tty7 sets in every spawned shell; the hook emitter refuses to
+/// write escape sequences into terminals that aren't tty7.
+pub const TTY7_ENV_MARKER: &str = "TTY7";
+
+/// Cap on how much hook stdin we'll read: real payloads are a few hundred
+/// bytes of JSON; anything huge is not for us.
+const MAX_STDIN: u64 = 64 * 1024;
+
+/// Entry point for the `tty7 agent-hook ` subcommand: read the
+/// hook's JSON payload from stdin, build the sentinel event, and write it to
+/// the controlling terminal. Always exits quietly — a hook that fails must
+/// never break the agent's own flow (Claude Code surfaces nonzero exits).
+pub fn run_agent_hook(agent: &str, event: &str) {
+ // Not inside tty7 (or a remote shell): stay silent, so globally-installed
+ // hooks don't leak escape sequences into other terminals.
+ if std::env::var_os(TTY7_ENV_MARKER).is_none() {
+ return;
+ }
+ // Hook payload: Claude Code writes {"session_id": …, "message": …, …} and
+ // closes stdin. Absent/malformed input still emits the bare event — the
+ // state machine works without ids or messages.
+ let mut input = String::new();
+ let _ = std::io::stdin().take(MAX_STDIN).read_to_string(&mut input);
+ write_to_controlling_tty(&build_hook_sequence(agent, event, &input));
+}
+
+/// Build the sentinel OSC sequence for one hook invocation — the pure core of
+/// [`run_agent_hook`], separated so the wire bytes are testable without a PTY.
+/// Round-trips through [`crate::core::cli_agent::parse_agent_event`] on the
+/// daemon side.
+fn build_hook_sequence(agent: &str, event: &str, stdin_json: &str) -> Vec {
+ let payload: serde_json::Value =
+ serde_json::from_str(stdin_json).unwrap_or(serde_json::json!({}));
+ let mut body = serde_json::json!({
+ "v": 1,
+ "agent": agent,
+ "event": event,
+ });
+ for key in ["session_id", "message"] {
+ if let Some(v) = payload
+ .get(key)
+ .and_then(|v| v.as_str())
+ .filter(|v| !v.is_empty())
+ {
+ body[key] = serde_json::Value::String(v.to_string());
+ }
+ }
+ format!("\x1b]777;notify;{AGENT_EVENT_SENTINEL};{body}\x07").into_bytes()
+}
+
+/// Write raw bytes to the pane's PTY so the daemon's sniffer reads them as pane
+/// output. Two routes, because agents run hooks differently:
+///
+/// 1. `/dev/tty` — the hook's own controlling terminal. Works when the agent
+/// runs the hook attached to its tty.
+/// 2. An ancestor's tty device — Claude Code runs hooks *detached* from the
+/// controlling terminal (they have no `/dev/tty`), but the agent process
+/// itself still owns the pane's PTY slave. So walk up the parent chain to
+/// the nearest process that has a real tty (that's the agent) and write its
+/// device (`/dev/ttysNNN` on macOS, `/dev/pts/N` on Linux) directly. Writing
+/// the slave sends output to the master, exactly like `/dev/tty` would.
+#[cfg(unix)]
+fn write_to_controlling_tty(bytes: &[u8]) -> bool {
+ if write_dev(std::path::Path::new("/dev/tty"), bytes) {
+ return true;
+ }
+ if let Some(dev) = ancestor_tty_device() {
+ return write_dev(&dev, bytes);
+ }
+ false
+}
+
+#[cfg(unix)]
+fn write_dev(path: &std::path::Path, bytes: &[u8]) -> bool {
+ use std::io::Write as _;
+ match std::fs::OpenOptions::new().write(true).open(path) {
+ Ok(mut tty) => tty.write_all(bytes).and_then(|_| tty.flush()).is_ok(),
+ Err(_) => false,
+ }
+}
+
+/// The controlling-tty device of the nearest ancestor that has one — the agent
+/// process, when it ran us detached. Walks the parent chain via `ps` (the hook
+/// runs at most a few times per turn, so the process spawn is negligible and
+/// beats platform-specific sysctl/`/proc` FFI here).
+#[cfg(unix)]
+fn ancestor_tty_device() -> Option {
+ use std::process::Command;
+ // SAFETY: getppid is always safe and never fails.
+ let mut pid = unsafe { libc::getppid() };
+ for _ in 0..8 {
+ if pid <= 1 {
+ break;
+ }
+ // `tty=` prints the terminal (`ttys004`, `pts/3`, or `??`/empty for none)
+ // and `ppid=` the parent, both header-less so parsing is trivial.
+ let out = Command::new("ps")
+ .args(["-o", "tty=", "-o", "ppid=", "-p", &pid.to_string()])
+ .output()
+ .ok()?;
+ let line = String::from_utf8_lossy(&out.stdout);
+ let mut fields = line.split_whitespace();
+ let tty = fields.next().unwrap_or("");
+ let ppid: i32 = fields.next().and_then(|s| s.parse().ok()).unwrap_or(1);
+ if !tty.is_empty() && tty != "??" && tty != "?" {
+ return Some(std::path::PathBuf::from(format!("/dev/{tty}")));
+ }
+ pid = ppid;
+ }
+ None
+}
+
+#[cfg(not(unix))]
+fn write_to_controlling_tty(_bytes: &[u8]) -> bool {
+ false
+}
+
+// ---------------------------------------------------------------------------
+// Claude Code installer.
+// ---------------------------------------------------------------------------
+
+/// The Claude Code hook events we subscribe to, and the sentinel event each
+/// maps onto. `Notification` covers both "needs permission" and "waiting for
+/// input" — exactly the Waiting state.
+const CLAUDE_HOOK_EVENTS: [(&str, &str); 5] = [
+ ("SessionStart", "session-start"),
+ ("UserPromptSubmit", "prompt-submit"),
+ ("Notification", "notification"),
+ ("Stop", "stop"),
+ ("SessionEnd", "session-end"),
+];
+
+/// Substring that identifies a hook entry as ours, for idempotent
+/// install/upgrade (an entry containing it is replaced, never duplicated).
+const HOOK_MARKER: &str = "agent-hook claude";
+
+/// Claude Code's user settings file: `$CLAUDE_CONFIG_DIR/settings.json`,
+/// defaulting to `~/.claude/settings.json`.
+fn claude_settings_path() -> Option {
+ if let Some(dir) = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|d| !d.is_empty()) {
+ return Some(PathBuf::from(dir).join("settings.json"));
+ }
+ Some(home_dir()?.join(".claude").join("settings.json"))
+}
+
+fn home_dir() -> Option {
+ #[cfg(unix)]
+ {
+ std::env::var_os("HOME").map(PathBuf::from)
+ }
+ #[cfg(not(unix))]
+ {
+ std::env::var_os("USERPROFILE").map(PathBuf::from)
+ }
+}
+
+/// The hook command line written into Claude's settings — this binary, by
+/// absolute path, so it works regardless of PATH. Quoted because macOS app
+/// paths ("/Applications/…") can carry spaces.
+fn hook_command(event: &str) -> Option {
+ let exe = std::env::current_exe().ok()?;
+ Some(format!("\"{}\" agent-hook claude {event}", exe.display()))
+}
+
+/// Install (or upgrade) the tty7 hooks in Claude Code's `settings.json`,
+/// preserving everything else in the file. Idempotent: entries carrying
+/// [`HOOK_MARKER`] are rewritten in place (e.g. after the binary moved);
+/// user-defined hooks on the same events are left untouched. Returns a short
+/// human-readable summary for the caller to toast.
+pub fn install_claude_hooks() -> anyhow::Result {
+ let path =
+ claude_settings_path().ok_or_else(|| anyhow::anyhow!("cannot resolve home directory"))?;
+
+ let mut root: serde_json::Value = match std::fs::read_to_string(&path) {
+ Ok(text) => serde_json::from_str(&text).map_err(|e| {
+ anyhow::anyhow!(
+ "{} is not valid JSON ({e}); not touching it",
+ path.display()
+ )
+ })?,
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => serde_json::json!({}),
+ Err(e) => return Err(anyhow::anyhow!("read {}: {e}", path.display())),
+ };
+ if !root.is_object() {
+ return Err(anyhow::anyhow!(
+ "{} is not a JSON object; not touching it",
+ path.display()
+ ));
+ }
+
+ let hooks = root
+ .as_object_mut()
+ .unwrap()
+ .entry("hooks")
+ .or_insert_with(|| serde_json::json!({}));
+ if !hooks.is_object() {
+ return Err(anyhow::anyhow!(
+ "\"hooks\" in {} is not an object; not touching it",
+ path.display()
+ ));
+ }
+
+ for (claude_event, tty7_event) in CLAUDE_HOOK_EVENTS {
+ let command = hook_command(tty7_event)
+ .ok_or_else(|| anyhow::anyhow!("cannot resolve tty7's own executable path"))?;
+ let entries = hooks
+ .as_object_mut()
+ .unwrap()
+ .entry(claude_event)
+ .or_insert_with(|| serde_json::json!([]));
+ let Some(list) = entries.as_array_mut() else {
+ continue; // malformed user config on this event; leave it alone
+ };
+ // Drop any previous tty7 entry (stale exe path), then append ours.
+ list.retain(|matcher| !matcher_contains_marker(matcher));
+ list.push(serde_json::json!({
+ "hooks": [{ "type": "command", "command": command }]
+ }));
+ }
+
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ crate::core::config::write_atomic(&path, serde_json::to_string_pretty(&root)?.as_bytes())?;
+ Ok(format!(
+ "Claude Code hooks installed in {} — restart running claude sessions to pick them up",
+ path.display()
+ ))
+}
+
+/// Whether the tty7 hooks are already present in Claude Code's settings (all
+/// five events carry a marker entry).
+pub fn claude_hooks_installed() -> bool {
+ let Some(path) = claude_settings_path() else {
+ return false;
+ };
+ let Ok(text) = std::fs::read_to_string(&path) else {
+ return false;
+ };
+ let Ok(root) = serde_json::from_str::(&text) else {
+ return false;
+ };
+ CLAUDE_HOOK_EVENTS.iter().all(|(claude_event, _)| {
+ root.get("hooks")
+ .and_then(|h| h.get(claude_event))
+ .and_then(|e| e.as_array())
+ .is_some_and(|list| list.iter().any(matcher_contains_marker))
+ })
+}
+
+/// Whether one matcher entry (`{"matcher": …, "hooks": [{"command": …}]}`)
+/// carries a tty7 hook command.
+fn matcher_contains_marker(matcher: &serde_json::Value) -> bool {
+ matcher
+ .get("hooks")
+ .and_then(|h| h.as_array())
+ .is_some_and(|hooks| {
+ hooks.iter().any(|h| {
+ h.get("command")
+ .and_then(|c| c.as_str())
+ .is_some_and(|c| c.contains(HOOK_MARKER))
+ })
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// The emitter's bytes must parse back into the exact event the daemon's
+ /// sniffer expects — the two ends of the protocol locked together.
+ #[test]
+ fn hook_sequence_round_trips_through_the_daemon_parser() {
+ use crate::core::cli_agent::{AgentEventKind, CLIAgent, parse_agent_event};
+
+ let seq = build_hook_sequence(
+ "claude",
+ "notification",
+ r#"{"session_id":"abc-123","message":"Claude needs your permission","cwd":"/w"}"#,
+ );
+ // Strip the OSC framing (`ESC ]` … `BEL`) to get the payload the
+ // tokenizer would deliver.
+ let payload = &seq[2..seq.len() - 1];
+ let ev = parse_agent_event(payload).expect("daemon parses the emitted event");
+ assert_eq!(ev.agent, Some(CLIAgent::Claude));
+ assert_eq!(ev.kind, AgentEventKind::Notification);
+ assert_eq!(ev.session_id.as_deref(), Some("abc-123"));
+ assert!(ev.message.as_deref().unwrap().contains("permission"));
+
+ // Garbage stdin still yields a well-formed bare event.
+ let seq = build_hook_sequence("claude", "stop", "not json at all");
+ let ev = parse_agent_event(&seq[2..seq.len() - 1]).expect("bare event still parses");
+ assert_eq!(ev.kind, AgentEventKind::Stop);
+ assert_eq!(ev.session_id, None);
+ }
+
+ /// The controlling-tty fallback (`ancestor_tty_device`) is what makes the
+ /// hook work at all: Claude Code runs hooks detached from the controlling
+ /// terminal, so `/dev/tty` fails and we must reach the agent's tty via the
+ /// parent chain (verified end-to-end against a real detached-hook PTY
+ /// setup). The device path itself is environment-dependent, so this guards
+ /// only the invariant that survives CI: the `ps`-walk never panics and only
+ /// ever yields a `/dev/…` device (never a bare tty name we'd fail to open).
+ #[cfg(unix)]
+ #[test]
+ fn ancestor_tty_device_is_none_or_a_dev_path() {
+ match ancestor_tty_device() {
+ None => {}
+ Some(dev) => assert!(
+ dev.starts_with("/dev/"),
+ "a resolved tty must be an openable device path, got {dev:?}"
+ ),
+ }
+ }
+
+ #[test]
+ fn marker_detection_matches_our_entries_only() {
+ let ours = serde_json::json!({
+ "hooks": [{ "type": "command", "command": "\"/x/tty7\" agent-hook claude stop" }]
+ });
+ assert!(matcher_contains_marker(&ours));
+ let theirs = serde_json::json!({
+ "hooks": [{ "type": "command", "command": "afplay /System/Library/Sounds/Glass.aiff" }]
+ });
+ assert!(!matcher_contains_marker(&theirs));
+ assert!(!matcher_contains_marker(&serde_json::json!({})));
+ }
+
+ #[test]
+ fn hook_command_quotes_the_exe_path() {
+ let cmd = hook_command("stop").expect("current_exe resolves in tests");
+ assert!(cmd.starts_with('"'));
+ assert!(cmd.ends_with("agent-hook claude stop"));
+ }
+
+ /// Full install → verify → re-install round trip against a scratch
+ /// settings file (`CLAUDE_CONFIG_DIR` is honored, so the test never
+ /// touches the real `~/.claude`). Serialized with the env-var lock other
+ /// env-mutating tests use… none exists for this var, so the test sets it
+ /// once and relies on `cargo test` threads not racing the same var (only
+ /// this test touches CLAUDE_CONFIG_DIR).
+ #[test]
+ fn install_is_idempotent_and_preserves_user_hooks() {
+ let dir = std::env::temp_dir().join(format!("tty7-hooks-test-{}", std::process::id()));
+ std::fs::create_dir_all(&dir).unwrap();
+ let settings = dir.join("settings.json");
+ // Pre-existing user config: a model pick and their own Stop hook.
+ std::fs::write(
+ &settings,
+ serde_json::json!({
+ "model": "opus",
+ "hooks": {
+ "Stop": [{ "hooks": [{ "type": "command", "command": "afplay ding.aiff" }] }]
+ }
+ })
+ .to_string(),
+ )
+ .unwrap();
+ // SAFETY: test-only env mutation; no other test reads this var.
+ unsafe { std::env::set_var("CLAUDE_CONFIG_DIR", &dir) };
+
+ assert!(!claude_hooks_installed());
+ install_claude_hooks().expect("install succeeds");
+ assert!(claude_hooks_installed());
+
+ // Install again: no duplicates.
+ install_claude_hooks().expect("re-install succeeds");
+ let root: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(&settings).unwrap()).unwrap();
+ // User settings and user hooks survive.
+ assert_eq!(root["model"], "opus");
+ let stop = root["hooks"]["Stop"].as_array().unwrap();
+ assert_eq!(
+ stop.iter().filter(|m| matcher_contains_marker(m)).count(),
+ 1,
+ "exactly one tty7 entry after two installs"
+ );
+ assert!(
+ stop.iter()
+ .any(|m| m.to_string().contains("afplay ding.aiff")),
+ "the user's own Stop hook survives"
+ );
+ // All five events are wired.
+ for (event, _) in CLAUDE_HOOK_EVENTS {
+ assert!(
+ root["hooks"][event]
+ .as_array()
+ .unwrap()
+ .iter()
+ .any(matcher_contains_marker),
+ "{event} carries the tty7 hook"
+ );
+ }
+
+ // SAFETY: restore for any later test relying on the default path.
+ unsafe { std::env::remove_var("CLAUDE_CONFIG_DIR") };
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
diff --git a/src/core/agent_prompt.rs b/src/core/agent_prompt.rs
new file mode 100644
index 00000000..f9cd27ab
--- /dev/null
+++ b/src/core/agent_prompt.rs
@@ -0,0 +1,123 @@
+//! Prompt builders that feed terminal context *back into* a running CLI coding
+//! agent — the review-prompt / selection-range-prompt idea, sized to tty7:
+//! take what the user is looking at (a selection in some
+//! pane, the repo's `git diff`) and phrase it as one self-contained prompt to
+//! paste into the agent's PTY. Pure string builders, unit-tested; the UI layer
+//! owns finding the agent pane and writing the bytes.
+
+/// Cap on embedded context (selection or diff) so a pathological selection or
+/// a giant diff can't flood the agent's input buffer. Anything longer is
+/// truncated with an explicit note — the agent can always ask for more.
+const MAX_CONTEXT_BYTES: usize = 24 * 1024;
+
+/// Truncate `text` to [`MAX_CONTEXT_BYTES`] on a char boundary, appending a
+/// note when anything was cut.
+fn capped(text: &str) -> String {
+ if text.len() <= MAX_CONTEXT_BYTES {
+ return text.to_string();
+ }
+ let mut end = MAX_CONTEXT_BYTES;
+ while !text.is_char_boundary(end) {
+ end -= 1;
+ }
+ format!(
+ "{}\n[… truncated by tty7 — ask for the rest if needed]",
+ &text[..end]
+ )
+}
+
+/// A prompt asking the agent to look at terminal output the user selected
+/// (a build error, a stack trace, a failing test). `cwd` locates the context.
+pub fn build_selection_prompt(selection: &str, cwd: Option<&str>) -> Option {
+ let selection = selection.trim_end();
+ if selection.trim().is_empty() {
+ return None;
+ }
+ let mut prompt = String::from(
+ "Here is terminal output I selected in another pane; please take a look and help me address it.",
+ );
+ if let Some(cwd) = cwd.filter(|c| !c.is_empty()) {
+ prompt.push_str(&format!(" It came from a shell running in `{cwd}`."));
+ }
+ prompt.push_str("\n\n```\n");
+ prompt.push_str(&capped(selection));
+ prompt.push_str("\n```");
+ Some(prompt)
+}
+
+/// A prompt asking the agent to review the working tree's diff. `diff` is the
+/// combined `git diff` (+ `git diff --cached`) output, embedded so the agent
+/// needn't re-run it; an empty diff yields `None` (nothing to review).
+pub fn build_diff_review_prompt(diff: &str, cwd: Option<&str>) -> Option {
+ let diff = diff.trim_end();
+ if diff.trim().is_empty() {
+ return None;
+ }
+ let mut prompt = String::from(
+ "Please review the following uncommitted changes in this repository: point out bugs, \
+ regressions, and anything that looks unintended. Keep it focused — this is a working diff, \
+ not a style pass.",
+ );
+ if let Some(cwd) = cwd.filter(|c| !c.is_empty()) {
+ prompt.push_str(&format!(" Repository: `{cwd}`."));
+ }
+ prompt.push_str("\n\n```diff\n");
+ prompt.push_str(&capped(diff));
+ prompt.push_str("\n```");
+ Some(prompt)
+}
+
+/// The bytes that deliver `prompt` into an agent's PTY: a bracketed paste (so
+/// multi-line prompts insert as one block instead of submitting line by line —
+/// every recognized agent's TUI enables bracketed paste), followed by CR to
+/// submit. ESC bytes inside the prompt are stripped, same as the clipboard
+/// paste path, so embedded content can't fake the paste terminator.
+pub fn submit_bytes(prompt: &str) -> Vec {
+ let mut bytes = b"\x1b[200~".to_vec();
+ bytes.extend(prompt.bytes().filter(|&b| b != 0x1b));
+ bytes.extend_from_slice(b"\x1b[201~\r");
+ bytes
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn selection_prompt_embeds_the_text_and_cwd() {
+ let p = build_selection_prompt("error[E0308]: mismatched types", Some("/work/tty7"))
+ .expect("non-empty selection builds");
+ assert!(p.contains("error[E0308]"));
+ assert!(p.contains("/work/tty7"));
+ assert!(p.contains("```"));
+ // Empty / whitespace selections build nothing.
+ assert_eq!(build_selection_prompt(" \n", None), None);
+ }
+
+ #[test]
+ fn diff_prompt_embeds_the_diff() {
+ let p = build_diff_review_prompt("--- a/x\n+++ b/x\n+added", None).unwrap();
+ assert!(p.contains("```diff"));
+ assert!(p.contains("+added"));
+ assert_eq!(build_diff_review_prompt("", None), None);
+ }
+
+ #[test]
+ fn oversized_context_is_truncated_with_a_note() {
+ let big = "x".repeat(MAX_CONTEXT_BYTES + 100);
+ let p = build_selection_prompt(&big, None).unwrap();
+ assert!(p.len() < big.len() + 500);
+ assert!(p.contains("truncated by tty7"));
+ }
+
+ #[test]
+ fn submit_bytes_bracket_and_sanitize() {
+ let bytes = submit_bytes("fix this\nplease");
+ assert!(bytes.starts_with(b"\x1b[200~"));
+ assert!(bytes.ends_with(b"\x1b[201~\r"));
+ // Embedded ESC can't terminate the paste early.
+ let sneaky = submit_bytes("a\x1b[201~; rm -rf /\nb");
+ let inner = &sneaky[6..sneaky.len() - 7];
+ assert!(!inner.contains(&0x1b));
+ }
+}
diff --git a/src/core/cli_agent.rs b/src/core/cli_agent.rs
new file mode 100644
index 00000000..e9a5d039
--- /dev/null
+++ b/src/core/cli_agent.rs
@@ -0,0 +1,838 @@
+//! Third-party CLI coding-agent registry + detection.
+//!
+//! tty7 recognizes when a pane is running someone else's coding agent (Claude
+//! Code, Codex, Gemini CLI, …) so the tab chip can brand it and desktop
+//! notifications can say *which* agent finished or needs you. This is
+//! deliberately *not* tty7's own agent: it only observes and enriches whatever
+//! agent the user launched.
+//!
+//! Detection is command-based: the daemon already
+//! reads the foreground process's `argv` for SSH-context sniffing, so we reuse
+//! that to match the invoked command against a known agent. Matching is a pure
+//! function over `argv` — [`CLIAgent::detect_from_argv`] — kept here in `core`
+//! (framework-light, unit-tested) and called daemon-side, with the resulting
+//! `Option` streamed to the client for the UI.
+//!
+//! The enum is serialized across the daemon↔client protocol, so its variants
+//! are the wire contract; add new agents at the end.
+//!
+//! Beyond identity, this module also defines the *rich status* layer (a
+//! second detection tier): agents whose hooks/plugins emit tty7's OSC 777
+//! sentinel events ([`AGENT_EVENT_SENTINEL`]) get a per-session state machine
+//! ([`AgentSessionState`]: idle / working / waiting-for-you / done) plus the
+//! native session id used for resume-after-restart. Everything here is pure
+//! and unit-tested; the daemon sniffs the events and streams state changes to
+//! the client.
+
+use std::collections::HashMap;
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+
+/// A recognized third-party CLI coding agent. Ordering is the wire contract
+/// (serialized in [`crate::daemon::protocol`]); append, never reorder.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub enum CLIAgent {
+ Claude,
+ Codex,
+ Gemini,
+ Aider,
+ Amp,
+ OpenCode,
+ Copilot,
+ Cursor,
+ Goose,
+ Droid,
+ Pi,
+ Auggie,
+ Hermes,
+ Vibe,
+ Antigravity,
+ Grok,
+ Qwen,
+}
+
+impl CLIAgent {
+ /// Every known agent, for iteration in detection and tests.
+ pub const ALL: [CLIAgent; 17] = [
+ CLIAgent::Claude,
+ CLIAgent::Codex,
+ CLIAgent::Gemini,
+ CLIAgent::Aider,
+ CLIAgent::Amp,
+ CLIAgent::OpenCode,
+ CLIAgent::Copilot,
+ CLIAgent::Cursor,
+ CLIAgent::Goose,
+ CLIAgent::Droid,
+ CLIAgent::Pi,
+ CLIAgent::Auggie,
+ CLIAgent::Hermes,
+ CLIAgent::Vibe,
+ CLIAgent::Antigravity,
+ CLIAgent::Grok,
+ CLIAgent::Qwen,
+ ];
+
+ /// The command names that identify this agent — the launcher binary plus any
+ /// npm/pip package-dir aliases that show up in an interpreter-wrapped `argv`
+ /// (e.g. `node …/@anthropic-ai/claude-code/cli.js`, where the launcher is
+ /// `node` and only the `claude-code` path segment names the agent). All
+ /// lowercase; matched against extension-stripped path segments.
+ fn aliases(self) -> &'static [&'static str] {
+ match self {
+ CLIAgent::Claude => &["claude", "claude-code"],
+ CLIAgent::Codex => &["codex", "codex-cli"],
+ CLIAgent::Gemini => &["gemini", "gemini-cli"],
+ CLIAgent::Aider => &["aider", "aider-chat"],
+ CLIAgent::Amp => &["amp"],
+ CLIAgent::OpenCode => &["opencode"],
+ CLIAgent::Copilot => &["copilot"],
+ CLIAgent::Cursor => &["cursor-agent"],
+ CLIAgent::Goose => &["goose"],
+ CLIAgent::Droid => &["droid"],
+ CLIAgent::Pi => &["pi"],
+ CLIAgent::Auggie => &["auggie"],
+ CLIAgent::Hermes => &["hermes"],
+ CLIAgent::Vibe => &["vibe", "vibe-acp"],
+ CLIAgent::Antigravity => &["agy", "antigravity"],
+ CLIAgent::Grok => &["grok"],
+ CLIAgent::Qwen => &["qwen", "qwen-code"],
+ }
+ }
+
+ /// Stable machine name (lowercase), used as the `agent` field of the OSC
+ /// event protocol and as the value side of user-defined detection rules in
+ /// `config.json` (`agent_commands: {"my-wrapper": "claude"}`).
+ pub fn slug(self) -> &'static str {
+ match self {
+ CLIAgent::Claude => "claude",
+ CLIAgent::Codex => "codex",
+ CLIAgent::Gemini => "gemini",
+ CLIAgent::Aider => "aider",
+ CLIAgent::Amp => "amp",
+ CLIAgent::OpenCode => "opencode",
+ CLIAgent::Copilot => "copilot",
+ CLIAgent::Cursor => "cursor",
+ CLIAgent::Goose => "goose",
+ CLIAgent::Droid => "droid",
+ CLIAgent::Pi => "pi",
+ CLIAgent::Auggie => "auggie",
+ CLIAgent::Hermes => "hermes",
+ CLIAgent::Vibe => "vibe",
+ CLIAgent::Antigravity => "antigravity",
+ CLIAgent::Grok => "grok",
+ CLIAgent::Qwen => "qwen",
+ }
+ }
+
+ /// Look an agent up by its [`slug`](Self::slug) (case-insensitive).
+ pub fn from_slug(name: &str) -> Option {
+ let name = name.trim().to_ascii_lowercase();
+ CLIAgent::ALL.into_iter().find(|a| a.slug() == name)
+ }
+
+ /// Human-readable name for tab chips, notifications, and menus.
+ pub fn display_name(self) -> &'static str {
+ match self {
+ CLIAgent::Claude => "Claude Code",
+ CLIAgent::Codex => "Codex",
+ CLIAgent::Gemini => "Gemini",
+ CLIAgent::Aider => "Aider",
+ CLIAgent::Amp => "Amp",
+ CLIAgent::OpenCode => "OpenCode",
+ CLIAgent::Copilot => "Copilot",
+ CLIAgent::Cursor => "Cursor",
+ CLIAgent::Goose => "Goose",
+ CLIAgent::Droid => "Droid",
+ CLIAgent::Pi => "Pi",
+ CLIAgent::Auggie => "Auggie",
+ CLIAgent::Hermes => "Hermes",
+ CLIAgent::Vibe => "Vibe",
+ CLIAgent::Antigravity => "Antigravity",
+ CLIAgent::Grok => "Grok",
+ CLIAgent::Qwen => "Qwen Code",
+ }
+ }
+
+ /// The shell command that resumes a previous session of this agent by its
+ /// native session id, or `None` for agents without a known resume flag.
+ /// The id is what the agent reported in its `session-start` event (see
+ /// [`AgentEvent`]); commands mirror cmux's per-agent resume table.
+ pub fn resume_command(self, session_id: &str) -> Option {
+ // Ids come from the agent's own events, but they still land on a shell
+ // command line — refuse anything that isn't a plain token so a
+ // malicious/corrupt id can't smuggle shell syntax.
+ if session_id.is_empty()
+ || !session_id
+ .bytes()
+ .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.')
+ {
+ return None;
+ }
+ match self {
+ CLIAgent::Claude => Some(format!("claude --resume {session_id}")),
+ CLIAgent::Codex => Some(format!("codex resume {session_id}")),
+ CLIAgent::Gemini => Some(format!("gemini --resume {session_id}")),
+ CLIAgent::OpenCode => Some(format!("opencode --session {session_id}")),
+ CLIAgent::Amp => Some(format!("amp threads continue {session_id}")),
+ CLIAgent::Cursor => Some(format!("cursor-agent --resume {session_id}")),
+ _ => None,
+ }
+ }
+
+ /// Brand accent (0xRRGGBB) for the tab chip's agent dot. Chosen for legibility
+ /// on both light and dark themes rather than exact brand black/white — a pure
+ /// black or white dot vanishes against one theme, so vendors whose mark is
+ /// monochrome (Codex/OpenAI, Cursor) get a recognizable mid-tone hue instead.
+ pub fn accent_rgb(self) -> u32 {
+ match self {
+ CLIAgent::Claude => 0xD97757, // Claude terracotta
+ CLIAgent::Codex => 0x10A37F, // OpenAI green (black mark reads as this)
+ CLIAgent::Gemini => 0x4285F4, // Google blue
+ CLIAgent::Aider => 0x14B8A6, // teal
+ CLIAgent::Amp => 0xF34E3F, // Amp red
+ CLIAgent::OpenCode => 0x6E56CF, // violet
+ CLIAgent::Copilot => 0x8957E5, // GitHub purple
+ CLIAgent::Cursor => 0x9AA0A6, // Cursor is monochrome → neutral grey
+ CLIAgent::Goose => 0x9A8CFF, // periwinkle
+ CLIAgent::Droid => 0xF59E0B, // amber
+ CLIAgent::Pi => 0x0EA5E9, // sky
+ CLIAgent::Auggie => 0x16A34A, // Augment green
+ CLIAgent::Hermes => 0x8B5CF6, // violet
+ CLIAgent::Vibe => 0xFF7000, // Mistral orange
+ CLIAgent::Antigravity => 0x2563EB, // Google blue (darker than Gemini's)
+ CLIAgent::Grok => 0x64748B, // xAI is monochrome → slate
+ CLIAgent::Qwen => 0x7C3AED, // Qwen purple
+ }
+ }
+
+ /// Asset path of this agent's brand glyph, resolved through the app's
+ /// [`crate::ui::assets`] source and rendered as a white silhouette on the
+ /// brand-colored avatar (gpui rasterizes SVGs to a tinted alpha mask, so the
+ /// mark's own fill is irrelevant — geometry only). Vendors we ship a brand
+ /// mark for point at `icons/agents/…`; the rest fall back to the generic
+ /// gpui-component `bot` glyph so every recognized agent still gets an avatar.
+ pub fn icon_path(self) -> &'static str {
+ match self {
+ CLIAgent::Claude => "icons/agents/claude.svg",
+ CLIAgent::Codex => "icons/agents/codex.svg",
+ CLIAgent::Gemini => "icons/agents/gemini.svg",
+ CLIAgent::Amp => "icons/agents/amp.svg",
+ CLIAgent::OpenCode => "icons/agents/opencode.svg",
+ CLIAgent::Copilot => "icons/agents/copilot.svg",
+ CLIAgent::Cursor => "icons/agents/cursor.svg",
+ CLIAgent::Goose => "icons/agents/goose.svg",
+ CLIAgent::Droid => "icons/agents/droid.svg",
+ // No brand mark bundled → generic robot glyph.
+ CLIAgent::Aider
+ | CLIAgent::Pi
+ | CLIAgent::Auggie
+ | CLIAgent::Hermes
+ | CLIAgent::Vibe
+ | CLIAgent::Antigravity
+ | CLIAgent::Grok
+ | CLIAgent::Qwen => "icons/bot.svg",
+ }
+ }
+
+ /// Match a single extension-stripped, lowercased command token against the
+ /// registry. `None` when nothing matches.
+ fn match_token(token: &str) -> Option {
+ CLIAgent::ALL
+ .into_iter()
+ .find(|a| a.aliases().contains(&token))
+ }
+
+ /// Identify the coding agent a foreground `argv` is running, or `None`.
+ ///
+ /// The strategy is command-name detection:
+ /// 1. Strip any leading `VAR=value` environment assignments (`FOO=1 claude`).
+ /// 2. If the launcher's own basename matches a known agent, that's it — the
+ /// native-binary case (`claude`, `codex`, `gemini`, `aider`, …).
+ /// 3. Otherwise, if the launcher is a script *interpreter* (`node`, `bun`,
+ /// `python`, `npx`, …), scan the remaining path-like arguments for a
+ /// segment that names an agent — the npm/pip-wrapped case
+ /// (`node …/claude-code/cli.js`, `npx @anthropic-ai/claude-code`).
+ ///
+ /// The interpreter gate is what keeps `cat codex.md` or `vim aider.py` from
+ /// false-matching: a non-interpreter launcher only ever matches on its own
+ /// name, never on its arguments.
+ ///
+ /// The production caller (the daemon's foreground poll) goes through
+ /// [`detect_from_argv_with`](Self::detect_from_argv_with) to honor
+ /// user-defined rules; this rule-free form is the pure core the test suite
+ /// exercises.
+ #[cfg_attr(not(test), allow(dead_code))]
+ pub fn detect_from_argv(argv: &[String]) -> Option {
+ Self::detect_from_argv_with(argv, &HashMap::new())
+ }
+
+ /// [`detect_from_argv`](Self::detect_from_argv) extended with user-defined
+ /// rules (`config.json`'s `agent_commands`): a map from a command basename
+ /// to an agent [`slug`](Self::slug), so a personal wrapper (`"cc":
+ /// "claude"`) is branded like the agent it launches — a command allowlist
+ /// keyed by exact basename instead of regex. Custom rules apply to the
+ /// *launcher* only (never to
+ /// interpreter arguments) and lose to a built-in match on the same name.
+ pub fn detect_from_argv_with(
+ argv: &[String],
+ custom: &HashMap,
+ ) -> Option {
+ // 1. Skip leading environment assignments (`KEY=val`). A bare `env` prefix
+ // (`env claude`) is treated as an interpreter below so its target is
+ // scanned.
+ let mut rest = argv
+ .iter()
+ .map(String::as_str)
+ .skip_while(|t| is_env_assignment(t));
+
+ let launcher = rest.next()?;
+ let launcher_stem = base_stem(launcher);
+
+ // 2. Native binary: the launcher itself is the agent — by the built-in
+ // registry first, then by a user-defined rule.
+ if let Some(agent) = CLIAgent::match_token(launcher_stem) {
+ return Some(agent);
+ }
+ if let Some(agent) = custom
+ .get(&launcher_stem.to_ascii_lowercase())
+ .and_then(|slug| CLIAgent::from_slug(slug))
+ {
+ return Some(agent);
+ }
+
+ // 3. Interpreter wrapper: scan the script path / package arg it runs.
+ if is_interpreter(launcher_stem) {
+ for arg in rest {
+ // Only inspect path-like / package-like tokens (the script it
+ // runs), never bare flags or option values.
+ if arg.starts_with('-') {
+ continue;
+ }
+ for segment in arg.split('/') {
+ if let Some(agent) =
+ CLIAgent::match_token(&base_stem(segment).to_ascii_lowercase())
+ {
+ return Some(agent);
+ }
+ }
+ }
+ }
+
+ None
+ }
+}
+
+/// A `KEY=value` shell environment assignment prefix (`FOO=bar cmd`). The `KEY`
+/// must be a non-empty run of identifier chars before the first `=`.
+fn is_env_assignment(token: &str) -> bool {
+ match token.split_once('=') {
+ Some((key, _)) => {
+ // A real env var starts with a letter/underscore and is otherwise
+ // alphanumerics/underscores — this rejects things like `a=b` paths or
+ // `--flag=val` that merely contain `=`.
+ let mut bytes = key.bytes();
+ bytes
+ .next()
+ .is_some_and(|b| b.is_ascii_alphabetic() || b == b'_')
+ && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
+ }
+ None => false,
+ }
+}
+
+/// The final path component with a leading dir and a trailing script extension
+/// stripped, lowercased-ready but case preserved (callers lowercase when they
+/// match interpreter args). `/usr/bin/claude` → `claude`, `cli.js` → `cli`.
+fn base_stem(token: &str) -> &str {
+ let name = Path::new(token)
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or(token);
+ // Strip one known script extension; leave unknown suffixes intact so
+ // `claude-code` stays whole.
+ for ext in [".js", ".mjs", ".cjs", ".ts", ".py", ".rb", ".sh"] {
+ if let Some(stem) = name.strip_suffix(ext) {
+ return stem;
+ }
+ }
+ name
+}
+
+/// Whether a launcher basename is a script interpreter whose argument (rather
+/// than the launcher itself) names the real program — so agent detection should
+/// scan past it. Covers the common Node/Python/Ruby/`env`/`npx` wrappers agents
+/// ship as.
+fn is_interpreter(stem: &str) -> bool {
+ matches!(
+ stem.to_ascii_lowercase().as_str(),
+ "node"
+ | "nodejs"
+ | "bun"
+ | "deno"
+ | "npx"
+ | "pnpm"
+ | "yarn"
+ | "python"
+ | "python3"
+ | "ruby"
+ | "uv"
+ | "uvx"
+ | "env"
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Rich session status — the OSC event protocol + per-pane state machine.
+//
+// Identity detection above answers "*which* agent runs here"; this layer
+// answers "what is it doing". Agent-side hooks (installed by
+// `core::agent_hooks`, or hand-wired for any agent) emit an OSC 777
+// notification whose title is the [`AGENT_EVENT_SENTINEL`] and whose body is a
+// small JSON event. The daemon sniffs those out of the PTY stream, folds them
+// through [`AgentSessionState::apply_event`], and streams the state to the
+// client (`DaemonMsg::AgentStatus`) for status dots, "needs your input"
+// notifications, and session resume. It's a self-describing sentinel channel
+// (OSC 777 + `tty7://cli-agent` sentinel + versioned JSON).
+// ---------------------------------------------------------------------------
+
+/// The OSC 777 notification title that marks a payload as a tty7 agent event
+/// rather than a user-facing notification:
+/// `ESC ] 777;notify;tty7://cli-agent;{json} BEL`.
+pub const AGENT_EVENT_SENTINEL: &str = "tty7://cli-agent";
+
+/// What an agent session is doing right now, coarsely. `Waiting` is the state
+/// the whole feature exists for: the agent stopped mid-turn and needs the user
+/// (a permission prompt, a question) — the moment worth a notification and an
+/// amber dot.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum AgentStatus {
+ /// Session open, no turn in flight (freshly started, or the user hasn't
+ /// prompted since the last turn ended and was seen).
+ #[default]
+ Idle,
+ /// A turn is in flight (prompt submitted, tools running).
+ Working,
+ /// Stopped mid-turn on the user: permission request, question, or an
+ /// opaque "the agent pinged you" notification.
+ Waiting,
+ /// The turn finished; the result is sitting there waiting to be read.
+ Done,
+}
+
+/// Per-pane agent session state, maintained daemon-side and mirrored to the
+/// client. Exists only while an agent is detected in the pane's foreground.
+#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentSessionState {
+ #[serde(default = "AgentSessionState::default_status")]
+ pub status: AgentStatus,
+ /// Human-readable context for `Waiting`/`Done` (e.g. "Claude needs your
+ /// permission to use Bash"), straight from the event.
+ #[serde(default)]
+ pub message: Option,
+ /// The agent's *native* session id (from its `session-start` event), the
+ /// key its own `--resume` flag takes — persisted for restore.
+ #[serde(default)]
+ pub session_id: Option,
+ /// Whether this state came from the rich sentinel channel (hooks
+ /// installed) rather than the opaque OSC 9/777 fallback. Rich state drives
+ /// turn-level notifications; fallback state only paints the dot (the
+ /// agent's own notification text was already toasted by the client).
+ #[serde(default)]
+ pub rich: bool,
+}
+
+impl AgentStatus {
+ /// The status dot color (0xRRGGBB) shared by the tab chip and the sidebar,
+ /// or `None` for `Idle` (no dot — a resting agent is just its brand mark).
+ pub fn dot_rgb(self) -> Option {
+ match self {
+ AgentStatus::Idle => None,
+ AgentStatus::Working => Some(0x3B82F6), // blue: in flight
+ AgentStatus::Waiting => Some(0xF59E0B), // amber: needs you
+ AgentStatus::Done => Some(0x22C55E), // green: result ready
+ }
+ }
+}
+
+impl AgentSessionState {
+ fn default_status() -> AgentStatus {
+ AgentStatus::Idle
+ }
+
+ /// Fold one rich event into the state. Pure transition function — the
+ /// daemon owns *when* to call it and who to tell.
+ pub fn apply_event(&mut self, ev: &AgentEvent) {
+ self.rich = true;
+ if let Some(id) = &ev.session_id {
+ self.session_id = Some(id.clone());
+ }
+ match ev.kind {
+ AgentEventKind::SessionStart => {
+ self.status = AgentStatus::Idle;
+ self.message = None;
+ }
+ AgentEventKind::PromptSubmit => {
+ self.status = AgentStatus::Working;
+ self.message = None;
+ }
+ // Explicit blocks from agents that distinguish them (Codex/OpenCode
+ // plugins): always the urgent "needs you" state.
+ AgentEventKind::PermissionRequest | AgentEventKind::QuestionAsked => {
+ self.status = AgentStatus::Waiting;
+ self.message = ev.message.clone();
+ }
+ // Claude Code overloads its single Notification hook: it fires
+ // *mid-turn* for a permission/decision prompt (a genuine block worth
+ // the amber "needs you" state), but ALSO fires *between* turns as an
+ // idle "Claude is waiting for your input" reminder — which must not
+ // masquerade as a block. Escalate only when a turn is actually in
+ // flight; otherwise it's a passive nudge and the current state
+ // (typically Done, freshly replied) stands. Keyed on turn phase, not
+ // the message text, so it survives version/locale changes.
+ AgentEventKind::Notification => {
+ if self.status == AgentStatus::Working {
+ self.status = AgentStatus::Waiting;
+ self.message = ev.message.clone();
+ }
+ }
+ AgentEventKind::Stop => {
+ self.status = AgentStatus::Done;
+ self.message = ev.message.clone();
+ }
+ // The agent session ended but its id stays: Claude & friends can
+ // resume an *ended* session, which is exactly what restore does.
+ AgentEventKind::SessionEnd => {
+ self.status = AgentStatus::Idle;
+ self.message = None;
+ }
+ }
+ }
+}
+
+/// The event vocabulary of the sentinel protocol (`"event"` in the JSON).
+/// Deliberately a superset of what any one agent emits: Claude Code hooks map
+/// onto session-start / prompt-submit / notification / stop / session-end,
+/// while permission-request / question-asked are there for agents (Codex,
+/// OpenCode plugins) that can distinguish them.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum AgentEventKind {
+ SessionStart,
+ PromptSubmit,
+ PermissionRequest,
+ QuestionAsked,
+ Notification,
+ Stop,
+ SessionEnd,
+}
+
+/// One parsed sentinel event.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct AgentEvent {
+ /// Which agent sent it, when the payload names one we know. Lets the event
+ /// brand a pane even where argv detection can't see the process (a wrapper
+ /// we don't recognize).
+ pub agent: Option,
+ pub kind: AgentEventKind,
+ pub session_id: Option,
+ pub message: Option,
+}
+
+/// Parse a complete OSC payload (identifier included, e.g.
+/// `777;notify;tty7://cli-agent;{"v":1,…}`) into an [`AgentEvent`]. `None` for
+/// anything that isn't a well-formed sentinel event — including unknown
+/// `event` values, so the protocol can grow without old daemons
+/// mis-classifying new events.
+pub fn parse_agent_event(payload: &[u8]) -> Option {
+ let rest = payload.strip_prefix(b"777;notify;")?;
+ let rest = rest.strip_prefix(AGENT_EVENT_SENTINEL.as_bytes())?;
+ let json = rest.strip_prefix(b";")?;
+
+ #[derive(Deserialize)]
+ struct Wire {
+ // Protocol version; v1 is all that exists. Kept for forward evolution.
+ #[serde(default)]
+ #[allow(dead_code)]
+ v: u32,
+ #[serde(default)]
+ agent: Option,
+ event: String,
+ #[serde(default)]
+ session_id: Option,
+ #[serde(default)]
+ message: Option,
+ }
+
+ let w: Wire = serde_json::from_slice(json).ok()?;
+ let kind = serde_json::from_value::(serde_json::Value::String(w.event)).ok()?;
+ let nonempty = |s: Option| s.filter(|s| !s.trim().is_empty());
+ Some(AgentEvent {
+ agent: w.agent.as_deref().and_then(CLIAgent::from_slug),
+ kind,
+ session_id: nonempty(w.session_id),
+ message: nonempty(w.message),
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn argv(parts: &[&str]) -> Vec {
+ parts.iter().map(|s| s.to_string()).collect()
+ }
+
+ #[test]
+ fn detects_native_binaries() {
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["claude"])),
+ Some(CLIAgent::Claude)
+ );
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["/opt/homebrew/bin/codex", "--model", "o3"])),
+ Some(CLIAgent::Codex)
+ );
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["/usr/local/bin/gemini"])),
+ Some(CLIAgent::Gemini)
+ );
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["cursor-agent"])),
+ Some(CLIAgent::Cursor)
+ );
+ }
+
+ #[test]
+ fn strips_leading_env_assignments() {
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["FOO=1", "BAR=baz", "claude"])),
+ Some(CLIAgent::Claude)
+ );
+ }
+
+ #[test]
+ fn detects_node_wrapped_claude_by_package_dir() {
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&[
+ "node",
+ "/Users/x/.npm/_npx/node_modules/@anthropic-ai/claude-code/cli.js",
+ ])),
+ Some(CLIAgent::Claude)
+ );
+ }
+
+ #[test]
+ fn detects_npx_package_form() {
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["npx", "@anthropic-ai/claude-code"])),
+ Some(CLIAgent::Claude)
+ );
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["npx", "@google/gemini-cli"])),
+ Some(CLIAgent::Gemini)
+ );
+ }
+
+ #[test]
+ fn detects_python_wrapped_aider() {
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&[
+ "python3",
+ "/usr/lib/python3.12/site-packages/aider/__main__.py",
+ ])),
+ Some(CLIAgent::Aider)
+ );
+ }
+
+ #[test]
+ fn non_interpreter_does_not_match_on_arguments() {
+ // A file *named* like an agent, opened by an unrelated tool, must not
+ // trip detection — only interpreters have their args scanned.
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["cat", "codex.md"])),
+ None
+ );
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["vim", "claude-code/notes.txt"])),
+ None
+ );
+ assert_eq!(CLIAgent::detect_from_argv(&argv(&["less", "aider"])), None);
+ }
+
+ #[test]
+ fn unrelated_commands_are_none() {
+ assert_eq!(CLIAgent::detect_from_argv(&argv(&["zsh"])), None);
+ assert_eq!(
+ CLIAgent::detect_from_argv(&argv(&["node", "server.js"])),
+ None
+ );
+ assert_eq!(CLIAgent::detect_from_argv(&argv(&[])), None);
+ }
+
+ #[test]
+ fn every_agent_has_metadata() {
+ for a in CLIAgent::ALL {
+ assert!(!a.display_name().is_empty());
+ assert!(!a.aliases().is_empty());
+ assert!(a.accent_rgb() <= 0xFFFFFF);
+ assert_eq!(CLIAgent::from_slug(a.slug()), Some(a));
+ }
+ }
+
+ #[test]
+ fn detects_newer_agents_by_command() {
+ for (cmd, agent) in [
+ ("auggie", CLIAgent::Auggie),
+ ("agy", CLIAgent::Antigravity),
+ ("vibe-acp", CLIAgent::Vibe),
+ ("grok", CLIAgent::Grok),
+ ("/usr/local/bin/qwen", CLIAgent::Qwen),
+ ("pi", CLIAgent::Pi),
+ ("hermes", CLIAgent::Hermes),
+ ] {
+ assert_eq!(CLIAgent::detect_from_argv(&argv(&[cmd])), Some(agent));
+ }
+ }
+
+ #[test]
+ fn custom_rules_map_wrappers_to_agents() {
+ let custom: HashMap = [("cc".to_string(), "claude".to_string())].into();
+ assert_eq!(
+ CLIAgent::detect_from_argv_with(&argv(&["/home/x/bin/cc", "-c"]), &custom),
+ Some(CLIAgent::Claude)
+ );
+ // A rule naming an unknown agent is ignored, not an error.
+ let bogus: HashMap = [("cc".to_string(), "hal9000".to_string())].into();
+ assert_eq!(
+ CLIAgent::detect_from_argv_with(&argv(&["cc"]), &bogus),
+ None
+ );
+ // Custom rules never scan interpreter arguments.
+ assert_eq!(
+ CLIAgent::detect_from_argv_with(&argv(&["node", "cc/cli.js"]), &custom),
+ None
+ );
+ // Built-ins still win on their own names.
+ let shadow: HashMap = [("codex".to_string(), "claude".to_string())].into();
+ assert_eq!(
+ CLIAgent::detect_from_argv_with(&argv(&["codex"]), &shadow),
+ Some(CLIAgent::Codex)
+ );
+ }
+
+ #[test]
+ fn parses_sentinel_events() {
+ let ev = parse_agent_event(
+ br#"777;notify;tty7://cli-agent;{"v":1,"agent":"claude","event":"permission-request","session_id":"abc-123","message":"Claude needs your permission to use Bash"}"#,
+ )
+ .expect("well-formed sentinel event");
+ assert_eq!(ev.agent, Some(CLIAgent::Claude));
+ assert_eq!(ev.kind, AgentEventKind::PermissionRequest);
+ assert_eq!(ev.session_id.as_deref(), Some("abc-123"));
+ assert!(ev.message.as_deref().unwrap().contains("permission"));
+
+ // A plain OSC 777 notification is NOT an event.
+ assert_eq!(parse_agent_event(b"777;notify;Build;done"), None);
+ // Unknown event names are dropped (forward evolution).
+ assert_eq!(
+ parse_agent_event(br#"777;notify;tty7://cli-agent;{"event":"quantum-leap"}"#),
+ None
+ );
+ // Malformed JSON is dropped.
+ assert_eq!(
+ parse_agent_event(b"777;notify;tty7://cli-agent;{oops"),
+ None
+ );
+ }
+
+ #[test]
+ fn session_state_machine_follows_the_turn() {
+ let mut s = AgentSessionState::default();
+ assert_eq!(s.status, AgentStatus::Idle);
+
+ let ev = |kind, msg: Option<&str>, id: Option<&str>| AgentEvent {
+ agent: Some(CLIAgent::Claude),
+ kind,
+ session_id: id.map(String::from),
+ message: msg.map(String::from),
+ };
+
+ s.apply_event(&ev(AgentEventKind::SessionStart, None, Some("sid-1")));
+ assert_eq!(s.status, AgentStatus::Idle);
+ assert_eq!(s.session_id.as_deref(), Some("sid-1"));
+ assert!(s.rich);
+
+ s.apply_event(&ev(AgentEventKind::PromptSubmit, None, None));
+ assert_eq!(s.status, AgentStatus::Working);
+
+ // A Notification arriving MID-TURN (while Working) is a real block —
+ // a permission/decision prompt — so it escalates to Waiting.
+ s.apply_event(&ev(
+ AgentEventKind::Notification,
+ Some("Claude needs your permission"),
+ None,
+ ));
+ assert_eq!(s.status, AgentStatus::Waiting);
+ assert!(s.message.as_deref().unwrap().contains("permission"));
+
+ s.apply_event(&ev(AgentEventKind::Stop, None, None));
+ assert_eq!(s.status, AgentStatus::Done);
+
+ // A Notification arriving BETWEEN turns (while Done) is Claude Code's
+ // idle "waiting for your input" nudge, NOT a block — it must not flip
+ // the finished-and-green session to amber "needs you".
+ s.apply_event(&ev(
+ AgentEventKind::Notification,
+ Some("Claude is waiting for your input"),
+ None,
+ ));
+ assert_eq!(
+ s.status,
+ AgentStatus::Done,
+ "an idle notification between turns must not fabricate a block"
+ );
+
+ // Session end goes idle but KEEPS the id — ended sessions resume.
+ s.apply_event(&ev(AgentEventKind::SessionEnd, None, None));
+ assert_eq!(s.status, AgentStatus::Idle);
+ assert_eq!(s.session_id.as_deref(), Some("sid-1"));
+ }
+
+ #[test]
+ fn resume_commands_are_shell_safe() {
+ assert_eq!(
+ CLIAgent::Claude.resume_command("abc-123").as_deref(),
+ Some("claude --resume abc-123")
+ );
+ assert_eq!(
+ CLIAgent::Codex.resume_command("th_read.9").as_deref(),
+ Some("codex resume th_read.9")
+ );
+ // No resume flag known → None.
+ assert_eq!(CLIAgent::Aider.resume_command("abc"), None);
+ // An id carrying shell syntax is refused outright.
+ assert_eq!(CLIAgent::Claude.resume_command("abc; rm -rf /"), None);
+ assert_eq!(CLIAgent::Claude.resume_command("$(boom)"), None);
+ assert_eq!(CLIAgent::Claude.resume_command(""), None);
+ }
+
+ #[test]
+ fn status_metadata_is_consistent() {
+ assert_eq!(AgentStatus::Idle.dot_rgb(), None);
+ for st in [
+ AgentStatus::Working,
+ AgentStatus::Waiting,
+ AgentStatus::Done,
+ ] {
+ assert!(st.dot_rgb().is_some());
+ }
+ // Wire form is kebab-case (shared with the JSON protocol).
+ assert_eq!(
+ serde_json::to_string(&AgentStatus::Waiting).unwrap(),
+ "\"waiting\""
+ );
+ }
+}
diff --git a/src/core/config.rs b/src/core/config.rs
index d5ce8526..173f0d81 100644
--- a/src/core/config.rs
+++ b/src/core/config.rs
@@ -198,6 +198,22 @@ pub struct Config {
/// rows. Entries for deleted profiles are harmless (never surfaced).
#[serde(default)]
pub ssh_profile_frecency: HashMap,
+
+ // ── CLI coding agents ────────────────────────────────────────────────────
+ /// User-defined agent-detection rules: a command basename → an agent slug
+ /// (`{"cc": "claude", "my-codex": "codex"}`), so personal wrappers get
+ /// branded like the agent they launch. Complements the built-in registry in
+ /// [`crate::core::cli_agent`]; built-ins win on their own names. The daemon
+ /// reads this once per process (restart the daemon to apply changes).
+ #[serde(default)]
+ pub agent_commands: HashMap,
+ /// On session restore, when a pane can't re-attach (the daemon lost it —
+ /// reboot, daemon restart) but it was running a coding agent whose native
+ /// session id we captured, type that agent's resume command into the fresh
+ /// shell (`claude --resume `, `codex resume `, …) so the
+ /// conversation continues where it left off. cmux-style; on by default.
+ #[serde(default = "default_true")]
+ pub restore_agent_sessions: bool,
}
/// One saved profile's usage record for palette frecency (see
@@ -426,6 +442,8 @@ impl Default for Config {
verify_host_keys: true,
ssh_warn_on_close: false,
ssh_profile_frecency: HashMap::new(),
+ agent_commands: HashMap::new(),
+ restore_agent_sessions: true,
}
}
}
@@ -649,6 +667,22 @@ pub fn extra_env() -> HashMap {
Config::load().env
}
+/// User-defined agent-detection rules (`agent_commands`), keys lowercased,
+/// cached once per process. The daemon consults this from its 0.5 s foreground
+/// poll on every pane, so it must not re-read `config.json` each time; the
+/// trade-off is that rule edits apply on the next daemon start (the GUI's
+/// "Restart daemon" command counts).
+pub fn agent_commands_cached() -> &'static HashMap {
+ static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new();
+ CACHE.get_or_init(|| {
+ Config::load()
+ .agent_commands
+ .into_iter()
+ .map(|(k, v)| (k.to_ascii_lowercase(), v))
+ .collect()
+ })
+}
+
/// Serde default for [`Config::keybinding_preset`]: the no-op `"default"` preset.
fn default_preset() -> String {
"default".to_string()
diff --git a/src/core/mod.rs b/src/core/mod.rs
index c2a38f47..44b7e97c 100644
--- a/src/core/mod.rs
+++ b/src/core/mod.rs
@@ -8,6 +8,9 @@
//! code.
pub mod actions;
+pub mod agent_hooks;
+pub mod agent_prompt;
+pub mod cli_agent;
pub mod config;
// SSH connection-manager data layer (WS1). Its public API is consumed by the
// daemon-session, auth, forwarding, and UI workstreams, which land separately —
diff --git a/src/core/osc.rs b/src/core/osc.rs
index 78df7d92..871fd477 100644
--- a/src/core/osc.rs
+++ b/src/core/osc.rs
@@ -179,6 +179,48 @@ impl OscTokenizer {
}
}
+/// Parse a buffered OSC payload (the bytes after `ESC ]`, e.g. `9;Build done`
+/// or `777;notify;Title;Body`) into a `(title, body)` desktop notification, or
+/// `None` if it isn't one. Shared by the client's notification toaster
+/// (`terminal::remote`) and the daemon's agent-status sniffer (`daemon::pane`),
+/// so ConEmu's OSC 9 subcommand quirks are handled in exactly one place.
+///
+/// tty7's own agent-event sentinel (`777;notify;tty7://cli-agent;{json}` — see
+/// [`crate::core::cli_agent::AGENT_EVENT_SENTINEL`]) parses as a notification
+/// *shape*, but it is machine-to-machine traffic: callers that surface toasts
+/// must check for it first (via [`crate::core::cli_agent::parse_agent_event`])
+/// rather than showing the raw JSON to the user.
+pub fn parse_notification(payload: &[u8]) -> Option<(Option, String)> {
+ // OSC 9 ; — iTerm2 / growl style; title-less, body is the text.
+ if let Some(rest) = payload.strip_prefix(b"9;") {
+ // ConEmu overloads OSC 9 with numeric subcommands (`9;4;…` progress,
+ // `9;9;`, …); those aren't notifications, so skip a `;`/``
+ // leading field. A real message rarely starts with a bare single digit.
+ let first = rest.split(|&b| b == b';').next().unwrap_or(rest);
+ if first.len() == 1 && first[0].is_ascii_digit() {
+ return None;
+ }
+ let body = String::from_utf8_lossy(rest).into_owned();
+ return (!body.is_empty()).then_some((None, body));
+ }
+ // OSC 777 ; notify ; ; — urxvt style.
+ if let Some(rest) = payload.strip_prefix(b"777;notify;") {
+ let mut parts = rest.splitn(2, |&b| b == b';');
+ let first = String::from_utf8_lossy(parts.next().unwrap_or(b"")).into_owned();
+ let second = parts
+ .next()
+ .map(|b| String::from_utf8_lossy(b).into_owned());
+ // With both fields present it's title + body; with only one it's a body-only
+ // notification (some senders omit the title).
+ let (title, body) = match second {
+ Some(body) if !body.is_empty() => (Some(first), body),
+ _ => (None, first),
+ };
+ return (!body.is_empty()).then_some((title, body));
+ }
+ None
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/core/session.rs b/src/core/session.rs
index 2de9962a..854e9f1b 100644
--- a/src/core/session.rs
+++ b/src/core/session.rs
@@ -44,6 +44,16 @@ pub enum SessionPane {
/// for sessions written before this field existed.
#[serde(default)]
ssh_spec: Option>,
+ /// The coding agent this leaf was running at save time, plus its native
+ /// session id (from the agent's own `session-start` event). When the
+ /// pane can't re-attach on restore, these drive the cmux-style resume:
+ /// the fresh shell is handed the agent's resume command
+ /// (`claude --resume `, …) so the conversation continues. `None`
+ /// for panes without an agent, agents without hooks, or old sessions.
+ #[serde(default)]
+ agent: Option,
+ #[serde(default)]
+ agent_session_id: Option,
},
/// A split of two subtrees along `axis`, with `a` taking `ratio` of space.
Split {
@@ -173,6 +183,8 @@ mod tests {
cwd: Some(PathBuf::from("/work")),
pane_id: Some(7),
ssh_spec: None,
+ agent: None,
+ agent_session_id: None,
},
},
SessionTab {
@@ -184,11 +196,15 @@ mod tests {
cwd: None,
pane_id: None,
ssh_spec: None,
+ agent: None,
+ agent_session_id: None,
}),
b: Box::new(SessionPane::Leaf {
cwd: Some(PathBuf::from("/tmp")),
pane_id: Some(9),
ssh_spec: None,
+ agent: None,
+ agent_session_id: None,
}),
},
},
@@ -211,6 +227,42 @@ mod tests {
}
}
+ #[test]
+ fn leaf_agent_resume_fields_round_trip_and_default() {
+ // Round trip: the agent + native session id survive serialization.
+ let leaf = SessionPane::Leaf {
+ cwd: None,
+ pane_id: None,
+ ssh_spec: None,
+ agent: Some(crate::core::cli_agent::CLIAgent::Claude),
+ agent_session_id: Some("abc-123".into()),
+ };
+ let back: SessionPane =
+ serde_json::from_str(&serde_json::to_string(&leaf).unwrap()).unwrap();
+ match back {
+ SessionPane::Leaf {
+ agent,
+ agent_session_id,
+ ..
+ } => {
+ assert_eq!(agent, Some(crate::core::cli_agent::CLIAgent::Claude));
+ assert_eq!(agent_session_id.as_deref(), Some("abc-123"));
+ }
+ _ => panic!("expected leaf"),
+ }
+ // A session written before these fields existed decodes with `None`s.
+ let old: SessionPane =
+ serde_json::from_str(r#"{"Leaf":{"cwd":"/x","pane_id":3}}"#).unwrap();
+ assert!(matches!(
+ old,
+ SessionPane::Leaf {
+ agent: None,
+ agent_session_id: None,
+ ..
+ }
+ ));
+ }
+
#[test]
fn session_defaults_fill_missing_fields() {
// An empty object → default (active 0, no tabs).
@@ -242,6 +294,8 @@ mod tests {
cwd: Some(PathBuf::from("/home/u")),
pane_id: Some(1),
ssh_spec: None,
+ agent: None,
+ agent_session_id: None,
},
}],
};
diff --git a/src/daemon/pane.rs b/src/daemon/pane.rs
index 50bf77c5..8f6ce251 100644
--- a/src/daemon/pane.rs
+++ b/src/daemon/pane.rs
@@ -221,6 +221,14 @@ fn apply_common_command_setup(cmd: &mut CommandBuilder, initial_cwd: &Option,
+ /// The third-party CLI coding agent running in the foreground, detected from
+ /// the foreground `argv` (same process-table poll as `remote`). `None` when
+ /// no known agent runs — see [`crate::core::cli_agent`].
+ agent: Option,
+ /// The rich agent-session status (idle/working/waiting/done + native
+ /// session id), folded from the sentinel OSC events the agent's hooks emit
+ /// (with an opaque OSC 9/777 fallback). Cleared when the agent exits.
+ /// See [`crate::core::cli_agent::AgentSessionState`].
+ agent_session: Option,
/// Last geometry the PTY was sized to (spawn size, then each `resize`).
/// Reported to a re-attaching client as `DaemonMsg::Size` so its replay of
/// the ring runs at the geometry the ring was recorded under.
@@ -373,6 +390,18 @@ enum PaneBackend {
NativeSsh(NativeSshBackend),
}
+/// The reader thread's two off-hot-path foreground probes, bundled so
+/// [`DaemonPane::spawn_reader`] takes them as one argument. Both are
+/// process-table reads (foreground process-group leader → `argv`) run together
+/// on the reader's 0.5 s poll: `remote` classifies an SSH context, `agent`
+/// classifies a third-party coding agent. Boxed rather than generic because
+/// they're invoked at most twice a second — the indirection is free here and
+/// keeps the reader's signature readable.
+struct ForegroundProbes {
+ remote: Box Option + Send>,
+ agent: Box Option + Send>,
+}
+
/// The local-PTY backend: the same handles `DaemonPane` has always owned.
struct PtyBackend {
/// The PTY master. Kept for the pane's lifetime to `resize` it and to query the
@@ -533,6 +562,8 @@ impl DaemonPane {
cwd: spawn.initial_cwd,
shell: ShellState::default(),
remote: spawn.remote.clone(),
+ agent: None,
+ agent_session: None,
size,
alive: true,
}));
@@ -579,13 +610,17 @@ impl DaemonPane {
// keyboard — see `foreground_command_running` / issue #26.
let fg_master = master.clone();
let remote_master = master.clone();
+ let agent_master = master.clone();
let reader = Self::spawn_reader(
state,
shutting_down,
gate,
reader_handle,
move || foreground_command_running(&fg_master, shell_pid),
- move || foreground_remote_context(&remote_master),
+ ForegroundProbes {
+ remote: Box::new(move || foreground_remote_context(&remote_master)),
+ agent: Box::new(move || foreground_agent(&agent_master)),
+ },
death,
);
*pane.reader.lock().unwrap() = Some(reader);
@@ -634,6 +669,10 @@ impl DaemonPane {
cwd: None,
shell: ShellState::default(),
remote: Some(remote),
+ // A native-SSH pane has no local process group, so foreground-argv
+ // agent detection never runs for it.
+ agent: None,
+ agent_session: None,
size,
alive: true,
}));
@@ -679,7 +718,10 @@ impl DaemonPane {
gate,
reader_handle,
|| false,
- || None,
+ ForegroundProbes {
+ remote: Box::new(|| None),
+ agent: Box::new(|| None),
+ },
death,
);
*pane.reader.lock().unwrap() = Some(reader);
@@ -727,6 +769,10 @@ impl DaemonPane {
/// `Prompt` on change. On EOF it reports the death through `death` — marking
/// the pane not-alive and sending `Exited`, keeping the ring for a later
/// attach, or handing an unattached pane to `on_dead` (see [`DeathReporter`]).
+ ///
+ /// The two off-hot-path foreground probes (remote context + coding agent)
+ /// travel together in [`ForegroundProbes`]: both are process-table reads run
+ /// on the same 0.5 s poll, and bundling them keeps the signature at arity.
fn spawn_reader(
state: Arc>,
shutting_down: Arc,
@@ -736,9 +782,13 @@ impl DaemonPane {
// when a prompt mark arrives, to reject marks a foreground program emits —
// see the call site and [`foreground_command_running`].
foreground_running: impl Fn() -> bool + Send + 'static,
- foreground_remote: impl Fn() -> Option + Send + 'static,
+ probes: ForegroundProbes,
death: Arc,
) -> JoinHandle<()> {
+ let ForegroundProbes {
+ remote: foreground_remote,
+ agent: foreground_agent_fn,
+ } = probes;
std::thread::Builder::new()
.name("tty7-daemon-pane-reader".to_string())
.spawn(move || {
@@ -815,14 +865,19 @@ impl DaemonPane {
}
}
- // SSH-context detection is a process-table query
- // (sysctl/procfs). Keep it out of the state lock and
- // off the per-chunk hot path; half-second freshness is
- // enough for link hover/click state while keeping PTY
- // drain latency predictable.
- let remote = if std::time::Instant::now() >= next_remote_check {
+ // SSH-context + coding-agent detection are process-table
+ // queries (sysctl/procfs). Keep them out of the state lock
+ // and off the per-chunk hot path; half-second freshness is
+ // enough for link hover/click state and the agent tab chip
+ // while keeping PTY drain latency predictable. Both ride the
+ // one poll gate so we read the foreground process at most
+ // twice per interval.
+ let poll_now = std::time::Instant::now() >= next_remote_check;
+ if poll_now {
next_remote_check =
std::time::Instant::now() + REMOTE_CONTEXT_POLL_INTERVAL;
+ }
+ let remote = if poll_now {
// A native-SSH pane already carries its own remote
// context; process-table detection must not clobber
// it (this pane *is* SSH). Only a plain PTY pane gets
@@ -837,6 +892,7 @@ impl DaemonPane {
} else {
None
};
+ let agent = poll_now.then(&foreground_agent_fn);
let tr1 = trace.then(std::time::Instant::now);
let mut st = state.lock().unwrap();
@@ -854,6 +910,9 @@ impl DaemonPane {
if let Some(remote) = remote {
apply_remote_context(&mut st, remote);
}
+ if let Some(agent) = agent {
+ apply_agent(&mut st, agent);
+ }
if let Some(tr1) = tr1 {
tr_disp_t += tr1.elapsed();
}
@@ -1394,6 +1453,12 @@ fn attach_subscriber(st: &mut PaneState, subscriber: Sender) -> u64 {
if st.remote.is_some() {
let _ = subscriber.send(DaemonMsg::RemoteContext(st.remote.clone()));
}
+ if st.agent.is_some() {
+ let _ = subscriber.send(DaemonMsg::Agent(st.agent));
+ }
+ if st.agent_session.is_some() {
+ let _ = subscriber.send(DaemonMsg::AgentStatus(st.agent_session.clone()));
+ }
// A dead pane's reader thread — the one that reports the child's exit — is
// long gone, so replay its exit too: without this an attach racing the
// child's death (it exited between the client's `List` and its `Attach`)
@@ -1427,6 +1492,61 @@ fn apply_signals(st: &mut PaneState, signals: SniffSignals) {
});
}
}
+ apply_agent_signals(st, signals.agent_events, signals.notification);
+}
+
+/// Fold the chunk's agent signals into the pane's session state and push any
+/// resulting change. Called with the state lock held.
+///
+/// Two tiers: sentinel events (hooks installed) drive the full
+/// state machine and may even *identify* the agent where argv detection can't
+/// see through a wrapper; a plain OSC 9/777 notification is the no-hooks
+/// fallback — it only means "the agent pinged you", so it marks the session
+/// `Waiting` (non-rich) and never overrides live rich state.
+fn apply_agent_signals(
+ st: &mut PaneState,
+ events: Vec,
+ notification: Option,
+) {
+ use crate::core::cli_agent::{AgentSessionState, AgentStatus};
+
+ if events.is_empty() && notification.is_none() {
+ return;
+ }
+ let before = st.agent_session.clone();
+
+ for event in &events {
+ // An event naming an agent brands the pane even when the process-table
+ // poll can't (an unrecognized wrapper binary): identity via protocol.
+ if st.agent.is_none() && event.agent.is_some() {
+ st.agent = event.agent;
+ if let Some(sub) = &st.subscriber {
+ let _ = sub.send(DaemonMsg::Agent(st.agent));
+ }
+ }
+ st.agent_session
+ .get_or_insert_with(AgentSessionState::default)
+ .apply_event(event);
+ }
+
+ // Opaque fallback: only meaningful when we know an agent runs here, and
+ // never on top of rich state (the hooks channel owns it then).
+ if let Some(body) = notification
+ && st.agent.is_some()
+ && !st.agent_session.as_ref().is_some_and(|s| s.rich)
+ {
+ let sess = st
+ .agent_session
+ .get_or_insert_with(AgentSessionState::default);
+ sess.status = AgentStatus::Waiting;
+ sess.message = Some(body);
+ }
+
+ if st.agent_session != before
+ && let Some(sub) = &st.subscriber
+ {
+ let _ = sub.send(DaemonMsg::AgentStatus(st.agent_session.clone()));
+ }
}
fn apply_remote_context(st: &mut PaneState, remote: Option) {
@@ -1439,6 +1559,26 @@ fn apply_remote_context(st: &mut PaneState, remote: Option) {
st.remote = remote;
}
+fn apply_agent(st: &mut PaneState, agent: Option) {
+ if st.agent == agent {
+ return;
+ }
+ // The agent leaving the foreground ends its session: clear the rich state
+ // (and tell the client) so a stale "waiting" dot can't outlive the process.
+ // The poll can blip momentarily (an agent-spawned subcommand takes the
+ // foreground group), but events re-establish state on the next signal.
+ if agent.is_none() && st.agent_session.is_some() {
+ st.agent_session = None;
+ if let Some(sub) = &st.subscriber {
+ let _ = sub.send(DaemonMsg::AgentStatus(None));
+ }
+ }
+ if let Some(sub) = &st.subscriber {
+ let _ = sub.send(DaemonMsg::Agent(agent));
+ }
+ st.agent = agent;
+}
+
/// Whether a foreground command — not the shell itself — currently owns the
/// PTY. True while e.g. `ssh`, `vim`, or a nested shell runs; false when the
/// shell sits idle at its own prompt (it is then the terminal's foreground
@@ -1494,6 +1634,28 @@ fn foreground_remote_context(_master: &Mutex>) -> Opti
None
}
+/// Identify the third-party CLI coding agent (Claude Code, Codex, …) owning the
+/// PTY foreground, from its `argv`. Same process-table read as
+/// [`foreground_remote_context`]; runs off the hot path on the 0.5 s poll.
+#[cfg(any(target_os = "macos", target_os = "linux"))]
+fn foreground_agent(
+ master: &Mutex>,
+) -> Option {
+ let pid = master.lock().ok().and_then(|m| m.process_group_leader())?;
+ let argv = crate::daemon::remote::foreground_argv(pid)?;
+ crate::core::cli_agent::CLIAgent::detect_from_argv_with(
+ &argv,
+ crate::core::config::agent_commands_cached(),
+ )
+}
+
+#[cfg(not(any(target_os = "macos", target_os = "linux")))]
+fn foreground_agent(
+ _master: &Mutex>,
+) -> Option {
+ None
+}
+
// ---------------------------------------------------------------------------
// OSC sniffer (cwd + prompt). The byte-level OSC framing lives in
// `core::osc::OscTokenizer` (shared with the client's notification scanner);
@@ -1515,6 +1677,14 @@ struct ShellState {
struct SniffSignals {
cwd: Option,
shell: Option,
+ /// Sentinel agent events completed in this chunk, in stream order — each
+ /// one is a state-machine step, so unlike cwd/shell they must *all* apply
+ /// (a `stop` directly after a `notification` still means "done").
+ agent_events: Vec,
+ /// A plain (non-sentinel) OSC 9/777 desktop notification completed in this
+ /// chunk — the opaque "the agent pinged you" fallback signal for panes
+ /// whose agent has no hooks installed. Last body wins.
+ notification: Option,
}
struct OscSniffer {
@@ -1526,7 +1696,10 @@ struct OscSniffer {
impl OscSniffer {
fn new() -> Self {
Self {
- tok: OscTokenizer::new(&[b"7", b"133"]),
+ // 9 / 777 are the notification channels the agent-status layer
+ // rides (sentinel events + opaque fallback); the client sniffs the
+ // same two independently for its desktop toasts.
+ tok: OscTokenizer::new(&[b"7", b"133", b"9", b"777"]),
shell: ShellState::default(),
}
}
@@ -1543,6 +1716,14 @@ impl OscSniffer {
} else if let Some(rest) = payload.strip_prefix(b"133;") {
handle_osc133(shell, rest);
signals.shell = Some(shell.clone());
+ } else if let Some(event) = crate::core::cli_agent::parse_agent_event(payload) {
+ signals.agent_events.push(event);
+ } else if let Some((title, body)) = crate::core::osc::parse_notification(payload) {
+ // A sentinel-titled payload whose JSON failed to parse is
+ // protocol traffic, not a user notification — drop it.
+ if title.as_deref() != Some(crate::core::cli_agent::AGENT_EVENT_SENTINEL) {
+ signals.notification = Some(body);
+ }
}
});
signals
@@ -1702,6 +1883,57 @@ fn proc_name(pid: i32) -> Option {
mod tests {
use super::*;
+ /// End-to-end check of the *live* agent-detection chain this feature rides
+ /// on macOS/Linux: spawn a real PTY child whose `argv[0]` names a coding
+ /// agent (`exec -a codex …`), then follow the exact path `foreground_agent`
+ /// uses — read the PTY's foreground process-group leader, read its `argv`
+ /// from the process table, and run `detect_from_argv`. Guards against a
+ /// regression in the platform `process_group_leader` / `foreground_argv`
+ /// plumbing that the pure `detect_from_argv` unit tests can't see.
+ #[cfg(any(target_os = "macos", target_os = "linux"))]
+ #[test]
+ fn live_pty_child_argv_detects_the_agent() {
+ use portable_pty::{CommandBuilder, PtySize, native_pty_system};
+
+ let pty = native_pty_system()
+ .openpty(PtySize {
+ rows: 24,
+ cols: 80,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .expect("openpty");
+ // `exec -a codex` replaces the shell with `cat`, giving it argv[0]=codex
+ // while it blocks on stdin — so it stays the PTY's foreground group long
+ // enough to observe. `cat` (not `sleep`) keeps it alive until the master
+ // is dropped and its stdin EOFs. Must be bash: `exec -a` is a bashism
+ // that dash (Ubuntu's /bin/sh) rejects.
+ let mut cmd = CommandBuilder::new("bash");
+ cmd.args(["-c", "exec -a codex cat"]);
+ let mut child = pty.slave.spawn_command(cmd).expect("spawn child");
+ let master = Mutex::new(pty.master);
+
+ // Poll for the foreground group to become the child (not the transient
+ // `sh`), then detect. Bounded so a stuck spawn fails the test rather than
+ // hanging CI.
+ let mut detected = None;
+ for _ in 0..200 {
+ if let Some(agent) = foreground_agent(&master) {
+ detected = Some(agent);
+ break;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(10));
+ }
+ let _ = child.kill();
+ let _ = child.wait();
+
+ assert_eq!(
+ detected,
+ Some(crate::core::cli_agent::CLIAgent::Codex),
+ "a live PTY child with argv[0]=codex must be detected as Codex"
+ );
+ }
+
/// Spawn shell precedence: explicit override > configured > platform
/// default (`None`). Locks the contract stated on [`choose_shell`].
#[test]
@@ -2220,6 +2452,8 @@ mod tests {
cwd: None,
shell: ShellState::default(),
remote: None,
+ agent: None,
+ agent_session: None,
size: WinSize {
cols: 80,
rows: 24,
@@ -2230,6 +2464,107 @@ mod tests {
}
}
+ /// The full daemon-side rich-status path: sentinel OSC events sniffed out
+ /// of the byte stream drive the pane's session state machine, identify the
+ /// agent when argv detection hasn't, and stream every change to the
+ /// subscriber — while a plain notification only fires the opaque fallback.
+ #[test]
+ fn sentinel_events_drive_agent_session_state() {
+ use crate::core::cli_agent::{AgentStatus, CLIAgent};
+
+ let mut st = test_state(true);
+ let (tx, rx) = mpsc::channel();
+ st.subscriber = Some(tx);
+
+ let mut sniffer = OscSniffer::new();
+ let stream = concat!(
+ "\x1b]777;notify;tty7://cli-agent;",
+ r#"{"v":1,"agent":"claude","event":"session-start","session_id":"sid-9"}"#,
+ "\x07",
+ "\x1b]777;notify;tty7://cli-agent;",
+ r#"{"v":1,"agent":"claude","event":"prompt-submit"}"#,
+ "\x07",
+ );
+ apply_signals(&mut st, sniffer.feed(stream.as_bytes()));
+
+ // The event branded the pane (argv detection never ran here)…
+ assert_eq!(st.agent, Some(CLIAgent::Claude));
+ // …and the state machine folded both events: idle → working, id kept.
+ let sess = st.agent_session.clone().expect("session state exists");
+ assert_eq!(sess.status, AgentStatus::Working);
+ assert_eq!(sess.session_id.as_deref(), Some("sid-9"));
+ assert!(sess.rich);
+
+ // The subscriber saw the identity and the (final) status.
+ assert!(matches!(
+ rx.try_recv(),
+ Ok(DaemonMsg::Agent(Some(CLIAgent::Claude)))
+ ));
+ assert!(matches!(
+ rx.try_recv(),
+ Ok(DaemonMsg::AgentStatus(Some(s))) if s.status == AgentStatus::Working
+ ));
+
+ // A waiting event lands with its message.
+ let waiting = concat!(
+ "\x1b]777;notify;tty7://cli-agent;",
+ r#"{"event":"notification","message":"Claude needs your permission to use Bash"}"#,
+ "\x07",
+ );
+ apply_signals(&mut st, sniffer.feed(waiting.as_bytes()));
+ assert_eq!(
+ st.agent_session.as_ref().unwrap().status,
+ AgentStatus::Waiting
+ );
+ assert!(matches!(
+ rx.try_recv(),
+ Ok(DaemonMsg::AgentStatus(Some(s))) if s.message.as_deref().unwrap().contains("permission")
+ ));
+
+ // The agent leaving the foreground clears the session (and says so).
+ apply_agent(&mut st, None);
+ assert!(st.agent_session.is_none());
+ assert!(matches!(rx.try_recv(), Ok(DaemonMsg::AgentStatus(None))));
+ assert!(matches!(rx.try_recv(), Ok(DaemonMsg::Agent(None))));
+ }
+
+ /// The opaque fallback: with an agent detected but no hooks, a plain OSC 9
+ /// notification marks the session waiting (non-rich); without an agent it
+ /// does nothing; and it never clobbers live rich state.
+ #[test]
+ fn opaque_notifications_only_fall_back_when_no_rich_state() {
+ use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent};
+
+ // No agent → the notification is ignored (it's just a toast).
+ let mut st = test_state(true);
+ let mut sniffer = OscSniffer::new();
+ apply_signals(&mut st, sniffer.feed(b"\x1b]9;Build finished\x07"));
+ assert!(st.agent_session.is_none());
+
+ // Agent detected, no hooks → waiting, non-rich, body kept.
+ st.agent = Some(CLIAgent::Codex);
+ apply_signals(
+ &mut st,
+ sniffer.feed(b"\x1b]9;Codex wants to run tests\x07"),
+ );
+ let sess = st.agent_session.clone().unwrap();
+ assert_eq!(sess.status, AgentStatus::Waiting);
+ assert!(!sess.rich);
+
+ // Rich state present → the opaque ping is ignored.
+ st.agent_session = Some(AgentSessionState {
+ status: AgentStatus::Working,
+ message: None,
+ session_id: Some("sid".into()),
+ rich: true,
+ });
+ apply_signals(&mut st, sniffer.feed(b"\x1b]9;noise\x07"));
+ assert_eq!(
+ st.agent_session.as_ref().unwrap().status,
+ AgentStatus::Working
+ );
+ }
+
/// Attaching replays Size → Snapshot (→ Cwd) in order and installs the
/// subscriber under a fresh epoch.
#[test]
@@ -2301,7 +2636,10 @@ mod tests {
Arc::new(OutputGate::new()),
Box::new(std::io::Cursor::new(b"tail".to_vec())),
|| false, // no PTY here → treat the shell as foreground
- || None,
+ ForegroundProbes {
+ remote: Box::new(|| None),
+ agent: Box::new(|| None),
+ },
Arc::new(DeathReporter::new(move || {
dead_flag.store(true, Ordering::SeqCst)
})),
@@ -2335,7 +2673,10 @@ mod tests {
Arc::new(OutputGate::new()),
Box::new(std::io::Cursor::new(Vec::new())),
|| false,
- || None,
+ ForegroundProbes {
+ remote: Box::new(|| None),
+ agent: Box::new(|| None),
+ },
Arc::new(DeathReporter::new(move || dead_tx.send(()).unwrap())),
);
handle.join().unwrap();
@@ -2358,7 +2699,10 @@ mod tests {
Arc::new(OutputGate::new()),
Box::new(std::io::Cursor::new(Vec::new())),
|| false,
- || None,
+ ForegroundProbes {
+ remote: Box::new(|| None),
+ agent: Box::new(|| None),
+ },
Arc::new(DeathReporter::new(move || {
dead_flag.store(true, Ordering::SeqCst)
})),
@@ -2414,6 +2758,25 @@ mod tests {
assert!(dead_rx.try_recv().is_err(), "on_dead must fire only once");
}
+ /// Every spawned shell carries the `TTY7` marker, so the `tty7 agent-hook`
+ /// emitter fires (it stays silent without it). This is the env side of the
+ /// rich-status channel — a regression here silently breaks all hook-based
+ /// agent status, which no other test would catch.
+ #[test]
+ fn spawned_shell_carries_the_tty7_marker() {
+ let cmd = build_shell_command(None, &Some(PathBuf::from("/tmp")))
+ .expect("build default shell command")
+ .0;
+ let tty7 = cmd
+ .get_env(crate::core::agent_hooks::TTY7_ENV_MARKER)
+ .and_then(|v| v.to_str());
+ assert_eq!(
+ tty7,
+ Some(env!("CARGO_PKG_VERSION")),
+ "the daemon must inject TTY7 into every spawned shell"
+ );
+ }
+
/// `apply_signals` writes sniffed cwd/shell state into the pane state.
#[test]
fn apply_signals_updates_state() {
@@ -2424,7 +2787,7 @@ mod tests {
&mut st,
SniffSignals {
cwd: Some(PathBuf::from("/tmp/x")),
- shell: None,
+ ..SniffSignals::default()
},
);
assert_eq!(st.cwd, Some(PathBuf::from("/tmp/x")));
@@ -2433,12 +2796,12 @@ mod tests {
apply_signals(
&mut st,
SniffSignals {
- cwd: None,
shell: Some(ShellState {
active: true,
at_prompt: true,
last_exit_code: Some(0),
}),
+ ..SniffSignals::default()
},
);
assert!(st.shell.active && st.shell.at_prompt);
diff --git a/src/daemon/protocol.rs b/src/daemon/protocol.rs
index 26e58683..d59b68a6 100644
--- a/src/daemon/protocol.rs
+++ b/src/daemon/protocol.rs
@@ -781,6 +781,17 @@ pub enum DaemonMsg {
PaneList(Vec),
/// The foreground remote context, or `None` when the pane is local / unknown.
RemoteContext(Option),
+ /// The third-party CLI coding agent currently running in the foreground
+ /// (Claude Code, Codex, Gemini, …), or `None` when no known agent is running.
+ /// Detected daemon-side from the foreground `argv` — see
+ /// [`crate::core::cli_agent`].
+ Agent(Option),
+ /// The rich per-session agent status (idle / working / waiting / done +
+ /// native session id), sniffed daemon-side from the pane's OSC stream
+ /// (tty7's sentinel events, with an opaque OSC 9/777 fallback) — see
+ /// [`crate::core::cli_agent::AgentSessionState`]. `None` clears it (the
+ /// agent exited).
+ AgentStatus(Option),
/// Reply to `EnsureLoopbackForward`.
LoopbackForward(LoopbackForward),
/// Reply to `ListLoopbackForwards` and `CloseLoopbackForward`.
@@ -887,6 +898,10 @@ mod kind {
// (15–19 reserved: WS3 auth extensions.)
/// `ForwardList` — reply to the WS4 managed-forward messages.
pub const FORWARD_LIST: u8 = 20;
+ /// `Agent` — the foreground CLI coding agent detected on a pane (or its clear).
+ pub const AGENT: u8 = 21;
+ /// `AgentStatus` — the pane's rich agent-session status (or its clear).
+ pub const AGENT_STATUS: u8 = 22;
}
/// Write one framed message: `[u32 LE len][u8 kind][payload]`.
@@ -1141,6 +1156,8 @@ impl DaemonMsg {
DaemonMsg::RemoteContext(remote) => {
write_frame(w, kind::REMOTE_CONTEXT, &to_json(remote)?)
}
+ DaemonMsg::Agent(agent) => write_frame(w, kind::AGENT, &to_json(agent)?),
+ DaemonMsg::AgentStatus(state) => write_frame(w, kind::AGENT_STATUS, &to_json(state)?),
DaemonMsg::LoopbackForward(forward) => {
write_frame(w, kind::LOOPBACK_FORWARD, &to_json(forward)?)
}
@@ -1194,6 +1211,8 @@ impl DaemonMsg {
},
kind::PANE_LIST => DaemonMsg::PaneList(from_json(&payload)?),
kind::REMOTE_CONTEXT => DaemonMsg::RemoteContext(from_json(&payload)?),
+ kind::AGENT => DaemonMsg::Agent(from_json(&payload)?),
+ kind::AGENT_STATUS => DaemonMsg::AgentStatus(from_json(&payload)?),
kind::LOOPBACK_FORWARD => DaemonMsg::LoopbackForward(from_json(&payload)?),
kind::LOOPBACK_FORWARD_LIST => DaemonMsg::LoopbackForwardList(from_json(&payload)?),
kind::AUTH_PROMPT => {
@@ -1467,6 +1486,16 @@ mod tests {
target: "dev".into(),
})),
DaemonMsg::RemoteContext(None),
+ DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Claude)),
+ DaemonMsg::Agent(Some(crate::core::cli_agent::CLIAgent::Codex)),
+ DaemonMsg::Agent(None),
+ DaemonMsg::AgentStatus(Some(crate::core::cli_agent::AgentSessionState {
+ status: crate::core::cli_agent::AgentStatus::Waiting,
+ message: Some("Claude needs your permission to use Bash".into()),
+ session_id: Some("abc-123".into()),
+ rich: true,
+ })),
+ DaemonMsg::AgentStatus(None),
DaemonMsg::LoopbackForward(LoopbackForward { local_port: 49152 }),
DaemonMsg::LoopbackForwardList(vec![LoopbackForwardInfo {
id: LoopbackForwardId {
diff --git a/src/main.rs b/src/main.rs
index 9e1a4ed5..32aec0c3 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -12,10 +12,10 @@ mod ui;
use crate::core::config::Config;
use crate::ui::app::Tty7App;
+use crate::ui::assets::Assets;
use crate::ui::keymap;
use gpui::*;
use gpui_component::{ActiveTheme as _, Root, TitleBar};
-use gpui_component_assets::Assets;
/// Register the bundled Hack monospace faces with gpui's text system so the
/// default `font_family` ("Hack") renders identically on every machine, with no
@@ -245,6 +245,21 @@ fn enrich_path_from_login_shell() {
}
fn main() {
+ // Agent-hook mode: `tty7 agent-hook ` is the tiny emitter
+ // Claude Code's hooks invoke (see `core::agent_hooks`). It reads the hook
+ // payload from stdin, writes one OSC sequence to the controlling terminal,
+ // and exits — never touching config, the daemon, or the GUI. Checked first
+ // so a hook can never accidentally boot a window.
+ {
+ let args: Vec = std::env::args().skip(1).take(3).collect();
+ if args.first().map(String::as_str) == Some("agent-hook") {
+ if let [_, agent, event] = args.as_slice() {
+ crate::core::agent_hooks::run_agent_hook(agent, event);
+ }
+ return;
+ }
+ }
+
// Resolve the config directory override (if any) up front, before any code
// path touches config/session/history files (the daemon socket path resolves
// under this dir too, so the order matters).
diff --git a/src/terminal/git_status.rs b/src/terminal/git_status.rs
new file mode 100644
index 00000000..234f9efe
--- /dev/null
+++ b/src/terminal/git_status.rs
@@ -0,0 +1,130 @@
+//! A lightweight git snapshot for a pane's working directory — the current
+//! branch and the working-tree diff size — rendered as the sidebar row's third
+//! line (`⎇ feat/x +6 −5`): each session fronted with its branch and change
+//! count.
+//!
+//! Deliberately shell-out simple: one `git` invocation per field, run on a
+//! background thread by the caller (see [`crate::terminal::view`]) so the UI
+//! never blocks on a slow repo. Read-only — `GIT_OPTIONAL_LOCKS=0` keeps status
+//! polling from ever taking `index.lock` and fighting a real git command the
+//! user is running. Returns `None` when the cwd isn't inside a git work tree,
+//! so the sidebar simply omits the line.
+
+use std::path::Path;
+use std::process::{Command, Stdio};
+
+/// A pane's git snapshot: the branch it's on and how much the working tree has
+/// changed against `HEAD`. `added`/`removed` sum the per-file line counts from
+/// `git diff --numstat HEAD` (tracked staged + unstaged changes); binary files
+/// and untracked files don't contribute a line count.
+#[derive(Clone, PartialEq, Eq, Debug)]
+pub struct GitStatus {
+ /// The branch name (`main`, `feat/x`), or a short commit sha when the HEAD
+ /// is detached. Never empty.
+ pub branch: String,
+ /// Lines added across the working tree vs `HEAD`.
+ pub added: u32,
+ /// Lines removed across the working tree vs `HEAD`.
+ pub removed: u32,
+}
+
+/// Compute the git snapshot for `cwd`, or `None` when it isn't a git work tree
+/// (or the path is gone). Blocking — call it on a background executor.
+pub fn compute(cwd: &Path) -> Option {
+ if !cwd.exists() {
+ return None;
+ }
+ let branch = branch_name(cwd)?;
+ let (added, removed) = diff_numstat(cwd).unwrap_or((0, 0));
+ Some(GitStatus {
+ branch,
+ added,
+ removed,
+ })
+}
+
+/// The current branch name, or a short sha for a detached HEAD. Doubles as the
+/// "is this a git repo" gate: both probes failing (not a work tree) yields
+/// `None`.
+fn branch_name(cwd: &Path) -> Option {
+ // On a branch — even before the first commit — `symbolic-ref` names it.
+ if let Some(out) = git(cwd, &["symbolic-ref", "--quiet", "--short", "HEAD"]) {
+ let name = out.trim();
+ if !name.is_empty() {
+ return Some(name.to_string());
+ }
+ }
+ // Detached HEAD (or a rebase/bisect): fall back to the short commit sha.
+ let sha = git(cwd, &["rev-parse", "--short", "HEAD"])?;
+ let sha = sha.trim();
+ (!sha.is_empty()).then(|| sha.to_string())
+}
+
+/// Sum added/removed lines across the working tree vs `HEAD` from
+/// `git diff --numstat HEAD`. Binary files (`-\t-`) contribute nothing.
+fn diff_numstat(cwd: &Path) -> Option<(u32, u32)> {
+ let out = git(cwd, &["diff", "--numstat", "HEAD"])?;
+ let mut added = 0u32;
+ let mut removed = 0u32;
+ for line in out.lines() {
+ let mut fields = line.split('\t');
+ if let Some(n) = fields.next().and_then(|s| s.parse::().ok()) {
+ added += n;
+ }
+ if let Some(n) = fields.next().and_then(|s| s.parse::().ok()) {
+ removed += n;
+ }
+ }
+ Some((added, removed))
+}
+
+/// Run `git -C ` and return stdout on success, `None` on a
+/// non-zero exit or a missing `git`. `GIT_OPTIONAL_LOCKS=0` makes the read
+/// truly read-only; stdin is nulled so a misconfigured git can't block on a
+/// prompt.
+fn git(cwd: &Path, args: &[&str]) -> Option {
+ let out = Command::new("git")
+ .arg("-C")
+ .arg(cwd)
+ .args(args)
+ .env("GIT_OPTIONAL_LOCKS", "0")
+ .stdin(Stdio::null())
+ .stderr(Stdio::null())
+ .output()
+ .ok()?;
+ if !out.status.success() {
+ return None;
+ }
+ String::from_utf8(out.stdout).ok()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ /// A tmp path that is not a git repo yields no status (and never panics).
+ #[test]
+ fn non_repo_is_none() {
+ let dir = std::env::temp_dir().join("tty7-git-status-not-a-repo-xyz");
+ let _ = std::fs::create_dir_all(&dir);
+ assert_eq!(compute(&dir), None);
+ }
+
+ /// A path that doesn't exist is `None`, not a panic.
+ #[test]
+ fn missing_path_is_none() {
+ assert_eq!(compute(Path::new("/no/such/tty7/path/here")), None);
+ }
+
+ /// This repo (the crate root is inside the tty7 work tree) reports a branch,
+ /// exercising the real `git` probe end-to-end.
+ #[test]
+ fn own_repo_has_a_branch() {
+ let here = env!("CARGO_MANIFEST_DIR");
+ if let Some(status) = compute(Path::new(here)) {
+ assert!(!status.branch.is_empty());
+ }
+ // If the crate is built outside a work tree (e.g. a vendored tarball),
+ // `None` is the correct answer and the assertion above is skipped.
+ }
+}
diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs
index 0e55febc..3b64a032 100644
--- a/src/terminal/mod.rs
+++ b/src/terminal/mod.rs
@@ -21,6 +21,7 @@ pub mod element;
pub mod fps;
mod fuzzy;
mod generator;
+pub(crate) mod git_status;
mod highlight;
mod history;
mod hold;
@@ -36,4 +37,5 @@ mod typeahead;
pub mod view;
pub use remote::RemoteTerminal;
+pub(crate) use remote::notify_desktop;
pub use size::TermSize;
diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs
index 5f71816b..c5d558a1 100644
--- a/src/terminal/remote.rs
+++ b/src/terminal/remote.rs
@@ -35,6 +35,7 @@ use alacritty_terminal::vte::ansi;
use std::collections::VecDeque;
+use crate::core::cli_agent::{AgentSessionState, CLIAgent};
use crate::core::osc::OscTokenizer;
use crate::daemon::protocol::{
AuthPromptKind, AuthResponse, ClientMsg, DaemonMsg, KnownHostEntry, KnownHostId,
@@ -105,6 +106,8 @@ struct ReaderSignals {
cwd: Arc>>,
shell: Arc>,
remote: Arc>>,
+ agent: Arc>>,
+ agent_session: Arc>>,
exited: Arc,
child_exited: Arc,
zle_reading: Arc,
@@ -182,6 +185,15 @@ pub struct RemoteTerminal {
/// arrives *after* an auto-supplied stored password means the server rejected
/// it, so the sheet warns and offers to overwrite/clear the stale entry.
auto_supplied_password: bool,
+ /// The third-party CLI coding agent running in the pane's foreground, last
+ /// reported by the daemon via `Agent` (detected from the foreground `argv`).
+ /// `None` when no known agent runs. Drives the tab avatar's brand mark — see
+ /// [`crate::core::cli_agent`].
+ agent: Arc>>,
+ /// The agent's rich session status (idle/working/waiting/done + native
+ /// session id), last reported by the daemon via `AgentStatus`. Drives the
+ /// status dot, "needs your input" notifications, and session resume.
+ agent_session: Arc>>,
reader_thread: Option>,
}
@@ -300,6 +312,8 @@ impl RemoteTerminal {
let cwd: Arc>> = Arc::new(Mutex::new(None));
let shell_state: Arc> = Arc::new(Mutex::new(ShellState::default()));
let remote_context: Arc>> = Arc::new(Mutex::new(None));
+ let agent: Arc>> = Arc::new(Mutex::new(None));
+ let agent_session: Arc>> = Arc::new(Mutex::new(None));
let exited_flag = Arc::new(AtomicBool::new(false));
let child_exited = Arc::new(AtomicBool::new(false));
let zle_reading = Arc::new(AtomicBool::new(false));
@@ -315,6 +329,8 @@ impl RemoteTerminal {
cwd: cwd.clone(),
shell: shell_state.clone(),
remote: remote_context.clone(),
+ agent: agent.clone(),
+ agent_session: agent_session.clone(),
exited: exited_flag.clone(),
child_exited: child_exited.clone(),
zle_reading: zle_reading.clone(),
@@ -341,6 +357,8 @@ impl RemoteTerminal {
ssh_phase,
ssh_endpoint: None,
auto_supplied_password: false,
+ agent,
+ agent_session,
reader_thread: Some(reader_thread),
})
}
@@ -370,6 +388,8 @@ impl RemoteTerminal {
cwd,
shell,
remote,
+ agent,
+ agent_session,
exited: exited_flag,
child_exited,
zle_reading,
@@ -625,6 +645,21 @@ impl RemoteTerminal {
}
proxy.send_event(AlacEvent::Wakeup);
}
+ DaemonMsg::Agent(a) => {
+ flush_batch!();
+ if let Ok(mut guard) = agent.lock() {
+ *guard = a;
+ }
+ }
+ DaemonMsg::AgentStatus(state) => {
+ flush_batch!();
+ if let Ok(mut guard) = agent_session.lock() {
+ *guard = state;
+ }
+ // Status changes repaint the tab chip / sidebar
+ // dot even when the pane printed nothing.
+ proxy.send_event(AlacEvent::Wakeup);
+ }
DaemonMsg::Exited { .. } => {
// Child gone: apply what it printed last, then
// mark the emulator exited and flip the shared
@@ -835,6 +870,20 @@ impl RemoteTerminal {
/// Whether zle is reading the keyboard right now (live `133;B` seen, no
/// later mark). See the field docs; this is the gate for writing the
/// typeahead wipe without it echoing into the scrollback.
+ /// The third-party CLI coding agent (Claude Code, Codex, …) running in the
+ /// pane's foreground, as last reported by the daemon, or `None`. Cheap cache
+ /// read — detection runs daemon-side. See [`crate::core::cli_agent`].
+ pub fn foreground_agent(&self) -> Option {
+ self.agent.lock().ok().and_then(|g| *g)
+ }
+
+ /// The rich agent-session status (idle/working/waiting/done + native
+ /// session id), as last reported by the daemon, or `None` when no agent
+ /// session is live. Cheap cache read — sniffing runs daemon-side.
+ pub fn agent_session(&self) -> Option {
+ self.agent_session.lock().ok().and_then(|g| g.clone())
+ }
+
pub fn zle_reading(&self) -> bool {
self.zle_reading.load(Ordering::Relaxed)
}
@@ -1312,7 +1361,7 @@ fn stale_mode_resets(mode: TermMode) -> Vec {
///
/// Note: `notify-rust`'s macOS backend uses the deprecated `NSUserNotification`,
/// which is acceptable for a completion toast.
-pub(super) fn notify_desktop(title: Option<&str>, body: &str) {
+pub(crate) fn notify_desktop(title: Option<&str>, body: &str) {
let summary = title.unwrap_or("tty7").to_string();
let body = body.to_string();
std::thread::spawn(move || {
@@ -1377,36 +1426,22 @@ impl OscNotifyScanner {
/// Parse a buffered OSC payload (the bytes after `ESC ]`, e.g. `9;Build done` or
/// `777;notify;Title;Body`) into a `(title, body)` notification, or `None` if it
-/// isn't a notification we surface.
+/// isn't a notification we surface. The parsing itself lives in
+/// [`crate::core::osc::parse_notification`] (shared with the daemon's agent
+/// sniffer); this wrapper additionally drops tty7's own agent-event sentinel —
+/// those payloads are machine-to-machine JSON for the daemon's state machine,
+/// and toasting them would show raw JSON to the user.
fn parse_osc_notification(payload: &[u8]) -> Option<(Option, String)> {
- // OSC 9 ; — iTerm2 / growl style; title-less, body is the text.
- if let Some(rest) = payload.strip_prefix(b"9;") {
- // ConEmu overloads OSC 9 with numeric subcommands (`9;4;…` progress,
- // `9;9;`, …); those aren't notifications, so skip a `;`/``
- // leading field. A real message rarely starts with a bare single digit.
- let first = rest.split(|&b| b == b';').next().unwrap_or(rest);
- if first.len() == 1 && first[0].is_ascii_digit() {
- return None;
- }
- let body = String::from_utf8_lossy(rest).into_owned();
- return (!body.is_empty()).then_some((None, body));
+ if crate::core::cli_agent::parse_agent_event(payload).is_some() {
+ return None;
}
- // OSC 777 ; notify ; ; — urxvt style.
- if let Some(rest) = payload.strip_prefix(b"777;notify;") {
- let mut parts = rest.splitn(2, |&b| b == b';');
- let first = String::from_utf8_lossy(parts.next().unwrap_or(b"")).into_owned();
- let second = parts
- .next()
- .map(|b| String::from_utf8_lossy(b).into_owned());
- // With both fields present it's title + body; with only one it's a body-only
- // notification (some senders omit the title).
- let (title, body) = match second {
- Some(body) if !body.is_empty() => (Some(first), body),
- _ => (None, first),
- };
- return (!body.is_empty()).then_some((title, body));
+ let (title, body) = crate::core::osc::parse_notification(payload)?;
+ // A sentinel-titled payload whose JSON failed to parse is still not a
+ // user-facing notification; never toast it.
+ if title.as_deref() == Some(crate::core::cli_agent::AGENT_EVENT_SENTINEL) {
+ return None;
}
- None
+ Some((title, body))
}
/// Open a fresh connection to the daemon's listening endpoint. The endpoint is
@@ -2123,6 +2158,81 @@ mod tests {
assert!(at, "at_prompt should become true after the Prompt report");
}
+ /// `foreground_agent` reflects the daemon's last `Agent` report — `None`
+ /// before any report, the detected agent after one, and back to `None` when
+ /// the agent exits (the daemon reports `Agent(None)`).
+ #[test]
+ fn foreground_agent_follows_daemon_agent_reports() {
+ let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
+ let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
+
+ assert_eq!(term.foreground_agent(), None, "none before any report");
+
+ let poll = |want: Option| {
+ for _ in 0..200 {
+ if term.foreground_agent() == want {
+ return true;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(5));
+ }
+ false
+ };
+
+ DaemonMsg::Agent(Some(CLIAgent::Claude))
+ .encode(&mut daemon_side)
+ .unwrap();
+ daemon_side.flush().unwrap();
+ assert!(poll(Some(CLIAgent::Claude)), "agent report should surface");
+
+ DaemonMsg::Agent(None).encode(&mut daemon_side).unwrap();
+ daemon_side.flush().unwrap();
+ assert!(poll(None), "agent exit should clear it");
+ }
+
+ /// `DaemonMsg::AgentStatus` frames must land in the client's session
+ /// cache (and a `None` clear it) — the reader half of the rich-status
+ /// channel the daemon's sniffer feeds.
+ #[test]
+ fn agent_session_follows_daemon_status_reports() {
+ use crate::core::cli_agent::{AgentSessionState, AgentStatus};
+
+ let (client_side, mut daemon_side) = UnixStream::pair().unwrap();
+ let term = RemoteTerminal::from_stream(client_side, TermSize::new(80, 24)).unwrap();
+ assert_eq!(term.agent_session(), None, "none before any report");
+
+ let poll = |want: &dyn Fn(Option) -> bool| {
+ for _ in 0..200 {
+ if want(term.agent_session()) {
+ return true;
+ }
+ std::thread::sleep(std::time::Duration::from_millis(5));
+ }
+ false
+ };
+
+ DaemonMsg::AgentStatus(Some(AgentSessionState {
+ status: AgentStatus::Waiting,
+ message: Some("Claude needs your permission".into()),
+ session_id: Some("sid-1".into()),
+ rich: true,
+ }))
+ .encode(&mut daemon_side)
+ .unwrap();
+ daemon_side.flush().unwrap();
+ assert!(
+ poll(&|s| s.is_some_and(|s| s.status == AgentStatus::Waiting
+ && s.session_id.as_deref() == Some("sid-1")
+ && s.rich)),
+ "status report should surface with message + session id"
+ );
+
+ DaemonMsg::AgentStatus(None)
+ .encode(&mut daemon_side)
+ .unwrap();
+ daemon_side.flush().unwrap();
+ assert!(poll(&|s| s.is_none()), "a None report clears the session");
+ }
+
/// The typeahead wipe (^U) may only be written once zle actually reads the
/// keyboard; the client learns that from a *live* `133;B` (prompt end) in
/// the output stream. `133;D` (command done, but precmd hooks still running
diff --git a/src/terminal/view.rs b/src/terminal/view.rs
index c0281d67..0f9e13ec 100644
--- a/src/terminal/view.rs
+++ b/src/terminal/view.rs
@@ -224,6 +224,42 @@ pub struct TerminalView {
/// long-running commands completed while the window is in the background.
running_since: Option,
running_title: String,
+ /// The coding agent (if any) detected during the current foreground-command
+ /// episode, captured so its completion notification can be branded ("Claude
+ /// Code finished" rather than a generic "command finished"). Set the moment
+ /// the daemon reports an agent while a command runs; cleared when it ends.
+ running_agent: Option,
+ /// The rich agent status last seen by the poll, so transitions (working →
+ /// waiting, working → done) fire exactly one notification each and repaint
+ /// the status dot.
+ last_agent_status: Option,
+ /// When the current rich turn entered `Working`, for the "finished after
+ /// Ns" copy on its `Done` notification.
+ agent_turn_started: Option,
+ /// Whether this pane's agent ever reported over the rich sentinel channel.
+ /// While true, the coarse process-exit "agent finished" notification is
+ /// suppressed — the turn-level `stop` events already said it better.
+ agent_was_rich: bool,
+ /// Whether the agent's last finished turn (the green `Done` dot) is *unread*
+ /// — a turn ended that the user hasn't looked at since. Set when a new turn
+ /// finishes while this pane is unfocused; cleared the moment the pane gains
+ /// focus (you're looking at it). The tab avatar only paints the Done dot
+ /// while this is true, so a result you've already seen stops nagging. Blue
+ /// (working) / amber (waiting) are unaffected — they track live state.
+ agent_result_unread: bool,
+ /// The pane's last-computed git snapshot (branch + working-tree diff size),
+ /// shown as the sidebar row's third line. Computed off-thread by
+ /// [`refresh_git_status`](Self::refresh_git_status) on a cwd change or a
+ /// command finishing; `None` outside a git work tree (or before the first
+ /// probe lands).
+ git_status: Option,
+ /// The cwd `git_status` was last computed (or scheduled) for, so the poll
+ /// loop only reprobes when the working directory actually changes.
+ git_status_cwd: Option,
+ /// Monotonic tag bumped on every git reprobe; a background result is dropped
+ /// unless it still matches, so a slow probe from a since-changed cwd can't
+ /// overwrite a fresher one (same guard as `completion_generation`).
+ git_status_gen: u64,
/// The inline command line editor. Live only while the shell sits idle
/// at its prompt (`input_active`): there the terminal keeps keyboard focus and
/// we run our own line editor (so we own Tab / ↑ / ↓ for completion and
@@ -454,6 +490,15 @@ fn notify_command_finished(label: &str, elapsed: std::time::Duration) {
super::remote::notify_desktop(Some("tty7"), &body);
}
+/// Post a branded "the agent finished" notification — the coding-agent form of
+/// [`notify_command_finished`], titled with the agent so it's obvious *which*
+/// session came back.
+fn notify_agent_finished(agent: crate::core::cli_agent::CLIAgent, elapsed: std::time::Duration) {
+ let secs = elapsed.as_secs();
+ let body = format!("Finished after {secs}s");
+ super::remote::notify_desktop(Some(agent.display_name()), &body);
+}
+
/// Ring the OS system bell for the `Audible` bell mode. Returns `true` if a
/// sound was actually requested, `false` on platforms without a portable beep
/// (the caller then falls back to the visual flash so the bell is never silent).
@@ -784,6 +829,9 @@ impl TerminalView {
cx.on_focus_in(&focus_handle, window, |view, _window, cx| {
view.focused = true;
view.cursor_visible = true;
+ // Looking at the pane marks its finished turn read, so the tab
+ // avatar's green Done dot clears.
+ view.agent_result_unread = false;
view.report_focus_change(true);
cx.notify();
}),
@@ -897,6 +945,14 @@ impl TerminalView {
last_at_prompt: false,
running_since: None,
running_title: String::new(),
+ running_agent: None,
+ last_agent_status: None,
+ agent_turn_started: None,
+ agent_was_rich: false,
+ agent_result_unread: false,
+ git_status: None,
+ git_status_cwd: None,
+ git_status_gen: 0,
cmd: CmdEditor::new(),
typeahead: Typeahead::new(),
hold: GapHold::new(),
@@ -952,6 +1008,66 @@ impl TerminalView {
self.terminal.remote_context()
}
+ /// The coding agent running in this pane's foreground, or `None` when none
+ /// is. Identity comes from the daemon's foreground-`argv` detection (plus
+ /// the sentinel event channel, which can brand wrappers argv can't see
+ /// through). The tab avatar brands the pane with it. See
+ /// [`crate::core::cli_agent`].
+ pub fn agent(&self) -> Option {
+ self.terminal.foreground_agent()
+ }
+
+ /// The agent's rich session status (idle / working / waiting / done +
+ /// native session id), when the pane's agent reports events over the
+ /// sentinel OSC channel (or the opaque notification fallback). Drives the
+ /// avatar's status dot, "needs your input" notifications, and resume. An
+ /// output-idle *guess* is deliberately still absent — agents are quietest
+ /// while thinking, so only agent-reported state is trusted.
+ pub fn agent_session(&self) -> Option {
+ self.terminal.agent_session()
+ }
+
+ /// Whether this pane's finished turn (the green `Done` dot) is unread — a
+ /// turn ended that the user hasn't looked at since (see
+ /// [`agent_result_unread`](Self::agent_result_unread) field). Drives the
+ /// avatar dot's unread halo; the dot itself shows for any `Done`.
+ pub fn agent_result_unread(&self) -> bool {
+ self.agent_result_unread
+ }
+
+ /// The pane's last-computed git snapshot (branch + working-tree diff), for
+ /// the sidebar row's branch line. `None` outside a git work tree or before
+ /// the first background probe lands.
+ pub fn git_status(&self) -> Option {
+ self.git_status.clone()
+ }
+
+ /// The current grid selection as text, if any non-blank one exists — the
+ /// source for "Agent: Send Selection".
+ pub fn selection_text(&self) -> Option {
+ self.terminal
+ .term
+ .lock()
+ .selection_to_string()
+ .filter(|t| !t.trim().is_empty())
+ }
+
+ /// Deliver a built prompt into this pane's PTY as a bracketed paste + CR —
+ /// the submit path for the agent context-feed commands. See
+ /// [`crate::core::agent_prompt::submit_bytes`].
+ pub fn send_agent_prompt(&self, prompt: &str) {
+ self.terminal
+ .write(crate::core::agent_prompt::submit_bytes(prompt));
+ }
+
+ /// Type one command line + Enter into the pane's PTY, as if the user had.
+ /// Used by session restore to hand a fresh shell an agent resume command;
+ /// the bytes queue in the PTY until the (possibly still-starting) shell
+ /// reads them.
+ pub fn run_command_line(&self, cmd: &str) {
+ self.terminal.write(format!("{cmd}\r").into_bytes());
+ }
+
/// The shell this pane was explicitly spawned with (new-tab dropdown pick),
/// so splits can inherit it. `None` → the default shell.
pub fn shell_spec(&self) -> Option {
@@ -2268,34 +2384,193 @@ impl TerminalView {
cx.notify();
}
+ // Whether the configured notification policy allows a post right now:
+ // never / only-when-unfocused / always. Shared by the command-finished,
+ // agent-finished, and agent-waiting notifications.
+ let notify_allowed = match cx.global::().notify_on_command_finish {
+ NotifyMode::Never => false,
+ NotifyMode::Unfocused => !window.is_window_active(),
+ NotifyMode::Always => true,
+ };
+
// "Command finished" notification: a foreground command (not at prompt)
- // that ran long and finished while the window was in the background.
+ // that ran long and finished while the window was in the background. When
+ // the command was a recognized coding agent, brand the notification with
+ // the agent instead of the generic "command finished" copy.
let running = !at_prompt;
+ // While a command runs, latch the agent the daemon reports for it — the
+ // detection poll can land a beat after the command starts, so capture it
+ // whenever it appears rather than only at the start edge.
+ if running && self.running_agent.is_none() {
+ self.running_agent = self.terminal.foreground_agent();
+ }
+ // A command finishing (back-to-prompt edge) may have edited files or
+ // switched branch, so reprobe git after it — captured before the match
+ // below clears `running_since`.
+ let cmd_finished = self.running_since.is_some() && !running;
match (self.running_since, running) {
(None, true) => {
self.running_since = Some(std::time::Instant::now());
self.running_title = self.title.clone();
+ self.running_agent = self.terminal.foreground_agent();
}
(Some(start), false) => {
let elapsed = start.elapsed();
let title = std::mem::take(&mut self.running_title);
+ let agent = self.running_agent.take();
self.running_since = None;
- // Gate on the configured policy: never / only-when-unfocused /
- // always. The configured long-command floor still applies
- // regardless.
- let cfg = cx.global::();
- let notify = match cfg.notify_on_command_finish {
- NotifyMode::Never => false,
- NotifyMode::Unfocused => !window.is_window_active(),
- NotifyMode::Always => true,
- };
- let threshold = std::time::Duration::from_secs(cfg.notify_threshold_secs);
- if elapsed >= threshold && notify {
- notify_command_finished(&title, elapsed);
+ if notify_allowed {
+ match agent {
+ // A rich-channel agent already announced each turn's
+ // end (`stop` events below); a second "finished" on
+ // process exit would be noise.
+ Some(_) if self.agent_was_rich => {}
+ // An agent session ends the moment it finishes — no
+ // duration floor: "Claude Code finished" is worth saying
+ // even for a quick turn you stepped away from.
+ Some(agent) => notify_agent_finished(agent, elapsed),
+ None => {
+ let threshold = std::time::Duration::from_secs(
+ cx.global::().notify_threshold_secs,
+ );
+ if elapsed >= threshold {
+ notify_command_finished(&title, elapsed);
+ }
+ }
+ }
}
}
_ => {}
}
+
+ let turn_finished = self.poll_agent_status(notify_allowed, cx);
+
+ // Refresh the sidebar's git branch/diff line when the working directory
+ // changed (a `cd`), a command just finished, or an agent turn ended —
+ // an agent's session is one long foreground command, so its edits would
+ // otherwise stay invisible until it exits. All rare edges, so the
+ // off-thread `git` shell-out runs seldom, not every 300ms tick.
+ let cwd_now = self.cwd();
+ if cwd_now.as_ref() != self.git_status_cwd.as_ref() || cmd_finished || turn_finished {
+ self.refresh_git_status(cwd_now, cx);
+ }
+ }
+
+ /// Kick off an off-thread git probe for `cwd` and fold the result back on
+ /// the main thread, tagged with a generation so a stale probe (cwd changed
+ /// meanwhile) is dropped. Clears the status when there's no cwd (e.g. a
+ /// native-SSH pane pre-OSC-7, where a local `git` would be meaningless).
+ fn refresh_git_status(&mut self, cwd: Option, cx: &mut Context) {
+ self.git_status_cwd = cwd.clone();
+ let Some(cwd) = cwd else {
+ if self.git_status.take().is_some() {
+ cx.notify();
+ }
+ return;
+ };
+ self.git_status_gen += 1;
+ let generation = self.git_status_gen;
+ cx.spawn(async move |this, cx| {
+ let result = cx
+ .background_executor()
+ .spawn(async move { crate::terminal::git_status::compute(&cwd) })
+ .await;
+ let _ = this.update(cx, |view, cx| {
+ // Drop a probe whose cwd has since been superseded.
+ if view.git_status_gen != generation {
+ return;
+ }
+ if view.git_status != result {
+ view.git_status = result;
+ cx.notify();
+ }
+ });
+ })
+ .detach();
+ }
+
+ /// Fold the pane's rich agent status into turn-level notifications and the
+ /// status dot. Runs on the same cadence as the notification poll above.
+ ///
+ /// Only *transitions* act: entering `Waiting` says the agent needs you
+ /// (the reason attached), and a `Working → Done` edge says the turn
+ /// finished — with its duration when we saw it start. Non-rich (fallback)
+ /// state paints the dot but stays silent: the agent's own OSC notification
+ /// was already toasted by the reader thread, and echoing it would double
+ /// up. Attach replays land as a bare status with no observed transition
+ /// history, so a restored `Done` never re-notifies.
+ ///
+ /// Returns whether a turn just ended (a transition *into* `Done`) — the
+ /// caller uses it to reprobe git: an agent's whole session is one long
+ /// foreground command, so the back-to-prompt edge that normally refreshes
+ /// the branch/diff line never fires while it works.
+ fn poll_agent_status(&mut self, notify_allowed: bool, cx: &mut Context) -> bool {
+ use crate::core::cli_agent::AgentStatus;
+
+ let session = self.terminal.agent_session();
+ if session.as_ref().is_some_and(|s| s.rich) {
+ self.agent_was_rich = true;
+ }
+ if self.terminal.foreground_agent().is_none() && session.is_none() {
+ self.agent_was_rich = false;
+ }
+
+ let status = session.as_ref().map(|s| s.status);
+ if status == self.last_agent_status {
+ return false;
+ }
+ let prev = std::mem::replace(&mut self.last_agent_status, status);
+ let turn_finished = status == Some(AgentStatus::Done) && prev != Some(AgentStatus::Done);
+
+ // Read/unread for the green Done dot: a turn just finished is "unread"
+ // only if you weren't looking (focused pane = you watched it finish, so
+ // it's already read). Any non-Done status has no result to be unread.
+ match status {
+ Some(AgentStatus::Done) if prev != Some(AgentStatus::Done) => {
+ self.agent_result_unread = !self.focused;
+ }
+ Some(AgentStatus::Done) => {}
+ _ => self.agent_result_unread = false,
+ }
+
+ let rich = session.as_ref().is_some_and(|s| s.rich);
+ let agent_name = self
+ .terminal
+ .foreground_agent()
+ .map(|a| a.display_name())
+ .unwrap_or("Agent");
+ match status {
+ Some(AgentStatus::Working) => {
+ self.agent_turn_started = Some(std::time::Instant::now());
+ }
+ Some(AgentStatus::Waiting) if rich && notify_allowed => {
+ let body = session
+ .as_ref()
+ .and_then(|s| s.message.clone())
+ .unwrap_or_else(|| "Waiting for your input".to_string());
+ super::remote::notify_desktop(Some(agent_name), &body);
+ }
+ // Done only counts off an *observed* turn (working/waiting seen
+ // live), so an attach replay of old state stays quiet.
+ Some(AgentStatus::Done)
+ if rich
+ && notify_allowed
+ && matches!(
+ prev,
+ Some(AgentStatus::Working) | Some(AgentStatus::Waiting)
+ ) =>
+ {
+ let body = match self.agent_turn_started.take() {
+ Some(start) => format!("Finished after {}s", start.elapsed().as_secs()),
+ None => "Turn finished".to_string(),
+ };
+ super::remote::notify_desktop(Some(agent_name), &body);
+ }
+ _ => {}
+ }
+ // Status changed: repaint so the avatar dot / sidebar line track it.
+ cx.notify();
+ turn_finished
}
/// True when the shell sits idle at its prompt: the PTY's foreground process
diff --git a/src/ui/app.rs b/src/ui/app.rs
index 44570ee7..f330a060 100644
--- a/src/ui/app.rs
+++ b/src/ui/app.rs
@@ -130,6 +130,75 @@ impl Tab {
};
leaf.map(|l| l.read(cx).title.clone()).unwrap_or_default()
}
+
+ /// The git snapshot (branch + working-tree diff) of the tab's label-driving
+ /// terminal — the focused leaf with a `window`, else the first — for the
+ /// sidebar row's branch line (the branch and change count shown under the
+ /// title). `None` when that leaf isn't inside a git work tree, or before
+ /// its first probe lands.
+ pub(crate) fn git_status(
+ &self,
+ window: Option<&Window>,
+ cx: &App,
+ ) -> Option {
+ let leaf = match window {
+ Some(window) => self.pane.focused_or_first(window, cx),
+ None => self.pane.first_leaf(),
+ }?;
+ leaf.read(cx).git_status()
+ }
+
+ /// The coding agent running in this tab, or `None`. Any leaf counts (a
+ /// split with a shell on the left and Claude on the right is an agent
+ /// tab); the first agent leaf in tree order wins. Drives the tab avatar's
+ /// brand mark.
+ pub(crate) fn agent(&self, cx: &App) -> Option {
+ self.pane
+ .leaves()
+ .into_iter()
+ .find_map(|l| l.read(cx).agent())
+ }
+
+ /// The tab's most urgent agent status across its leaves — waiting beats
+ /// working beats done beats idle — or `None` when no leaf runs an agent.
+ /// The green `Done` state always shows (a finished turn stays visible until
+ /// the next one); [`agent_result_unread`](Self::agent_result_unread) then
+ /// says whether to emphasize it as unread. Drives the avatar dot and the
+ /// sidebar counts.
+ pub(crate) fn agent_status(&self, cx: &App) -> Option {
+ use crate::core::cli_agent::AgentStatus;
+ let urgency = |s: AgentStatus| match s {
+ AgentStatus::Waiting => 3,
+ AgentStatus::Working => 2,
+ AgentStatus::Done => 1,
+ AgentStatus::Idle => 0,
+ };
+ self.pane
+ .leaves()
+ .into_iter()
+ .filter(|l| l.read(cx).agent().is_some())
+ .map(|l| {
+ l.read(cx)
+ .agent_session()
+ .map(|s| s.status)
+ .unwrap_or(AgentStatus::Idle)
+ })
+ .max_by_key(|s| urgency(*s))
+ }
+
+ /// Whether the tab's shown status is an *unread* finished turn — a `Done`
+ /// the user hasn't looked at since. Drives the avatar dot's unread halo:
+ /// the green dot stays either way, the halo just says "new, come look."
+ /// Only meaningful when the shown status is `Done`.
+ pub(crate) fn agent_result_unread(&self, cx: &App) -> bool {
+ use crate::core::cli_agent::AgentStatus;
+ self.agent_status(cx) == Some(AgentStatus::Done)
+ && self.pane.leaves().into_iter().any(|l| {
+ let v = l.read(cx);
+ v.agent_session().map(|s| s.status) == Some(AgentStatus::Done)
+ && v.agent_result_unread()
+ })
+ }
}
/// In-progress inline rename of a tab (double-click a tab label). Holds the
@@ -2021,6 +2090,25 @@ impl Tty7App {
}
SaveQuickConnect(target) => self.open_ssh_profile_new_from_target(target, window, cx),
OpenSshProfiles => self.open_settings_section(SettingsSection::Ssh, window, cx),
+ SendSelectionToAgent => self.send_selection_to_agent(window, cx),
+ SendGitDiffToAgent => self.send_git_diff_to_agent(window, cx),
+ InstallClaudeHooks => {
+ // Synchronous file edit; report the outcome as a toast either
+ // way. Installing over an existing install just refreshes the
+ // entries (e.g. after the tty7 binary moved) — say so.
+ let refreshed = crate::core::agent_hooks::claude_hooks_installed();
+ match crate::core::agent_hooks::install_claude_hooks() {
+ Ok(summary) if refreshed => crate::terminal::notify_desktop(
+ Some("tty7"),
+ &format!("Refreshed: {summary}"),
+ ),
+ Ok(summary) => crate::terminal::notify_desktop(Some("tty7"), &summary),
+ Err(e) => crate::terminal::notify_desktop(
+ Some("tty7"),
+ &format!("Claude Code hook install failed: {e}"),
+ ),
+ }
+ }
// Handled inside `PaletteView` (opens a sub-list); these never emit a
// `Confirm` for this variant, so they never reach here.
OpenThemePicker | OpenSshConnectInput => {}
@@ -2028,6 +2116,108 @@ impl Tty7App {
}
}
+ // ----- Agent context feed (palette: "Agent: …") -------------------------
+
+ /// The pane the agent-feed commands deliver to: the first leaf running a
+ /// recognized coding agent, preferring the active tab, then any tab. `None`
+ /// when no agent runs anywhere.
+ fn agent_target_leaf(&self, cx: &App) -> Option> {
+ let runs_agent = |leaf: &Entity| leaf.read(cx).agent().is_some();
+ if let Some(tab) = self.tabs.get(self.active)
+ && let Some(leaf) = tab.pane.leaves().into_iter().find(runs_agent)
+ {
+ return Some(leaf);
+ }
+ self.tabs
+ .iter()
+ .enumerate()
+ .filter(|(i, _)| *i != self.active)
+ .flat_map(|(_, t)| t.pane.leaves())
+ .find(runs_agent)
+ }
+
+ /// Deliver `prompt` into the agent pane's PTY and bring that pane's tab to
+ /// the front so the user sees the turn start. Toasts when no agent runs.
+ fn deliver_agent_prompt(&mut self, prompt: &str, window: &mut Window, cx: &mut Context) {
+ let Some(target) = self.agent_target_leaf(cx) else {
+ crate::terminal::notify_desktop(
+ Some("tty7"),
+ "No running coding agent found — start one (claude, codex, …) in a pane first.",
+ );
+ return;
+ };
+ target.read(cx).send_agent_prompt(prompt);
+ if let Some(i) = self
+ .tabs
+ .iter()
+ .position(|t| t.pane.leaves().contains(&target))
+ {
+ self.activate(i, window, cx);
+ }
+ }
+
+ /// "Agent: Send Selection" — the focused pane's selection, phrased as a
+ /// prompt, into the running agent's pane (the context-feed idea).
+ fn send_selection_to_agent(&mut self, window: &mut Window, cx: &mut Context) {
+ let source = self
+ .tabs
+ .get(self.active)
+ .and_then(|t| t.pane.focused_or_first(window, cx));
+ let (selection, cwd) = match &source {
+ Some(view) => (view.read(cx).selection_text(), view.read(cx).cwd()),
+ None => (None, None),
+ };
+ let Some(selection) = selection else {
+ crate::terminal::notify_desktop(
+ Some("tty7"),
+ "Nothing selected — select some terminal output first.",
+ );
+ return;
+ };
+ let cwd = cwd.map(|c| c.to_string_lossy().into_owned());
+ if let Some(prompt) =
+ crate::core::agent_prompt::build_selection_prompt(&selection, cwd.as_deref())
+ {
+ self.deliver_agent_prompt(&prompt, window, cx);
+ }
+ }
+
+ /// "Agent: Send Git Diff for Review" — the focused pane's repo diff
+ /// (unstaged + staged), phrased as a review prompt, into the agent's pane.
+ fn send_git_diff_to_agent(&mut self, window: &mut Window, cx: &mut Context) {
+ let cwd = self
+ .tabs
+ .get(self.active)
+ .and_then(|t| t.pane.focused_or_first(window, cx))
+ .and_then(|view| view.read(cx).cwd());
+ let Some(cwd) = cwd else {
+ crate::terminal::notify_desktop(Some("tty7"), "This pane has no known directory.");
+ return;
+ };
+ // Unstaged + staged, concatenated — "everything not yet committed",
+ // which is what a review pass wants. Both invocations are quick; the
+ // prompt builder caps runaway diffs.
+ let run = |args: &[&str]| {
+ std::process::Command::new("git")
+ .args(args)
+ .current_dir(&cwd)
+ .output()
+ .ok()
+ .filter(|o| o.status.success())
+ .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
+ .unwrap_or_default()
+ };
+ let diff = format!("{}{}", run(&["diff"]), run(&["diff", "--cached"]));
+ let cwd_s = cwd.to_string_lossy().into_owned();
+ match crate::core::agent_prompt::build_diff_review_prompt(&diff, Some(&cwd_s)) {
+ Some(prompt) => self.deliver_agent_prompt(&prompt, window, cx),
+ None => crate::terminal::notify_desktop(
+ Some("tty7"),
+ &format!("No uncommitted changes in {cwd_s} (or not a git repository)."),
+ ),
+ }
+ }
+
// ----- Settings tab (Cmd+,) -------------------------------------------
/// Toggle the settings overlay (Cmd+,). If it's already open, close it;
@@ -3309,6 +3499,11 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
// reconnected on restore (FR-E4/C2); `None` for local panes. A
// live pane reattaches by `pane_id` and never needs this.
ssh_spec: view.ssh_spec(),
+ // The running agent + its native session id (when its hooks
+ // reported one), so a pane the daemon loses can resume the
+ // agent conversation instead of just reopening a shell.
+ agent: view.agent(),
+ agent_session_id: view.agent_session().and_then(|s| s.session_id),
}
}
Pane::Split {
@@ -3328,6 +3523,8 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane {
cwd: None,
pane_id: None,
ssh_spec: None,
+ agent: None,
+ agent_session_id: None,
},
}
}
@@ -3390,6 +3587,8 @@ fn session_to_pane(
cwd,
pane_id,
ssh_spec,
+ agent,
+ agent_session_id,
} => {
// Only restore the pane id when the daemon confirms it's still live;
// a stale id (daemon restarted, pane killed) falls back to a spawn.
@@ -3412,6 +3611,19 @@ fn session_to_pane(
// A shell pick isn't persisted in the session, so a stale pane that
// must respawn comes back on the default shell.
let view = new_terminal(font_size, cwd.clone(), restore, None, window, cx);
+ // A pane that could NOT re-attach lost its running agent with the
+ // daemon; when we captured that agent's native session id, hand
+ // the fresh shell its resume command so the conversation picks up
+ // where it left off (cmux's auto-resume, config-gated). The bytes
+ // sit in the PTY input queue until the shell reads its first
+ // command — same mechanism as tmux send-keys at spawn.
+ if restore.is_none()
+ && cx.global::().restore_agent_sessions
+ && let (Some(agent), Some(id)) = (agent, agent_session_id)
+ && let Some(cmd) = agent.resume_command(id)
+ {
+ view.read(cx).run_command_line(&cmd);
+ }
Pane::leaf(view)
}
SessionPane::Split { axis, ratio, a, b } => {
diff --git a/src/ui/assets.rs b/src/ui/assets.rs
new file mode 100644
index 00000000..ef3f0f1c
--- /dev/null
+++ b/src/ui/assets.rs
@@ -0,0 +1,59 @@
+//! The app's [`gpui::AssetSource`]: tty7's own bundled icons layered over
+//! gpui-component's icon set.
+//!
+//! gpui-component ships the generic UI glyphs (close, chevrons, `bot`, …) via
+//! [`gpui_component_assets::Assets`]. tty7 adds a small set of third-party
+//! coding-agent brand marks (`icons/agents/*.svg`) for the tab avatars — see
+//! [`crate::core::cli_agent::CLIAgent::icon_path`]. Rather than fork the
+//! upstream asset crate to carry app-specific brand art, this source resolves
+//! tty7's icons first and delegates everything else downstream, so both sets
+//! load through the single `AssetSource` gpui allows.
+
+use std::borrow::Cow;
+
+use gpui::{AssetSource, Result, SharedString};
+
+/// tty7's asset source. Registered once in `main` via `with_assets`.
+pub struct Assets;
+
+impl AssetSource for Assets {
+ fn load(&self, path: &str) -> Result