Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
### Added
- CLI help now points coding agents to Herdr's plain-text guide, documentation index, and built-in control skill.
- Added Qwen Code detection for idle, working, and user-confirmation states, plus optional native session restore.
- Local clients now refresh a configurable allowlist of desktop and authentication environment variables when they attach, so processes launched afterward can use the current Wayland, X11, and SSH session environment. (#2448)
- Herdr now keeps the outer terminal window title in sync with the session through `ui.window_title`, so window managers and terminal tab bars show the active workspace and the host the panes actually run on.
- The desktop tab bar now has configurable right-aligned status entries for zoom state, hostname, date/time, literal text, and asynchronously refreshed command output.
- Optional `keys.move_tab_previous` and `keys.move_tab_next` bindings now reorder the active tab in place, wrapping at either end.
Expand Down
13 changes: 13 additions & 0 deletions docs/next/website/src/content/docs/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,19 @@ resume_agents_on_restore = true

Only panes with a valid native session reference from an official integration can resume; other panes restore as normal shells. See [Session state and restore](/docs/session-state/) for supported Agents and persistence behavior.

## Environment refresh on attach

When a local client attaches, Herdr refreshes a tmux-style allowlist of desktop and authentication variables for processes launched afterward. Running processes keep their existing environment. This lets a server started outside a graphical session launch new panes with current values such as `WAYLAND_DISPLAY`, `DISPLAY`, and `SSH_AUTH_SOCK`.

Customize the allowlist under `[session]`:

```toml
[session]
update_environment = ["WAYLAND_DISPLAY", "DISPLAY", "SSH_AUTH_SOCK", "HYPRLAND_INSTANCE_SIGNATURE"]
```

Missing variables are removed from future process environments. Remote clients do not update the remote server from the local environment. `TERM`, `COLORTERM`, `CODEX_THREAD_ID`, and `HERDR_*` variables are always managed by Herdr and cannot be added to this list.

## IME cursor tracking

On macOS, AI Agent TUIs that hide the hardware cursor can prevent native input-method candidate windows from following the focused pane. Reveal a cursor anchor for those panes with:
Expand Down
6 changes: 6 additions & 0 deletions docs/next/website/src/data/config-reference.json
Original file line number Diff line number Diff line change
Expand Up @@ -1162,6 +1162,12 @@
"type": "boolean",
"default": "true",
"description": "Resume supported AI-agent panes into their native conversation sessions when restoring a Herdr session."
},
{
"key": "session.update_environment",
"type": "array of strings",
"default": "[\"DISPLAY\", \"KRB5CCNAME\", \"MSYSTEM\", \"SSH_ASKPASS\", \"SSH_AUTH_SOCK\", \"SSH_AGENT_PID\", \"SSH_CONNECTION\", \"WAYLAND_DISPLAY\", \"WINDOWID\", \"XAUTHORITY\", \"XDG_CURRENT_DESKTOP\", \"XDG_SESSION_DESKTOP\", \"XDG_SESSION_TYPE\"]",
"description": "Refresh selected environment variables from each local client for processes launched after it attaches. Running processes are unchanged."
}
]
},
Expand Down
1 change: 1 addition & 0 deletions src/app/creation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ impl App {
self.render_notify.clone(),
self.render_dirty.clone(),
extra_env,
&self.session_environment,
)?;
self.terminal_runtimes.insert(terminal.id.clone(), runtime);
self.state.terminals.insert(terminal.id.clone(), terminal);
Expand Down
8 changes: 3 additions & 5 deletions src/app/ids.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,9 @@ impl App {
let tab_id = self.public_tab_id(ws_idx, tab_idx)?;
let pane_id = self.public_pane_id(ws_idx, pane_id)?;
Some(
crate::pane::PaneLaunchEnv::from_extra(extra_env).with_identity(
workspace_id,
tab_id,
pane_id,
),
crate::pane::PaneLaunchEnv::from_extra(extra_env)
.with_session(&self.session_environment)
.with_identity(workspace_id, tab_id, pane_id),
)
}

Expand Down
83 changes: 82 additions & 1 deletion src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ mod theme_sync;
mod window_title;
mod worktrees;

use std::collections::{HashMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::future::pending;
use std::io::{self, Write};
use std::sync::Arc;
Expand Down Expand Up @@ -161,6 +161,8 @@ pub struct App {
/// even when an App-internal drain consumes the event before the forwarding drain.
pub(crate) local_input_source_switch: bool,
pub(crate) config_reloaded_from_disk: bool,
pub(crate) session_environment: crate::pane::SessionEnvironment,
session_update_environment: Vec<String>,
prefix_input_source: Box<dyn crate::platform::PrefixInputSource>,
}

Expand Down Expand Up @@ -233,6 +235,16 @@ fn background_update_check_enabled(no_session: bool, check_enabled: bool) -> boo
auto_updates_enabled(no_session) && check_enabled
}

fn filtered_session_update_environment(config: &crate::config::Config) -> Vec<String> {
config
.session
.update_environment
.iter()
.filter(|name| crate::config::session_environment_name_allowed(name))
.cloned()
.collect()
}

fn load_plugin_registry(no_session: bool) -> crate::app::state::InstalledPluginRegistry {
if no_session {
return std::collections::HashMap::new();
Expand Down Expand Up @@ -797,6 +809,8 @@ impl App {
local_terminal_notifications: true,
local_input_source_switch: true,
config_reloaded_from_disk: false,
session_environment: Vec::new(),
session_update_environment: filtered_session_update_environment(config),
prefix_input_source: Box::new(crate::platform::RealPrefixInputSource::default()),
};
app.configure_tab_bar_status(&config.ui.tab_bar_right, &config.ui.tab_bar_right_separator);
Expand Down Expand Up @@ -1378,6 +1392,30 @@ impl App {
self.apply_config_from_disk(true)
}

pub(crate) fn update_session_environment(&mut self, update: Vec<(String, Option<String>)>) {
let allowed = |name: &str| {
self.session_update_environment
.iter()
.any(|candidate| candidate == name)
&& crate::config::session_environment_name_allowed(name)
};
self.session_environment = update
.into_iter()
.filter(|(name, value)| {
allowed(name) && value.as_ref().is_none_or(|v| !v.contains('\0'))
})
.collect::<BTreeMap<_, _>>()
.into_iter()
.collect();
self.sync_session_environment_to_workspaces();
}

fn sync_session_environment_to_workspaces(&mut self) {
for workspace in &mut self.state.workspaces {
workspace.set_session_environment(&self.session_environment);
}
}

pub(crate) fn take_config_reloaded_from_disk(&mut self) -> bool {
let reloaded = self.config_reloaded_from_disk;
self.config_reloaded_from_disk = false;
Expand Down Expand Up @@ -1441,6 +1479,14 @@ impl App {
}
}

if !invalid_section("session") {
self.session_update_environment = filtered_session_update_environment(config);
let allowed = &self.session_update_environment;
self.session_environment
.retain(|(name, _)| allowed.iter().any(|candidate| candidate == name));
self.sync_session_environment_to_workspaces();
}

if !invalid_section("ui") {
// Validate sidebar bounds before they reach any `u16::clamp` call.
// On `min > max`, treat the entire `[ui]` section as invalid: keep
Expand Down Expand Up @@ -2090,6 +2136,41 @@ mod tests {
)
}

#[test]
fn session_environment_update_filters_and_replaces_the_overlay() {
let mut app = test_app();
app.state.workspaces = vec![Workspace::test_new("test")];
app.session_update_environment = vec![
"DISPLAY".into(),
"SSH_AUTH_SOCK".into(),
"TERM".into(),
"HERDR_SOCKET_PATH".into(),
];

app.update_session_environment(vec![
("DISPLAY".into(), Some("client-display".into())),
("DISPLAY".into(), Some("latest-display".into())),
("SSH_AUTH_SOCK".into(), None),
("TERM".into(), Some("unsafe-term".into())),
("HERDR_SOCKET_PATH".into(), Some("unsafe-socket".into())),
("UNLISTED".into(), Some("ignored".into())),
]);

let expected = vec![
("DISPLAY".into(), Some("latest-display".into())),
("SSH_AUTH_SOCK".into(), None),
];
assert_eq!(app.session_environment, expected);
assert_eq!(app.state.workspaces[0].session_environment, expected);

app.update_session_environment(vec![("DISPLAY".into(), None)]);
assert_eq!(app.session_environment, vec![("DISPLAY".into(), None)]);
assert_eq!(
app.state.workspaces[0].session_environment,
app.session_environment
);
}

fn unique_temp_path(name: &str) -> std::path::PathBuf {
let stamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
Expand Down
4 changes: 3 additions & 1 deletion src/app/popup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ impl App {
let cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
let pane_id = PaneId::alloc();
let terminal_id = TerminalId::alloc();
let launch_env = PaneLaunchEnv::from_extra(extra_env).without_pane_identity();
let launch_env = PaneLaunchEnv::from_extra(extra_env)
.with_session(&self.session_environment)
.without_pane_identity();
let terminal_area = if self.state.view.terminal_area.width >= 4
&& self.state.view.terminal_area.height >= 4
{
Expand Down
24 changes: 23 additions & 1 deletion src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,26 @@ fn is_remote_client_process() -> bool {
std::env::var(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR).is_ok()
}

fn requested_environment_update() -> Option<Vec<(String, Option<String>)>> {
if is_remote_client_process() {
return None;
}
let config = crate::config::Config::load().config;
let mut update = config
.session
.update_environment
.into_iter()
.filter(|name| crate::config::session_environment_name_allowed(name))
.map(|name| {
let value = std::env::var_os(&name).and_then(|value| value.into_string().ok());
(name, value)
})
.collect::<Vec<_>>();
update.sort_by(|left, right| left.0.cmp(&right.0));
update.dedup_by(|left, right| left.0 == right.0);
Some(update)
}

/// Time to wait for the server's Welcome reply during the handshake.
///
/// A local client talks to an already-connected server, so 5s is plenty. The
Expand Down Expand Up @@ -850,6 +870,7 @@ fn do_handshake(
cell_width_px,
cell_height_px,
),
environment_update: requested_environment_update(),
};
protocol::write_message(stream, &hello)
.map_err(|e| ClientError::ConnectionFailed(io::Error::other(e.to_string())))?;
Expand Down Expand Up @@ -2742,11 +2763,12 @@ mod tests {
}

#[test]
fn remote_client_uses_extended_handshake_timeout() {
fn remote_client_uses_extended_handshake_timeout_without_forwarding_environment() {
let _guard = env_lock().lock().unwrap();
let _remote = EnvVarGuard::set(crate::remote::REMOTE_KEYBINDINGS_ENV_VAR, "local");

assert_eq!(handshake_read_timeout(), REMOTE_HANDSHAKE_READ_TIMEOUT);
assert!(requested_environment_update().is_none());
}

#[test]
Expand Down
1 change: 1 addition & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ pub use self::{
};

pub(crate) use self::keybinds::parse_key_combo;
pub(crate) use self::model::session_environment_name_allowed;
pub(crate) use self::{
io::upsert_top_level_bool,
tab_bar::{
Expand Down
56 changes: 55 additions & 1 deletion src/config/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -271,16 +271,51 @@ pub struct SessionConfig {
/// Resume supported AI-agent panes into their native conversation sessions
/// when restoring a Herdr session. Default: true.
pub resume_agents_on_restore: bool,
/// Client environment variables refreshed for processes launched after attachment.
pub update_environment: Vec<String>,
}

impl Default for SessionConfig {
fn default() -> Self {
Self {
resume_agents_on_restore: true,
update_environment: default_update_environment(),
}
}
}

pub(crate) fn session_environment_name_allowed(name: &str) -> bool {
!name.is_empty()
&& !name.contains(['=', '\0'])
&& !name.eq_ignore_ascii_case("TERM")
&& !name.eq_ignore_ascii_case("COLORTERM")
&& !name.eq_ignore_ascii_case("CODEX_THREAD_ID")
&& !name
.get(..6)
.is_some_and(|prefix| prefix.eq_ignore_ascii_case("HERDR_"))
}

fn default_update_environment() -> Vec<String> {
[
"DISPLAY",
"KRB5CCNAME",
"MSYSTEM",
"SSH_ASKPASS",
"SSH_AUTH_SOCK",
"SSH_AGENT_PID",
"SSH_CONNECTION",
"WAYLAND_DISPLAY",
"WINDOWID",
"XAUTHORITY",
"XDG_CURRENT_DESKTOP",
"XDG_SESSION_DESKTOP",
"XDG_SESSION_TYPE",
]
.into_iter()
.map(str::to_owned)
.collect()
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ConfigReloadStatus {
Expand Down Expand Up @@ -1316,16 +1351,35 @@ new_cwd = "~/Projects"
}

#[test]
fn resume_agents_on_restore_defaults_on_and_parses() {
fn session_config_defaults_and_parses() {
let default_config = Config::default();
assert!(default_config.session.resume_agents_on_restore);
assert!(default_config
.session
.update_environment
.contains(&"WAYLAND_DISPLAY".to_string()));

let toml = r#"
[session]
resume_agents_on_restore = false
update_environment = ["DISPLAY", "NIRI_SOCKET"]
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(!config.session.resume_agents_on_restore);
assert_eq!(
config.session.update_environment,
vec!["DISPLAY", "NIRI_SOCKET"]
);
}

#[test]
fn session_environment_rejects_terminal_and_managed_names() {
assert!(session_environment_name_allowed("WAYLAND_DISPLAY"));
assert!(!session_environment_name_allowed("TERM"));
assert!(!session_environment_name_allowed("colorterm"));
assert!(!session_environment_name_allowed("HERDR_SOCKET_PATH"));
assert!(!session_environment_name_allowed("herdr_custom"));
assert!(!session_environment_name_allowed("CODEX_THREAD_ID"));
}

#[test]
Expand Down
3 changes: 3 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# Resume supported AI-agent panes into their native conversation sessions after
# a Herdr server restart. Requires official integrations that report session refs.
# resume_agents_on_restore = true
# Refresh selected environment variables from each local client when it attaches.
# Updated values apply only to processes launched afterward, not running panes.
# update_environment = ["DISPLAY", "KRB5CCNAME", "MSYSTEM", "SSH_ASKPASS", "SSH_AUTH_SOCK", "SSH_AGENT_PID", "SSH_CONNECTION", "WAYLAND_DISPLAY", "WINDOWID", "XAUTHORITY", "XDG_CURRENT_DESKTOP", "XDG_SESSION_DESKTOP", "XDG_SESSION_TYPE"]

[remote]
# Whether herdr manages the ssh config used for `herdr --remote`.
Expand Down
Loading
Loading