Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
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
47 changes: 42 additions & 5 deletions scripts/agent_detection_manifest_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,15 @@
DEFAULT_WEBSITE_DIR = PROJECT_ROOT / "website" / "agent-detection"
ENGINE_SOURCE = PROJECT_ROOT / "src" / "detect" / "manifest_update.rs"

MANIFEST_KEYS = {"id", "version", "min_engine_version", "updated_at", "aliases", "rules"}
MANIFEST_KEYS = {
"id",
"version",
"min_engine_version",
"updated_at",
"aliases",
"terminal_title_activity_glyphs",
"rules",
}
RULE_KEYS = {
"id",
"state",
Expand Down Expand Up @@ -53,10 +61,15 @@
MAX_TOTAL_MATCHERS = 1024
MAX_MATCHER_CHARS = 512

# Keep engine-2 clients on the OSC-capable manifest until an engine-3 release
# can consume top_non_empty_lines. Remove this entry when the website publishes
# the bundled Grok manifest.
# Keep published manifests compatible with older engines while a bundled
# manifest stages a newer engine feature. Remove each entry after the website
# can publish that bundled version.
STAGED_WEBSITE_MANIFESTS = {
"claude": (
"2026.08.12.2",
"2026.08.12.1",
"03efbec218b6dbde0b8b35ddbb2d495825651935da33cc78ad0c98a44f7aced3",
),
"grok": (
"2026.07.16.2",
"2026.07.16.1",
Expand Down Expand Up @@ -143,6 +156,31 @@ def validate_manifest(path: Path, engine_version: int) -> dict:
if not isinstance(aliases, list) or not all(isinstance(item, str) for item in aliases):
raise CheckError(f"{path}: aliases must be an array of strings")

title_activity_glyphs = manifest.get("terminal_title_activity_glyphs")
if title_activity_glyphs is not None:
if min_engine < 4:
raise CheckError(
f"{path}: terminal_title_activity_glyphs requires min_engine_version 4"
)
if not isinstance(title_activity_glyphs, str):
raise CheckError(f"{path}: terminal_title_activity_glyphs must be a string")
if not title_activity_glyphs:
raise CheckError(f"{path}: terminal_title_activity_glyphs must not be empty")
if len(title_activity_glyphs) > MAX_MATCHER_CHARS:
raise CheckError(
f"{path}: terminal_title_activity_glyphs exceeds max length {MAX_MATCHER_CHARS}"
)
if any(
char.isalnum()
or char.isspace()
or ord(char) < 32
or 0x7F <= ord(char) <= 0x9F
for char in title_activity_glyphs
):
raise CheckError(
f"{path}: terminal_title_activity_glyphs must contain only non-text glyphs"
)

rules = manifest.get("rules")
if not isinstance(rules, list) or not rules:
raise CheckError(f"{path}: rules must be a non-empty array")
Expand Down Expand Up @@ -327,7 +365,6 @@ def validate_catalog(
stages_new_engine_manifest = (
staged_manifest
== (bundled_manifest["version"], manifest["version"], website_digest)
and bundled_manifest["min_engine_version"] == engine_version
and manifest["min_engine_version"] < bundled_manifest["min_engine_version"]
)
if cmp < 0 and not stages_new_engine_manifest:
Expand Down
18 changes: 18 additions & 0 deletions scripts/test_agent_detection_manifest_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,24 @@ def test_rejects_manifest_requiring_newer_engine(self):
with self.assertRaisesRegex(check.CheckError, "exceeds engine"):
check.load_manifest_dir(bundled, engine_version=1)

def test_validates_title_activity_glyphs(self):
with tempfile.TemporaryDirectory() as tmp:
bundled = Path(tmp) / "bundled"
bundled.mkdir()
base = manifest("codex", "2026.06.10.1").replace(
"min_engine_version = 1",
'min_engine_version = 4\nterminal_title_activity_glyphs = "◐◓◑◒"',
)
manifest_path = bundled / "codex.toml"
manifest_path.write_text(base)
check.load_manifest_dir(bundled, engine_version=4)

for invalid in ('', 'A◆', '◆ ◇', r'\u007f'):
with self.subTest(invalid=invalid):
manifest_path.write_text(base.replace('"◐◓◑◒"', f'"{invalid}"'))
with self.assertRaises(check.CheckError):
check.load_manifest_dir(bundled, engine_version=4)

def test_rejects_top_non_empty_lines_below_engine_three(self):
with tempfile.TemporaryDirectory() as tmp:
bundled = Path(tmp) / "bundled"
Expand Down
14 changes: 12 additions & 2 deletions src/app/actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ pub struct PaneStateUpdate {
pub ws_idx: usize,
pub previous_agent_label: Option<String>,
pub previous_known_agent: Option<Agent>,
pub terminal_title_stripped_changed: bool,
pub previous_state: AgentState,
pub previous_seen: bool,
pub previous_presentation: crate::terminal::EffectivePresentation,
Expand Down Expand Up @@ -1046,6 +1047,7 @@ impl AppState {
ws_idx,
previous_agent_label: change.previous_agent_label.clone(),
previous_known_agent: change.previous_known_agent,
terminal_title_stripped_changed: false,
previous_state: change.previous_state,
previous_seen,
previous_presentation: change.previous_presentation.clone(),
Expand Down Expand Up @@ -2973,23 +2975,30 @@ impl AppState {
mutation,
managed_changed,
agent_name_changed,
terminal_title_stripped_changed,
unchanged_change,
managed_launch_pending,
suppress_acquisition_completion,
) = {
let terminal = self.terminals.get_mut(&terminal_id)?;
let previous_agent_name = terminal.agent_name.clone();
let previous_known_agent = terminal.effective_known_agent();
let managed_launch_pending = terminal.managed_agent_launch_pending();
let mutation = update(terminal)?;
let managed_changed = terminal.reconcile_managed_agent_at(now, false);
let suppress_acquisition_completion = terminal.finish_agent_process_acquisition();
let agent_name_changed = terminal.agent_name != previous_agent_name;
let unchanged_change = (mutation.agent_released || agent_name_changed)
.then(|| terminal.unchanged_effective_state_change_at(now));
let known_agent = terminal.effective_known_agent();
let terminal_title_stripped_changed = previous_known_agent != known_agent
&& super::terminal_titles::reconcile_terminal_title_policy(terminal);
let unchanged_change =
(mutation.agent_released || agent_name_changed || terminal_title_stripped_changed)
.then(|| terminal.unchanged_effective_state_change_at(now));
(
mutation,
managed_changed,
agent_name_changed,
terminal_title_stripped_changed,
unchanged_change,
managed_launch_pending,
suppress_acquisition_completion,
Expand All @@ -3014,6 +3023,7 @@ impl AppState {
ws_idx,
previous_agent_label: change.previous_agent_label.clone(),
previous_known_agent: change.previous_known_agent,
terminal_title_stripped_changed,
previous_state: change.previous_state,
previous_seen,
previous_presentation: change.previous_presentation.clone(),
Expand Down
9 changes: 7 additions & 2 deletions src/app/agent_resume.rs
Original file line number Diff line number Diff line change
Expand Up @@ -256,8 +256,13 @@ impl App {
err = %err,
"failed to start shell for deferred agent resume"
);
if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) {
terminal.clear_agent_runtime_identity_after_respawn();
let title_changed = self.state.terminals.get_mut(&terminal_id).is_some_and(
crate::terminal::TerminalState::clear_agent_runtime_identity_after_respawn,
);
if title_changed {
if let Some((ws_idx, _)) = self.find_pane(pane_id) {
self.emit_pane_updated(ws_idx, pane_id);
}
}
return false;
}
Expand Down
130 changes: 125 additions & 5 deletions src/app/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,11 +305,20 @@ impl App {
} else {
None
};
let manifests_changed = manifest_update_agents
.as_ref()
.is_some_and(|agents| !agents.is_empty());
if manifests_changed {
crate::detect::manifest::reload_manifests();
}
let terminal_cwd_reported = matches!(ev, AppEvent::TerminalCwdReported { .. });
let previous_toast = self.state.toast.clone();
let pane_updates = self.state.handle_app_event(ev);
if let Some(agents) = manifest_update_agents {
self.reset_agent_detection_for_agents(&agents);
if let Some(agents) = manifest_update_agents.as_ref() {
self.reset_agent_detection_for_agents(agents);
}
if manifests_changed {
self.reconcile_terminal_titles_after_manifest_reload();
}
if let Some((pane_id, agent)) = released_agent {
if pane_updates.iter().any(|update| update.pane_id == pane_id) {
Expand Down Expand Up @@ -601,8 +610,11 @@ impl App {
};

self.terminal_runtimes.insert(terminal_id.clone(), runtime);
if let Some(terminal) = self.state.terminals.get_mut(&terminal_id) {
terminal.clear_agent_runtime_identity_after_respawn();
let title_changed = self.state.terminals.get_mut(&terminal_id).is_some_and(
crate::terminal::TerminalState::clear_agent_runtime_identity_after_respawn,
);
if title_changed {
self.emit_pane_updated(ws_idx, pane_id);
}
self.state.focus_pane_in_workspace(ws_idx, pane_id);
self.schedule_session_save();
Expand All @@ -615,7 +627,7 @@ impl App {
};
let workspace_id = self.public_workspace_id(update.ws_idx);

if update.agent_name_changed {
if update.agent_name_changed || update.terminal_title_stripped_changed {
self.emit_pane_updated(update.ws_idx, update.pane_id);
}

Expand Down Expand Up @@ -987,6 +999,7 @@ impl App {
self.state.agent_manifest_summaries = summaries.clone();
let update_status = crate::detect::manifest_update::load_status();
self.reset_all_agent_detection_runtimes();
self.reconcile_terminal_titles_after_manifest_reload();
SuccessResponse {
id: request.id,
result: ResponseResult::AgentManifestReload {
Expand Down Expand Up @@ -1464,6 +1477,113 @@ mod tests {
.expect("matching agent detection runtime should be reset");
}

#[tokio::test]
async fn manifest_update_event_reconciles_all_active_title_policies() {
const CHILD_ENV: &str = "HERDR_TEST_TITLE_MANIFEST_UPDATE_CHILD";
if std::env::var_os(CHILD_ENV).is_none() {
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"app::api::tests::manifest_update_event_reconciles_all_active_title_policies",
"--nocapture",
])
.env(CHILD_ENV, "1")
.output()
.unwrap();
assert!(
output.status.success(),
"isolated manifest update test failed:\n{}",
String::from_utf8_lossy(&output.stderr)
);
return;
}
let _guard = crate::config::test_config_env_lock().lock().unwrap();
let old_config = std::env::var_os("XDG_CONFIG_HOME");
let old_state = std::env::var_os("XDG_STATE_HOME");
let base =
std::env::temp_dir().join(format!("herdr-title-auto-manifest-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::env::set_var("XDG_CONFIG_HOME", base.join("config"));
std::env::set_var("XDG_STATE_HOME", base.join("state"));
crate::detect::manifest::reload_manifests();

let event_hub = crate::api::EventHub::default();
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
let mut app = App::new(
&crate::config::Config::default(),
true,
None,
api_rx,
event_hub.clone(),
);
app.state.workspaces = vec![crate::workspace::Workspace::test_new("auto-title")];
app.state.ensure_test_terminals();
let pane_id = app.state.workspaces[0].tabs[0].root_pane;
let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id]
.attached_terminal_id
.clone();
let terminal = app.state.terminals.get_mut(&terminal_id).unwrap();
terminal.detected_agent = Some(Agent::Claude);
terminal.set_terminal_title(Some("◆ task".into()));
let revision = terminal.revision;

let remote_path = crate::detect::manifest_update::remote_manifest_path(Agent::Claude);
std::fs::create_dir_all(remote_path.parent().unwrap()).unwrap();
std::fs::write(
remote_path,
r#"
id = "claude"
version = "9999.01.01.1"
min_engine_version = 4
updated_at = "9999-01-01T00:00:00Z"
terminal_title_activity_glyphs = "◆"

[[rules]]
id = "idle"
state = "idle"
contains = ["remote-ready"]
"#,
)
.unwrap();

app.handle_internal_event(AppEvent::AgentDetectionManifestsUpdated {
updated: vec![crate::detect::manifest_update::ManifestUpdateCommit {
agent: Agent::Codex,
version: crate::detect::manifest_update::ManifestVersion::parse("9999.01.01.1")
.unwrap(),
}],
status: crate::detect::manifest_update::ManifestUpdateStatus::default(),
});

assert!(matches!(
crate::detect::manifest::explain(Agent::Claude, "remote-ready").source,
Some(crate::detect::manifest::ManifestSource::Remote { .. })
));
let terminal = app.state.terminals.get(&terminal_id).unwrap();
assert_eq!(terminal.terminal_title.as_deref(), Some("◆ task"));
assert_eq!(terminal.terminal_title_stripped().as_deref(), Some("task"));
assert_eq!(terminal.revision, revision + 1);
assert_eq!(
event_hub
.events_after(0)
.iter()
.filter(|(_, event)| event.event == crate::api::schema::EventKind::PaneUpdated)
.count(),
1
);

match old_config {
Some(value) => std::env::set_var("XDG_CONFIG_HOME", value),
None => std::env::remove_var("XDG_CONFIG_HOME"),
}
match old_state {
Some(value) => std::env::set_var("XDG_STATE_HOME", value),
None => std::env::remove_var("XDG_STATE_HOME"),
}
crate::detect::manifest::reload_manifests();
let _ = std::fs::remove_dir_all(base);
}

#[tokio::test]
async fn server_reload_agent_manifests_resets_detection_runtimes() {
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
Expand Down
6 changes: 6 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -700,6 +700,9 @@ impl App {
};

state.terminals = restored_terminals;
for terminal in state.terminals.values_mut() {
terminal_titles::reconcile_terminal_title_policy(terminal);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

for ws_idx in 0..state.workspaces.len() {
let cwd = state.workspaces[ws_idx]
Expand Down Expand Up @@ -844,6 +847,9 @@ impl App {
app.state.pane_id_aliases = pane_id_aliases;
app.state.workspaces = workspaces;
app.state.terminals = terminals;
for terminal in app.state.terminals.values_mut() {
terminal_titles::reconcile_terminal_title_policy(terminal);
}
app.terminal_runtimes = runtimes.into();
app.state.active = snapshot
.active
Expand Down
Loading
Loading