aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-29 20:38:55 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-29 20:38:55 +0300
commit9e302984ccb13a65e3e504fa7692b223f546ff84 (patch)
tree52808a27bd82c05f55117ad129137fe3ef46a8cb
parent77242943ba65a80cbc1180258dd64f06635233f9 (diff)
feat: expose foreground cwd in pane info
refs #345
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--docs/next/website/src/content/docs/cli-reference.mdx2
-rw-r--r--docs/next/website/src/content/docs/socket-api.mdx2
-rw-r--r--src/api/schema.rs6
-rw-r--r--src/app/agents.rs1
-rw-r--r--src/app/creation.rs3
-rw-r--r--src/pane.rs49
-rw-r--r--src/platform/linux.rs6
-rw-r--r--src/platform/macos.rs6
-rw-r--r--src/terminal/runtime.rs4
-rw-r--r--src/workspace/tab.rs11
-rw-r--r--tests/api_ping.rs154
12 files changed, 243 insertions, 2 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index f677bdb8..179f955b 100644
--- a/docs/next/CHANGELOG.md
+++ b/docs/next/CHANGELOG.md
@@ -4,6 +4,7 @@
### Added
- Added pane copy mode at `prefix+[` with keyboard navigation, visual selection, and clipboard yank support. (#231)
+- Added `foreground_cwd` to pane and agent API/CLI responses so integrations can inspect the active foreground process directory without changing the existing pane/workspace `cwd` semantics. (#345)
### Fixed
- WSL clipboard writes now prefer OSC 52 before WSLg clipboard tools, so mouse selection and double-click copy populate Windows clipboard history in Windows Terminal. (#333)
diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx
index d79e34d0..ad837142 100644
--- a/docs/next/website/src/content/docs/cli-reference.mdx
+++ b/docs/next/website/src/content/docs/cli-reference.mdx
@@ -136,6 +136,8 @@ herdr pane report-agent <pane_id> \
`pane get`, `pane list`, `agent get`, and `agent list` include a read-only `agent_session` object when an official integration has reported a native session reference. If no native session reference is stored, the field is omitted.
+Those commands include `foreground_cwd` when Herdr can resolve the cwd of the foreground process controlling the pane. The existing `cwd` field remains the pane/workspace cwd used for labels and follow-cwd behavior.
+
Report display-only pane metadata without taking over semantic state:
```bash
diff --git a/docs/next/website/src/content/docs/socket-api.mdx b/docs/next/website/src/content/docs/socket-api.mdx
index 382a3c62..8525c33b 100644
--- a/docs/next/website/src/content/docs/socket-api.mdx
+++ b/docs/next/website/src/content/docs/socket-api.mdx
@@ -178,6 +178,8 @@ Official integrations can also report a native session reference. `pane.get`, `p
If no native session reference is stored, the field is omitted.
+`pane.get`, `pane.list`, `agent.get`, and `agent.list` also expose `foreground_cwd` when Herdr can resolve the cwd of the process currently controlling the pane PTY. The existing `cwd` field remains the pane/workspace cwd used for labels, follow-cwd behavior, and restored session state.
+
Use `pane.report_metadata` when a user hook wants to customize presentation without taking over lifecycle state from a Herdr integration.
```json
diff --git a/src/api/schema.rs b/src/api/schema.rs
index cd82f99f..5d933af9 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -811,6 +811,8 @@ pub struct AgentInfo {
pub focused: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub foreground_cwd: Option<String>,
pub revision: u64,
}
@@ -824,6 +826,8 @@ pub struct PaneInfo {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
+ pub foreground_cwd: Option<String>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent: Option<String>,
@@ -1407,6 +1411,7 @@ mod tests {
tab_id: "w_1:1".into(),
focused: true,
cwd: Some("/worktrees/herdr/worktree-api".into()),
+ foreground_cwd: None,
label: None,
agent: None,
title: None,
@@ -1457,6 +1462,7 @@ mod tests {
tab_id: "w_1:2".into(),
focused: false,
cwd: Some("/tmp/review".into()),
+ foreground_cwd: None,
label: None,
agent: None,
title: None,
diff --git a/src/app/agents.rs b/src/app/agents.rs
index 55ee15df..6d463b28 100644
--- a/src/app/agents.rs
+++ b/src/app/agents.rs
@@ -423,6 +423,7 @@ impl App {
pane_id: pane.pane_id,
focused: pane.focused,
cwd: pane.cwd,
+ foreground_cwd: pane.foreground_cwd,
revision: pane.revision,
})
}
diff --git a/src/app/creation.rs b/src/app/creation.rs
index d483eb1a..0f63d60d 100644
--- a/src/app/creation.rs
+++ b/src/app/creation.rs
@@ -288,6 +288,9 @@ impl App {
cwd: ws.tabs[tab_idx]
.cwd_for_pane(pane_id, &self.state.terminals, &self.terminal_runtimes)
.map(|cwd| cwd.display().to_string()),
+ foreground_cwd: ws.tabs[tab_idx]
+ .foreground_cwd_for_pane(pane_id, &self.terminal_runtimes)
+ .map(|cwd| cwd.display().to_string()),
label: terminal.manual_label.clone(),
agent: terminal.effective_agent_label().map(str::to_string),
title: presentation.title,
diff --git a/src/pane.rs b/src/pane.rs
index 41bb439c..885fb11d 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -124,6 +124,29 @@ fn should_clear_agent_for_foreground_shell(
previous_agent.is_some() && new_agent.is_none() && foreground_is_pane_shell
}
+fn usable_process_cwd(pid: u32) -> Option<std::path::PathBuf> {
+ crate::platform::process_cwd(pid).filter(|cwd| cwd.is_absolute() && cwd.is_dir())
+}
+
+fn foreground_member_cwd_different_from_shell(
+ shell_pid: u32,
+ shell_cwd: Option<&std::path::PathBuf>,
+) -> Option<std::path::PathBuf> {
+ let job = crate::detect::foreground_job(shell_pid)?;
+ for process in job.processes {
+ if process.pid == shell_pid {
+ continue;
+ }
+ let Some(cwd) = usable_process_cwd(process.pid) else {
+ continue;
+ };
+ if shell_cwd != Some(&cwd) {
+ return Some(cwd);
+ }
+ }
+ None
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ForegroundShellAgentAction {
ObserveProbe,
@@ -2111,6 +2134,32 @@ impl PaneRuntime {
let pid = self.child_pid.load(Ordering::Relaxed);
crate::platform::process_cwd(pid)
}
+
+ /// Get the current working directory of the process group controlling the pane PTY.
+ pub fn foreground_cwd(&self) -> Option<std::path::PathBuf> {
+ #[cfg(unix)]
+ {
+ let pid = self.child_pid.load(Ordering::Acquire);
+ let shell_cwd = usable_process_cwd(pid);
+ let foreground_pgid = self
+ .master_fd()
+ .and_then(crate::platform::foreground_process_group_id_for_tty_fd)
+ .or_else(|| crate::platform::foreground_process_group_id(pid));
+ let leader_cwd = foreground_pgid.and_then(usable_process_cwd);
+
+ if leader_cwd.as_ref() == shell_cwd.as_ref() {
+ foreground_member_cwd_different_from_shell(pid, shell_cwd.as_ref()).or(leader_cwd)
+ } else {
+ leader_cwd
+ .or_else(|| foreground_member_cwd_different_from_shell(pid, shell_cwd.as_ref()))
+ }
+ }
+
+ #[cfg(not(unix))]
+ {
+ None
+ }
+ }
}
#[cfg(test)]
diff --git a/src/platform/linux.rs b/src/platform/linux.rs
index 046e72c7..d1ae07f1 100644
--- a/src/platform/linux.rs
+++ b/src/platform/linux.rs
@@ -1,5 +1,6 @@
use std::{
io::Write,
+ os::fd::RawFd,
path::PathBuf,
process::{Command, Stdio},
};
@@ -86,6 +87,11 @@ pub fn foreground_process_group_id(child_pid: u32) -> Option<u32> {
(tpgid > 0).then_some(tpgid as u32)
}
+pub fn foreground_process_group_id_for_tty_fd(fd: RawFd) -> Option<u32> {
+ let pgid = unsafe { libc::tcgetpgrp(fd) };
+ (pgid > 0).then_some(pgid as u32)
+}
+
fn process_pgrp_and_comm(pid: u32) -> Option<(i32, String)> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let close = stat.rfind(')')?;
diff --git a/src/platform/macos.rs b/src/platform/macos.rs
index ac0e160b..e38b29eb 100644
--- a/src/platform/macos.rs
+++ b/src/platform/macos.rs
@@ -1,5 +1,6 @@
use std::ffi::OsStr;
use std::io::Write;
+use std::os::fd::RawFd;
use std::os::unix::ffi::OsStrExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
@@ -177,6 +178,11 @@ pub fn foreground_process_group_id(pid: u32) -> Option<u32> {
}
}
+pub fn foreground_process_group_id_for_tty_fd(fd: RawFd) -> Option<u32> {
+ let pgid = unsafe { libc::tcgetpgrp(fd) };
+ (pgid > 0).then_some(pgid as u32)
+}
+
/// Get the effective process name from `argv[0]` via `sysctl(KERN_PROCARGS2)`.
///
/// This is the macOS equivalent of reading `/proc/{pid}/cmdline` on Linux.
diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs
index 636972a1..b1a25ee9 100644
--- a/src/terminal/runtime.rs
+++ b/src/terminal/runtime.rs
@@ -373,6 +373,10 @@ impl TerminalRuntime {
self.0.cwd()
}
+ pub fn foreground_cwd(&self) -> Option<std::path::PathBuf> {
+ self.0.foreground_cwd()
+ }
+
pub(crate) fn current_size(&self) -> (u16, u16) {
self.0.current_size()
}
diff --git a/src/workspace/tab.rs b/src/workspace/tab.rs
index d5ebe00f..100e67d0 100644
--- a/src/workspace/tab.rs
+++ b/src/workspace/tab.rs
@@ -405,4 +405,15 @@ impl Tab {
.map(|terminal| terminal.cwd.clone())
})
}
+
+ pub fn foreground_cwd_for_pane(
+ &self,
+ pane_id: PaneId,
+ terminal_runtimes: &TerminalRuntimeRegistry,
+ ) -> Option<PathBuf> {
+ let terminal_id = self.terminal_id(pane_id)?;
+ terminal_runtimes
+ .get(terminal_id)
+ .and_then(|rt| rt.foreground_cwd())
+ }
}
diff --git a/tests/api_ping.rs b/tests/api_ping.rs
index 064bac13..25e4c98e 100644
--- a/tests/api_ping.rs
+++ b/tests/api_ping.rs
@@ -72,8 +72,19 @@ fn wait_for_socket(path: &Path, timeout: Duration) {
panic!("socket did not appear at {}", path.display());
}
+fn wait_for_path(path: &Path, timeout: Duration) {
+ let deadline = Instant::now() + timeout;
+ while Instant::now() < deadline {
+ if path.exists() {
+ return;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("path did not appear at {}", path.display());
+}
+
fn spawn_herdr(config_home: &Path, runtime_dir: &Path, socket_path: &Path) -> SpawnedHerdr {
- spawn_herdr_with_path(config_home, runtime_dir, socket_path, None)
+ spawn_herdr_with_options(config_home, runtime_dir, socket_path, None, "/bin/sh")
}
fn spawn_herdr_with_path(
@@ -82,6 +93,31 @@ fn spawn_herdr_with_path(
socket_path: &Path,
path_override: Option<&Path>,
) -> SpawnedHerdr {
+ spawn_herdr_with_options(
+ config_home,
+ runtime_dir,
+ socket_path,
+ path_override,
+ "/bin/sh",
+ )
+}
+
+fn spawn_herdr_with_shell(
+ config_home: &Path,
+ runtime_dir: &Path,
+ socket_path: &Path,
+ shell: &str,
+) -> SpawnedHerdr {
+ spawn_herdr_with_options(config_home, runtime_dir, socket_path, None, shell)
+}
+
+fn spawn_herdr_with_options(
+ config_home: &Path,
+ runtime_dir: &Path,
+ socket_path: &Path,
+ path_override: Option<&Path>,
+ shell: &str,
+) -> SpawnedHerdr {
fs::create_dir_all(config_home.join("herdr")).unwrap();
fs::create_dir_all(runtime_dir).unwrap();
register_runtime_dir(runtime_dir);
@@ -106,7 +142,7 @@ fn spawn_herdr_with_path(
cmd.env("XDG_RUNTIME_DIR", runtime_dir);
cmd.env("HERDR_SOCKET_PATH", socket_path);
cmd.env_remove("HERDR_CLIENT_SOCKET_PATH");
- cmd.env("SHELL", "/bin/sh");
+ cmd.env("SHELL", shell);
cmd.env_remove("HERDR_ENV");
if let Some(path) = path_override {
cmd.env("PATH", path);
@@ -557,6 +593,120 @@ fn tab_methods_round_trip_over_socket() {
cleanup_spawned_herdr(child, base);
}
+#[cfg(target_os = "linux")]
+#[test]
+fn pane_info_reports_foreground_cwd_without_changing_pane_cwd() {
+ let _lock = test_lock();
+ let base = unique_test_dir();
+ let foreground = base.join("foreground-process");
+ let marker = base.join("foreground-ready");
+ let pid_file = base.join("foreground.pid");
+ fs::create_dir_all(&foreground).unwrap();
+ let config_home = base.join("config");
+ let runtime_dir = base.join("runtime");
+ let socket_path = runtime_dir.join("herdr.sock");
+
+ let child = spawn_herdr_with_shell(&config_home, &runtime_dir, &socket_path, "/bin/bash");
+ wait_for_socket(&socket_path, Duration::from_secs(5));
+
+ let created = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"fg_ws","method":"workspace.create","params":{{"cwd":"{}","focus":true}}}}"#,
+ base.display()
+ ),
+ );
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+
+ let command = format!(
+ "/bin/sh -c 'cd {} && printf %s $$ > {} && touch {} && sleep 30'",
+ foreground.display(),
+ pid_file.display(),
+ marker.display()
+ );
+ let send_text = send_request(
+ &socket_path,
+ &serde_json::json!({
+ "id": "fg_send",
+ "method": "pane.send_text",
+ "params": {
+ "pane_id": pane_id,
+ "text": command,
+ },
+ })
+ .to_string(),
+ );
+ assert_eq!(send_text["result"]["type"], "ok");
+ let send_enter = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"fg_enter","method":"pane.send_keys","params":{{"pane_id":"{}","keys":["Enter"]}}}}"#,
+ pane_id
+ ),
+ );
+ assert_eq!(send_enter["result"]["type"], "ok");
+ wait_for_path(&marker, Duration::from_secs(5));
+
+ let foreground_pid: u32 = fs::read_to_string(&pid_file).unwrap().parse().unwrap();
+ assert_eq!(
+ fs::read_link(format!("/proc/{foreground_pid}/cwd")).unwrap(),
+ foreground
+ );
+
+ let pane = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"fg_pane","method":"pane.get","params":{{"pane_id":"{}"}}}}"#,
+ pane_id
+ ),
+ );
+ assert_eq!(pane["result"]["pane"]["cwd"], base.display().to_string());
+ assert_eq!(
+ pane["result"]["pane"]["foreground_cwd"],
+ foreground.display().to_string()
+ );
+
+ let panes = send_request(
+ &socket_path,
+ r#"{"id":"fg_panes","method":"pane.list","params":{}}"#,
+ );
+ assert_eq!(
+ panes["result"]["panes"][0]["cwd"],
+ base.display().to_string()
+ );
+ assert_eq!(
+ panes["result"]["panes"][0]["foreground_cwd"],
+ foreground.display().to_string()
+ );
+
+ let reported = send_request(
+ &socket_path,
+ &format!(
+ r#"{{"id":"fg_report","method":"pane.report_agent","params":{{"pane_id":"{}","source":"test","agent":"probe","state":"working"}}}}"#,
+ pane_id
+ ),
+ );
+ assert_eq!(reported["result"]["type"], "ok");
+
+ let agents = send_request(
+ &socket_path,
+ r#"{"id":"fg_agents","method":"agent.list","params":{}}"#,
+ );
+ assert_eq!(
+ agents["result"]["agents"][0]["cwd"],
+ base.display().to_string()
+ );
+ assert_eq!(
+ agents["result"]["agents"][0]["foreground_cwd"],
+ foreground.display().to_string()
+ );
+
+ cleanup_spawned_herdr(child, base);
+}
+
#[cfg(not(target_os = "macos"))]
#[test]
fn agent_start_creates_named_terminal_over_socket() {