From a533257866272621a9fc66045f533ea218afc68f Mon Sep 17 00:00:00 2001
From: Taksh <takshkothari09@gmail.com>
Date: Fri, 31 Jul 2026 11:18:43 +0300
Subject: [PATCH 1/8] refactor(acp): add PresenceOffline loop action for auth
 expiry

Give the event loop a distinct signal when credentials are dead so it can
stop advertising online without exiting the process.

Signed-off-by: Taksh <takshkothari09@gmail.com>
---
 crates/buzz-acp/src/lib.rs | 5 ++++-
 1 file changed, 4 insertions(+), 1 deletion(-)

diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 403512a322..e3793419f0 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -2723,10 +2723,13 @@ async fn tokio_main() -> Result<()> {
     Ok(())
 }
 
-#[derive(PartialEq)]
+#[derive(PartialEq, Debug)]
 enum LoopAction {
     Continue,
     Exit,
+    /// Auth is permanently broken for this process — stop advertising online
+    /// presence so operators/sidebar stop treating the agent as reachable.
+    PresenceOffline,
 }
 
 fn event_mentions_agent(event: &nostr::Event, agent_pubkey_hex: &str) -> bool {

From 19ef2dcf16cdf72128c0e6be307c43e7b0c24cda Mon Sep 17 00:00:00 2001
From: Taksh <takshkothari09@gmail.com>
Date: Fri, 31 Jul 2026 11:18:43 +0300
Subject: [PATCH 2/8] fix(acp): classify Authentication required as a
 non-retryable auth error

Headless Claude expiry reports -32000 with this message; dead-letter it with
the existing Re-authenticate / 401 patterns instead of retrying forever.

Signed-off-by: Taksh <takshkothari09@gmail.com>
---
 crates/buzz-acp/src/lib.rs | 8 ++++++--
 1 file changed, 6 insertions(+), 2 deletions(-)

diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index e3793419f0..b4dd534982 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -3020,15 +3020,19 @@ fn dispatch_pending(
 ///   Specific to the auth-expiry flow; does not appear in unrelated errors.
 /// - `"API Error: 401"` — present in Claude/Codex HTTP-401 responses; 401 is
 ///   the standard auth-failure status and does not arise from network blips.
+/// - `"Authentication required"` — ACP `-32000` surface when the CLI reports
+///   `loggedIn: false` with no remaining credentials (headless expiry).
 ///
 /// False positives (misclassifying a transient error as non-retryable) silently
 /// drop a user message, which is worse than a false negative (extra retries on
-/// an auth error). Both patterns are therefore chosen for high precision.
+/// an auth error). These patterns are therefore chosen for high precision.
 fn is_auth_error(error: &acp::AcpError) -> bool {
     let acp::AcpError::AgentError { message, .. } = error else {
         return false;
     };
-    message.contains("Re-authenticate") || message.contains("API Error: 401")
+    message.contains("Re-authenticate")
+        || message.contains("API Error: 401")
+        || message.contains("Authentication required")
 }
 
 /// Spawn a task that posts a user-visible failure notice to the relay.

From f8e768dcaf1b7145ed7a14a0afc1dff80dcf3e3a Mon Sep 17 00:00:00 2001
From: Taksh <takshkothari09@gmail.com>
Date: Fri, 31 Jul 2026 11:18:59 +0300
Subject: [PATCH 3/8] fix(acp): return PresenceOffline when a turn hits auth
 expiry

Surface credential death to the event loop after dead-lettering the batch
and posting the re-auth notice.

Signed-off-by: Taksh <takshkothari09@gmail.com>
---
 crates/buzz-acp/src/lib.rs | 9 +++++++++
 1 file changed, 9 insertions(+)

diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index b4dd534982..4fec380524 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -3216,6 +3216,8 @@ fn handle_prompt_result(
         PromptOutcome::Cancelled => "cancelled",
         PromptOutcome::CancelDrainTimeout(_) => "cancel_drain_timeout",
     };
+    let auth_expired =
+        matches!(&result.outcome, PromptOutcome::Error(e) if is_auth_error(e));
     let agent_index = result.agent.index;
     // Capture the spawn-time configured model and our PID before the agent is
     // moved into match arms below. `desired_model` reflects the config/persona
@@ -3423,6 +3425,13 @@ fn handle_prompt_result(
             }
         }
     }
+    if auth_expired {
+        tracing::error!(
+            agent = agent_index,
+            "agent authentication expired — taking presence offline until restart"
+        );
+        return LoopAction::PresenceOffline;
+    }
     LoopAction::Continue
 }
 

From 486b5f558a80dd5fdd2698fd58fa9c4c5c0c1708 Mon Sep 17 00:00:00 2001
From: Taksh <takshkothari09@gmail.com>
Date: Fri, 31 Jul 2026 11:19:00 +0300
Subject: [PATCH 4/8] fix(acp): go offline and stop presence heartbeats on auth
 expiry

Keeps the harness process up for logs/restart, but stops looking online in
the sidebar once Claude/Codex credentials are dead (#3831).

Signed-off-by: Taksh <takshkothari09@gmail.com>
---
 crates/buzz-acp/src/lib.rs | 32 ++++++++++++++++++++++++++++----
 1 file changed, 28 insertions(+), 4 deletions(-)

diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 4fec380524..6d2c523c88 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -2354,7 +2354,7 @@ async fn tokio_main() -> Result<()> {
                 if let PromptSource::Channel(ch) = &result.source {
                     typing_channels.remove(ch);
                 }
-                if handle_prompt_result(
+                match handle_prompt_result(
                     &mut pool,
                     &mut queue,
                     &config,
@@ -2366,9 +2366,33 @@ async fn tokio_main() -> Result<()> {
                     &mut respawn_tasks,
                     observer.clone(),
                     Some(&ctx.rest_client),
-                ) == LoopAction::Exit
-                {
-                    break;
+                ) {
+                    LoopAction::Exit => break,
+                    LoopAction::PresenceOffline => {
+                        // Stop renewing online presence and advertise offline so
+                        // the sidebar stops treating this agent as reachable
+                        // after Claude/Codex credentials expire (#3831).
+                        presence_heartbeat = None;
+                        if let Some(h) = presence_task.take() {
+                            h.abort();
+                        }
+                        if config.presence_enabled {
+                            let pp = presence_publisher.clone();
+                            let pk = presence_keys.clone();
+                            presence_task = Some(tokio::spawn(async move {
+                                if let Err(e) = publish_presence(&pp, &pk, "offline").await {
+                                    tracing::warn!(
+                                        "failed to set presence offline after auth expiry: {e}"
+                                    );
+                                } else {
+                                    tracing::info!(
+                                        "presence set to offline after auth expiry"
+                                    );
+                                }
+                            }));
+                        }
+                    }
+                    LoopAction::Continue => {}
                 }
                 if drain_ready_join_results(
                     &mut pool,

From 28e5c4cd7f9b3e2c1ffb77ca4fbabef089fee94e Mon Sep 17 00:00:00 2001
From: Taksh <takshkothari09@gmail.com>
Date: Fri, 31 Jul 2026 11:19:42 +0300
Subject: [PATCH 5/8] test(acp): cover Authentication required auth-error
 classification

Pin the headless Claude expiry message so a future precision tweak cannot
quietly drop it from the non-retryable set.

Signed-off-by: Taksh <takshkothari09@gmail.com>
---
 crates/buzz-acp/src/lib.rs | 12 ++++++++++++
 1 file changed, 12 insertions(+)

diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 6d2c523c88..0ef057eaf1 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -6266,6 +6266,18 @@ mod error_outcome_emission_tests {
         );
     }
 
+    #[test]
+    fn is_auth_error_matches_authentication_required_message() {
+        let e = acp::AcpError::AgentError {
+            code: -32000,
+            message: "Authentication required".to_string(),
+        };
+        assert!(
+            is_auth_error(&e),
+            "headless 'Authentication required' must be classified as auth error"
+        );
+    }
+
     #[test]
     fn is_auth_error_rejects_other_agent_error_message() {
         let e = acp::AcpError::AgentError {

From 4c1285ab678786e6f0a411b3953c849fbd68f424 Mon Sep 17 00:00:00 2001
From: Taksh <takshkothari09@gmail.com>
Date: Fri, 31 Jul 2026 11:19:42 +0300
Subject: [PATCH 6/8] test(acp): assert auth expiry returns PresenceOffline

Regression pin for the #3831 sidebar-online-after-credential-death path.

Signed-off-by: Taksh <takshkothari09@gmail.com>
---
 crates/buzz-acp/src/lib.rs | 77 ++++++++++++++++++++++++++++++++++++++
 1 file changed, 77 insertions(+)

diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 0ef057eaf1..193f57ab64 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -6392,6 +6392,83 @@ mod error_outcome_emission_tests {
         );
     }
 
+    /// Auth expiry must also signal PresenceOffline so the event loop can stop
+    /// advertising the agent as online (#3831).
+    #[tokio::test]
+    async fn auth_error_returns_presence_offline_loop_action() {
+        let keys = nostr::Keys::generate();
+        let event = nostr::EventBuilder::new(nostr::Kind::Custom(9), "test")
+            .sign_with_keys(&keys)
+            .unwrap();
+        let channel_id = uuid::Uuid::new_v4();
+        let batch = FlushBatch {
+            channel_id,
+            events: vec![BatchEvent {
+                event,
+                prompt_tag: "test".into(),
+                received_at: std::time::Instant::now(),
+            }],
+            cancelled_events: vec![],
+            cancel_reason: None,
+        };
+
+        let auth_error = acp::AcpError::AgentError {
+            code: -32000,
+            message: "Authentication required".to_string(),
+        };
+
+        let agent = dummy_agent(0).await;
+        let mut pool = AgentPool::from_slots(vec![None]);
+        let task_id = pool.join_set.spawn(async {}).id();
+        pool.task_map_mut().insert(
+            task_id,
+            crate::pool::TaskMeta {
+                agent_index: 0,
+                channel_id: None,
+                turn_id: "test-turn-id".to_string(),
+                recoverable_batch: None,
+                control_tx: None,
+                steer_tx: None,
+            },
+        );
+        let mut queue = EventQueue::new(config::DedupMode::Queue);
+        let config = test_config();
+        let mut heartbeat_in_flight = false;
+        let removed_channels = std::collections::HashSet::new();
+        let mut crash_history = vec![SlotCircuit {
+            crash_times: Vec::new(),
+            open_until: None,
+            respawn_in_flight: false,
+        }];
+        let (respawn_tx, _respawn_rx) = mpsc::channel(8);
+        let mut respawn_tasks = tokio::task::JoinSet::new();
+        let result = PromptResult {
+            agent,
+            source: PromptSource::Channel(channel_id),
+            turn_id: "test-turn-id".to_string(),
+            outcome: PromptOutcome::Error(auth_error),
+            batch: Some(batch),
+        };
+        let action = handle_prompt_result(
+            &mut pool,
+            &mut queue,
+            &config,
+            result,
+            &mut heartbeat_in_flight,
+            &removed_channels,
+            &mut crash_history,
+            &respawn_tx,
+            &mut respawn_tasks,
+            None,
+            None,
+        );
+        assert_eq!(
+            action,
+            LoopAction::PresenceOffline,
+            "auth expiry must take presence offline so the agent stops looking online"
+        );
+    }
+
     /// A non-auth application error (e.g. usage credits) must still follow the
     /// standard requeue path so today's behavior is unchanged.
     #[tokio::test]

From f81477df8464a6d725d6223a94b2165e385ada5d Mon Sep 17 00:00:00 2001
From: Taksh <takshkothari09@gmail.com>
Date: Fri, 31 Jul 2026 11:20:04 +0300
Subject: [PATCH 7/8] fix(acp): tell users to restart after auth expiry
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The channel notice now matches presence behavior — re-login alone is not
enough until the harness process is restarted.

Signed-off-by: Taksh <takshkothari09@gmail.com>
---
 crates/buzz-acp/src/lib.rs | 9 +++------
 1 file changed, 3 insertions(+), 6 deletions(-)

diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs
index 193f57ab64..b63f24cc38 100644
--- a/crates/buzz-acp/src/lib.rs
+++ b/crates/buzz-acp/src/lib.rs
@@ -2385,9 +2385,7 @@ async fn tokio_main() -> Result<()> {
                                         "failed to set presence offline after auth expiry: {e}"
                                     );
                                 } else {
-                                    tracing::info!(
-                                        "presence set to offline after auth expiry"
-                                    );
+                                    tracing::info!("presence set to offline after auth expiry");
                                 }
                             }));
                         }
@@ -3191,7 +3189,7 @@ fn handle_prompt_result(
                 );
                 let content = "⚠️ I couldn't process the last request: authentication failed. \
                     Please re-authenticate the CLI (e.g. run `claude /login` or `codex login`) \
-                    and then re-send."
+                    and restart this agent. I'll stay offline until then."
                     .to_string();
                 spawn_failure_notice(rest_client, &batch, content);
             } else if let Some(dead) = queue.requeue(batch) {
@@ -3240,8 +3238,7 @@ fn handle_prompt_result(
         PromptOutcome::Cancelled => "cancelled",
         PromptOutcome::CancelDrainTimeout(_) => "cancel_drain_timeout",
     };
-    let auth_expired =
-        matches!(&result.outcome, PromptOutcome::Error(e) if is_auth_error(e));
+    let auth_expired = matches!(&result.outcome, PromptOutcome::Error(e) if is_auth_error(e));
     let agent_index = result.agent.index;
     // Capture the spawn-time configured model and our PID before the agent is
     // moved into match arms below. `desired_model` reflects the config/persona

From 4b600cd6dd0b289450afc168e20f4c9812f2fdeb Mon Sep 17 00:00:00 2001
From: Taksh <takshkothari09@gmail.com>
Date: Fri, 31 Jul 2026 11:20:04 +0300
Subject: [PATCH 8/8] docs(acp): document offline presence after credential
 expiry

Operators running headless Claude agents hit this often; put the recovery
steps next to the other harness docs.

Signed-off-by: Taksh <takshkothari09@gmail.com>
---
 crates/buzz-acp/README.md | 16 ++++++++++++++++
 1 file changed, 16 insertions(+)

diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md
index e6164b02dd..3d417e6008 100644
--- a/crates/buzz-acp/README.md
+++ b/crates/buzz-acp/README.md
@@ -335,6 +335,22 @@ Set `BUZZ_ACP_AGENT_COMMAND` and `BUZZ_ACP_AGENT_ARGS` to point at your agent bi
 
 See the [root TESTING.md](../../TESTING.md) for the full integration testing guide — automated test suites, multi-agent E2E testing via the ACP harness, and troubleshooting.
 
+## Troubleshooting
+
+### Agent stays "online" but every mention fails with `-32000`
+
+Claude / Codex subscription credentials can expire while the harness process is
+still up. When the agent returns a non-retryable auth error (`Authentication
+required`, `Re-authenticate`, or `API Error: 401`), buzz-acp:
+
+1. Dead-letters the failed turn and posts a channel notice asking you to re-login
+2. Stops renewing presence heartbeats
+3. Publishes presence `offline` so the sidebar stops looking live
+
+Re-authenticate the CLI (`claude /login` / `codex login`) and **restart**
+`buzz-acp` — presence does not automatically flip back to online inside the
+same process after auth death.
+
 ## License
 
 Apache-2.0
