diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs
index db78435a5..af9a96ef9 100644
--- a/apps/staged/src-tauri/src/doctor.rs
+++ b/apps/staged/src-tauri/src/doctor.rs
@@ -85,9 +85,30 @@ async fn run_doctor_report(app_handle: &tauri::AppHandle, check_freshness: bool)
if let Some(check) = node_runtime {
report.checks.push(check);
}
+ mark_bundled_bridges(&mut report, bundled_dir.as_deref());
report
}
+/// Stamp `bundled` on bridge readouts whose resolved path lives inside the
+/// app's bundled ACP tools dir. The doctor crate resolves those bridges via
+/// the PATH prefix `apply_bundled_tools_env` injects, so a prefix match
+/// against the same dir identifies them; the UI then presents them as
+/// bundled instead of showing an app-internal resource path.
+fn mark_bundled_bridges(report: &mut DoctorReport, bundled_dir: Option<&Path>) {
+ let Some(dir) = bundled_dir else { return };
+ for check in &mut report.checks {
+ let in_bundled_dir = check
+ .bridge_path
+ .as_deref()
+ .is_some_and(|p| Path::new(p).starts_with(dir));
+ if in_bundled_dir {
+ if let Some(bridge) = check.bridge.as_mut() {
+ bridge.bundled = Some(true);
+ }
+ }
+ }
+}
+
/// Run a fix for a doctor check, identified by check ID and fix type.
///
/// The actual shell command is looked up from the static check definitions —
@@ -632,4 +653,52 @@ mod tests {
assert_eq!(parse_node_major("not-a-version"), None);
assert_eq!(parse_node_major(""), None);
}
+
+ fn agent_check_with_bridge(id: &str, bridge_path: &str) -> DoctorCheck {
+ let mut check =
+ node_runtime_doctor_check(CheckStatus::Pass, "Installed".to_string(), None, None);
+ check.id = id.to_string();
+ check.bridge_path = Some(bridge_path.to_string());
+ check.bridge = Some(AgentVersionInfo::default());
+ check
+ }
+
+ #[test]
+ fn mark_bundled_bridges_stamps_only_bridges_inside_bundled_dir() {
+ let mut report = DoctorReport {
+ checks: vec![
+ agent_check_with_bridge(
+ "ai-agent-claude",
+ "/bundle/resources/acp/bin/claude-agent-acp",
+ ),
+ agent_check_with_bridge("ai-agent-pi", "/Users/me/.npm-global/bin/pi-acp"),
+ ],
+ };
+
+ mark_bundled_bridges(&mut report, Some(Path::new("/bundle/resources/acp/bin")));
+
+ assert_eq!(
+ report.checks[0].bridge.as_ref().unwrap().bundled,
+ Some(true),
+ );
+ assert_eq!(
+ report.checks[1].bridge.as_ref().unwrap().bundled,
+ None,
+ "a user-installed bridge outside the bundled dir must not be stamped",
+ );
+ }
+
+ #[test]
+ fn mark_bundled_bridges_without_bundled_dir_is_a_no_op() {
+ let mut report = DoctorReport {
+ checks: vec![agent_check_with_bridge(
+ "ai-agent-claude",
+ "/bundle/resources/acp/bin/claude-agent-acp",
+ )],
+ };
+
+ mark_bundled_bridges(&mut report, None);
+
+ assert_eq!(report.checks[0].bridge.as_ref().unwrap().bundled, None);
+ }
}
diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts
index 27b35a8a4..1d40f8ddd 100644
--- a/apps/staged/src/lib/commands.ts
+++ b/apps/staged/src/lib/commands.ts
@@ -1315,6 +1315,9 @@ export interface AgentVersionInfo {
updateCommand: string | null;
/** 'updateMain' or 'updateBridge', matching this readout's slot. */
updateFixType: 'updateMain' | 'updateBridge' | null;
+ /** True when this binary ships bundled with Staged (resolved from the app's
+ * bundled ACP tools dir rather than a user install). */
+ bundled: boolean | null;
}
export interface DoctorCheck {
diff --git a/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte b/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte
index 91ee102a2..ac0924fd5 100644
--- a/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte
+++ b/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte
@@ -49,19 +49,11 @@
check.status !== 'pass'
);
- /** Readouts (main + bridge) that have any version info worth surfacing. */
- interface ReadoutEntry {
- slot: 'main' | 'bridge';
- info: AgentVersionInfo;
+ /** Whether a readout surfaces an update badge (under its own path line). */
+ function showsUpdateBadge(info: AgentVersionInfo | null): boolean {
+ return info?.updateAvailable === true;
}
- const readouts = $derived(
- (
- [
- { slot: 'main', info: check.main },
- { slot: 'bridge', info: check.bridge },
- ] as { slot: 'main' | 'bridge'; info: AgentVersionInfo | null }[]
- ).filter((r): r is ReadoutEntry => r.info?.updateAvailable === true)
- );
+ const anyUpdateBadge = $derived(showsUpdateBadge(check.main) || showsUpdateBadge(check.bridge));
/** Update commands that will run when the user confirms (actionable only). */
const updateCommands = $derived(
@@ -81,7 +73,7 @@
// is noise — and skip rows that already surface a result (Update button or
// badge) so the spinner and the result never display together.
const showFreshnessSpinner = $derived(
- doctorState.freshnessLoading && check.status !== 'fail' && !canUpdate && readouts.length === 0
+ doctorState.freshnessLoading && check.status !== 'fail' && !canUpdate && !anyUpdateBadge
);
let showUpdateDialog = $state(false);
@@ -141,6 +133,16 @@
}
+{#snippet updateBadge(slot: 'main' | 'bridge', info: AgentVersionInfo | null)}
+ {#if info?.updateAvailable === true}
+
+