From 1f41bc0d84e7ebbea0b228ade16bf1bb17f003e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonada?= Date: Wed, 2 Sep 2026 08:46:21 -0400 Subject: [PATCH 01/10] fix: stop open-pane remount from disk and window focus getSessionDetail returns the first history page. Reloading the selected task on every updates.jsonl write, roster refresh, or WebKitGTK focus painted as a session restart. Load the open pane once on select; keep the current task across roster pages; ignore window focus on Wayland. --- src/App.tsx | 32 +++++++++++++++++--------------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 1268de6..0953f8c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -75,7 +75,9 @@ function pickSelectedId( prev: string | null, managed?: ManagedAgentInfo[], ): string | null { - if (prev && list.some((s) => s.id === prev)) return prev; + // Keep the open pane. The roster page is not the full list, so "not in + // this page" is not "gone" — treating it as gone remounts from page 1. + if (prev) return prev; if (managed?.length) { const managedSid = managed.find((m) => m.sessionId)?.sessionId; if (managedSid && list.some((s) => s.id === managedSid)) { @@ -294,10 +296,10 @@ function App() { if (now - lastFsRefreshRef.current < FS_REFRESH_MIN_MS) return; lastFsRefreshRef.current = now; void refreshList(); - const id = selectedIdRef.current; - if (id) void refreshDetail(id, true); + // Roster only. Reloading the open pane from disk replaces recentUpdates + // with the first history page and paints as a session restart. setGitRefreshKey((n) => n + 1); - }, [refreshList, refreshDetail]); + }, [refreshList]); const liveManagedCount = useMemo( () => @@ -312,11 +314,17 @@ function App() { () => setGitRefreshKey((n) => n + 1), ); + const loadedDetailIdRef = useRef(null); useEffect(() => { if (!selectedId) { setDetail(null); + loadedDetailIdRef.current = null; return; } + // Load once per selected task. refreshDetail identity must not replay + // getSessionDetail (first history page) into a live pane. + if (loadedDetailIdRef.current === selectedId) return; + loadedDetailIdRef.current = selectedId; void refreshDetail(selectedId); }, [selectedId, refreshDetail]); @@ -344,12 +352,7 @@ function App() { // the appended bytes for this card. void refreshCard(payload.sessionId); } - if ( - selected && - (!payload.sessionId || payload.sessionId === selected) - ) { - void refreshDetail(selected, true); - } + // Do not getSessionDetail the open pane on updates.jsonl. }).then((fn) => { if (cancelled) fn(); else unlisten = fn; @@ -358,7 +361,7 @@ function App() { cancelled = true; unlisten?.(); }; - }, [refreshList, refreshDetail, refreshCard]); + }, [refreshList, refreshCard]); // These are advertised ACP capabilities, so consume their notifications // and invalidate the workspace immediately. @@ -382,7 +385,6 @@ function App() { return; } setGitRefreshKey((n) => n + 1); - if (selected) void refreshDetail(selected, true); }).then((fn) => { if (cancelled) fn(); else unlisten = fn; @@ -391,7 +393,7 @@ function App() { cancelled = true; unlisten?.(); }; - }, [refreshDetail]); + }, []); // Focus / tab visible → catch anything the watcher missed (debounced). useEffect(() => { @@ -405,10 +407,10 @@ function App() { }, 300); }; document.addEventListener("visibilitychange", onVis); - window.addEventListener("focus", onVis); + // WebKitGTK/Wayland fires window focus on every click; that used to + // refreshFromDisk → roster reload → selection bounce → page-1 remount. return () => { document.removeEventListener("visibilitychange", onVis); - window.removeEventListener("focus", onVis); if (t != null) window.clearTimeout(t); }; }, [refreshFromDisk]); From 0c338b66379e8dae01fda09de609289e17233d99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonada?= Date: Wed, 2 Sep 2026 10:47:22 -0400 Subject: [PATCH 02/10] fix(acp): stop Live/Starting flicker on reconnect --- src-tauri/src/acp/mod.rs | 6 +++++- src-tauri/src/agent_manager/reconnect.rs | 19 +++++++++++++++++++ src-tauri/src/agent_types.rs | 5 +++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/acp/mod.rs b/src-tauri/src/acp/mod.rs index 5db9405..14456a3 100644 --- a/src-tauri/src/acp/mod.rs +++ b/src-tauri/src/acp/mod.rs @@ -558,6 +558,9 @@ fn build_spawn_argv( let mut args: Vec = Vec::new(); args.extend(global_args.iter().cloned()); args.push("agent".into()); + // Dedicated per-task process. Sharing the TUI leader makes session/load + // steal the same backend and the card heading flip Live ↔ Starting. + args.push("--no-leader".into()); if always_approve { args.push("--always-approve".into()); } @@ -675,6 +678,7 @@ mod tests { "--permission-mode", "auto", "agent", + "--no-leader", "-m", "grok-4", "stdio", @@ -682,7 +686,7 @@ mod tests { ); assert_eq!( build_spawn_argv(true, &[], &[]), - vec!["agent", "--always-approve", "stdio"] + vec!["agent", "--no-leader", "--always-approve", "stdio"] ); } diff --git a/src-tauri/src/agent_manager/reconnect.rs b/src-tauri/src/agent_manager/reconnect.rs index 78a38d8..5ed1613 100644 --- a/src-tauri/src/agent_manager/reconnect.rs +++ b/src-tauri/src/agent_manager/reconnect.rs @@ -301,6 +301,11 @@ fn run(inner: Arc, handle_id: String, failed_generation: u64) { let next_generation = failed_generation.wrapping_add(1); let mut last_error = "ACP reconnect did not start".to_string(); + // Reap the failed stdio child before spawning a replacement. Overlapping + // `grok agent stdio` processes on the same session fight over session + // locks and MCP, which shows up as the task card flipping Live ↔ Starting. + kill_failed_client(&inner, &handle_id, failed_generation); + for delay in RECONNECT_DELAYS { if !delay.is_zero() { thread::sleep(delay); @@ -406,6 +411,20 @@ struct ReconnectSnapshot { agent_args: Vec, } +fn kill_failed_client(inner: &Inner, handle_id: &str, failed_generation: u64) { + let client = { + let agents = inner.agents.lock(); + let Some(agent) = agents.get(handle_id) else { + return; + }; + if agent.connection_generation != failed_generation || !agent.reconnecting { + return; + } + Arc::clone(&agent.client) + }; + let _ = client.kill(); +} + fn reconnect_snapshot( inner: &Inner, handle_id: &str, diff --git a/src-tauri/src/agent_types.rs b/src-tauri/src/agent_types.rs index b002e67..f7fe8af 100644 --- a/src-tauri/src/agent_types.rs +++ b/src-tauri/src/agent_types.rs @@ -20,8 +20,9 @@ pub enum PermissionMode { Default, AcceptEdits, /// Grok Auto: host allows safe tools; ask on high risk. - /// Spawn/attach pass top-level `grok --permission-mode auto agent stdio` - /// (not under `agent` — clap rejects that and the process dies immediately). + /// Spawn/attach pass top-level `grok --permission-mode auto agent --no-leader stdio` + /// (`--permission-mode` is not under `agent` — clap rejects that and the + /// process dies immediately). /// Live Mode toggles notify Grok via `x.ai/yolo_mode_changed`; the host /// never overrides a permission request that Grok's classifier escalated. Auto, From b129bf3a005ef1b9e809283f73d756b249b428d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonada?= Date: Wed, 2 Sep 2026 11:28:31 -0400 Subject: [PATCH 03/10] fix: keep timeline buffers while ACP is starting --- src/App.tsx | 8 ++++++-- src/hooks/useAgentEvents.ts | 17 ++++++++++++----- src/hooks/useTimelineHistory.ts | 2 +- src/utils/managedChrome.ts | 3 ++- src/utils/managedStatus.ts | 10 ++++++++++ 5 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0953f8c..61d1508 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -48,7 +48,10 @@ import { isLocalSlashCommand, runLocalSlash, } from "./utils/localSlash"; -import { isLiveManagedStatus } from "./utils/managedStatus"; +import { + isAttachedManagedStatus, + isLiveManagedStatus, +} from "./utils/managedStatus"; import type { UserQuestionResolvePayload } from "./utils/permissionPayload"; import { joinUnderRoot } from "./utils/paths"; import { @@ -175,9 +178,10 @@ function App() { onError: setError, }); // ACP owns the live tail when attached; disk-only sessions re-hydrate on poll. + // Include `starting` so reconnect does not replace the open pane with page 1. const liveOwnsTail = managedForSession != null && - isLiveManagedStatus(managedForSession.status); + isAttachedManagedStatus(managedForSession.status); const timelineHistory = useTimelineHistory( selectedId, detail, diff --git a/src/hooks/useAgentEvents.ts b/src/hooks/useAgentEvents.ts index 22da81e..b1d13ad 100644 --- a/src/hooks/useAgentEvents.ts +++ b/src/hooks/useAgentEvents.ts @@ -15,7 +15,10 @@ import type { ShellEntry, } from "../types"; import type { LocalSlashItem } from "../utils/localSlash"; -import { isLiveManagedStatus } from "../utils/managedStatus"; +import { + isAttachedManagedStatus, + isLiveManagedStatus, +} from "../utils/managedStatus"; import { describeUpdate, extractUpdateTsMs } from "../utils/format"; import { describePendingInteractionNotification, @@ -250,9 +253,11 @@ export function useAgentEvents( (sessionId: string, updates: unknown[]) => { if (!sessionId) return; const diskItems = hydrateLiveFromDiskUpdates(updates, sessionId); - setLiveBySession((prev) => - mergeDiskLiveIntoMap(prev, sessionId, diskItems), - ); + setLiveBySession((prev) => { + const next = mergeDiskLiveIntoMap(prev, sessionId, diskItems); + if (next !== prev) liveRef.current = next; + return next; + }); }, [], ); @@ -576,7 +581,9 @@ export function useAgentEvents( const keep = new Set(); if (selectedSessionId) keep.add(selectedSessionId); for (const m of managedList) { - if (!isLiveManagedStatus(m.status)) continue; + // Keep buffers through starting/stopping so reconnect does not drop + // the open timeline (291 → first disk page → 291). + if (!isAttachedManagedStatus(m.status)) continue; keep.add(m.handleId); if (m.sessionId) keep.add(m.sessionId); } diff --git a/src/hooks/useTimelineHistory.ts b/src/hooks/useTimelineHistory.ts index 849e1a9..b2413e9 100644 --- a/src/hooks/useTimelineHistory.ts +++ b/src/hooks/useTimelineHistory.ts @@ -53,7 +53,7 @@ export function updatesPageFingerprint(updates: unknown[]): string { * - First page available for a session visit → hydrate * - "Load earlier activity" → hydrate (always; uses latest map merge) * - Silent detail / FS polls: - * - when `liveOwnsTail` (live ACP attached) → update raw page only + * - when `liveOwnsTail` (ACP attached, including starting) → raw page only * - when disk-only → re-hydrate when the page fingerprint changes */ export function useTimelineHistory( diff --git a/src/utils/managedChrome.ts b/src/utils/managedChrome.ts index 79fd55f..8d5aa28 100644 --- a/src/utils/managedChrome.ts +++ b/src/utils/managedChrome.ts @@ -1,4 +1,5 @@ import type { ManagedStatus } from "../types"; +import { isAttachedManagedStatus } from "./managedStatus"; /** * Visual run-state for a task card / list chrome. @@ -18,7 +19,7 @@ export type CardState = /** Non-terminal PinkCode attach (includes starting / ready / running / …). */ export function isPinkcodeAttached(st?: ManagedStatus | null): boolean { - return Boolean(st && st !== "stopped" && st !== "error"); + return isAttachedManagedStatus(st); } const MANAGED_ACTIVE = new Set([ diff --git a/src/utils/managedStatus.ts b/src/utils/managedStatus.ts index bb886af..736535c 100644 --- a/src/utils/managedStatus.ts +++ b/src/utils/managedStatus.ts @@ -10,3 +10,13 @@ export function isLiveManagedStatus( status === "awaitingPermission" ); } + +/** + * Non-terminal attach, including `starting` / `stopping`. + * Keep timeline buffers and skip disk page-1 resync while reconnecting. + */ +export function isAttachedManagedStatus( + status: ManagedAgentInfo["status"] | null | undefined, +): boolean { + return Boolean(status && status !== "stopped" && status !== "error"); +} From b2c77295e89633e3ddadf490400e7f0db2cb72a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonada?= Date: Wed, 2 Sep 2026 11:55:54 -0400 Subject: [PATCH 04/10] fix: no-op send when another Grok process owns the session Refuse attach if another pid already has the session. Do not paint an error banner; Open in Grok Build is the cue. Send no-ops. --- src-tauri/src/agent_manager.rs | 49 ++++++++++++++++++++---- src-tauri/src/agent_manager/reconnect.rs | 9 +++++ src-tauri/src/sessions.rs | 19 +++++++++ src/App.tsx | 39 +++++++++++-------- src/api.ts | 21 ++++++++++ src/utils/managedStatus.ts | 2 +- 6 files changed, 114 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/agent_manager.rs b/src-tauri/src/agent_manager.rs index 73679b6..713165c 100644 --- a/src-tauri/src/agent_manager.rs +++ b/src-tauri/src/agent_manager.rs @@ -11,6 +11,7 @@ use crate::permission_policy::{ GateDecision, }; use crate::rpc_handler::{self, HandleResult, ResponseAction}; +use crate::sessions; use crate::shell_emitter; use crate::shell_stream::ShellStream; use crate::task_prefs; @@ -37,6 +38,8 @@ struct LiveAgent { agent_args: Vec, in_flight_prompts: HashSet, current_prompt_id: Option, + last_activate: Option, + reconnect_burst: u32, } struct RequestTarget { @@ -917,7 +920,18 @@ impl AgentManager { ) { return; } - let can_reconnect = agent.info.session_id.is_some(); + const UNSTABLE: Duration = Duration::from_secs(5); + let unstable = agent + .last_activate + .map(|t| t.elapsed() < UNSTABLE) + .unwrap_or(false); + if unstable { + agent.reconnect_burst = agent.reconnect_burst.saturating_add(1); + } else { + agent.reconnect_burst = 0; + } + let can_reconnect = + agent.info.session_id.is_some() && agent.reconnect_burst < 3; agent.info.status = if can_reconnect { ManagedStatus::Starting } else { @@ -928,6 +942,10 @@ impl AgentManager { agent.info.pending_permission_count = 0; agent.info.last_error = Some(if can_reconnect { format!("ACP {failure}; reconnecting after transport loss: {reason}") + } else if agent.reconnect_burst >= 3 { + format!( + "ACP {failure}: {reason} (gave up after repeated reconnects; the session may be open in another Grok process)" + ) } else { format!("ACP {failure}: {reason}") }); @@ -1043,6 +1061,8 @@ impl AgentManager { agent_args: agent_args.to_vec(), in_flight_prompts: HashSet::new(), current_prompt_id: None, + last_activate: None, + reconnect_burst: 0, }, ); Self::drain_early_requests(&self.inner, &handle_id); @@ -1263,6 +1283,9 @@ impl AgentManager { }; info.session_id = Some(session_id.clone()); + if let Some(a) = self.inner.agents.lock().get_mut(&handle_id) { + a.last_activate = Some(Instant::now()); + } task_prefs::set_permission_mode(&session_id, permission_mode)?; if let Some(models) = result.models.as_ref() { models::apply_models_info(&mut info, models); @@ -1312,14 +1335,24 @@ impl AgentManager { return Err(format!("Invalid working directory: {cwd}")); } - { + let existing = { let agents = self.inner.agents.lock(); - if let Some(existing) = agents.values().find(|a| { - a.info.session_id.as_deref() == Some(session_id.as_str()) - && !matches!(a.info.status, ManagedStatus::Stopped | ManagedStatus::Error) - }) { - return Ok(existing.info.clone()); + agents + .values() + .find(|a| { + a.info.session_id.as_deref() == Some(session_id.as_str()) + && !matches!(a.info.status, ManagedStatus::Stopped | ManagedStatus::Error) + }) + .map(|a| (a.info.clone(), a.info.pid)) + }; + if let Some((info, pid)) = existing { + if let Some(msg) = sessions::session_open_elsewhere_error(&session_id, pid) { + return Err(msg); } + return Ok(info); + } + if let Some(msg) = sessions::session_open_elsewhere_error(&session_id, None) { + return Err(msg); } { let mut attaching = self.inner.attaching_sessions.lock(); @@ -1327,6 +1360,7 @@ impl AgentManager { return Err(format!("session {session_id} is already attaching")); } } + let _reservation = AttachReservation { inner: Arc::clone(&self.inner), session_id: session_id.clone(), @@ -1384,6 +1418,7 @@ impl AgentManager { info.status = ManagedStatus::Ready; if let Some(a) = self.inner.agents.lock().get_mut(&handle_id) { a.info = info.clone(); + a.last_activate = Some(Instant::now()); } Self::emit_status(&self.inner, &info); diff --git a/src-tauri/src/agent_manager/reconnect.rs b/src-tauri/src/agent_manager/reconnect.rs index 5ed1613..eb5deea 100644 --- a/src-tauri/src/agent_manager/reconnect.rs +++ b/src-tauri/src/agent_manager/reconnect.rs @@ -306,6 +306,14 @@ fn run(inner: Arc, handle_id: String, failed_generation: u64) { // locks and MCP, which shows up as the task card flipping Live ↔ Starting. kill_failed_client(&inner, &handle_id, failed_generation); + if let Some(snapshot) = reconnect_snapshot(&inner, &handle_id, failed_generation) { + if let Some(msg) = crate::sessions::session_open_elsewhere_error(&snapshot.session_id, None) + { + fail_reconnect(&inner, &handle_id, failed_generation, &msg); + return; + } + } + for delay in RECONNECT_DELAYS { if !delay.is_zero() { thread::sleep(delay); @@ -482,6 +490,7 @@ fn activate_candidate( super::models::apply_models_info(&mut agent.info, models); } set_prompt_state(agent, activation.running_prompt_id); + agent.last_activate = Some(std::time::Instant::now()); AgentManager::emit_status(inner, &agent.info); Some(old_client) } diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs index ce3d108..50869b7 100644 --- a/src-tauri/src/sessions.rs +++ b/src-tauri/src/sessions.rs @@ -110,6 +110,25 @@ pub fn read_active_sessions() -> Result> { .collect()) } +/// Live pid holding `session_id` in `active_sessions.json`, if it is not `ignore_pid`. +pub fn foreign_active_pid(session_id: &str, ignore_pid: Option) -> Option { + read_active_sessions().ok()?.into_iter().find_map(|s| { + if s.session_id == session_id && ignore_pid != Some(s.pid) { + Some(s.pid) + } else { + None + } + }) +} + +pub fn session_open_elsewhere_error(session_id: &str, ignore_pid: Option) -> Option { + foreign_active_pid(session_id, ignore_pid).map(|pid| { + format!( + "session is already open in another Grok process (pid {pid}); close that process or pick a different task" + ) + }) +} + /// Best-effort check that `pid` still refers to a live process. fn process_is_alive(pid: u32) -> bool { if pid == 0 { diff --git a/src/App.tsx b/src/App.tsx index 61d1508..5d5d7cc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import { attachAgent, + formatInvokeError, + isExclusiveSessionError, getLastSpawnPermissionMode, getSessionDetail, interjectAgent, @@ -509,10 +511,7 @@ function App() { sessionId: string, ): Promise { const existing = managedList.find( - (m) => - m.sessionId === sessionId && - m.status !== "stopped" && - m.status !== "error", + (m) => m.sessionId === sessionId && isLiveManagedStatus(m.status), ); if (existing) return existing; @@ -522,11 +521,19 @@ function App() { setSelectedId(sessionId); // Backend restores this task's saved mode when permissionMode is omitted. const saved = taskPermissionModes[sessionId]; - const info = await attachAgent({ - sessionId: card.id, - cwd: card.cwd, - permissionMode: saved ?? null, - }); + let info: ManagedAgentInfo; + try { + info = await attachAgent({ + sessionId: card.id, + cwd: card.cwd, + permissionMode: saved ?? null, + }); + } catch (e) { + // Another Grok process owns the session. Open in Grok Build chrome + // is the signal; do not paint the error banner. + if (isExclusiveSessionError(e)) return null; + throw e; + } upsertManaged(info); if (info.sessionId) { setTaskPermissionModes((prev) => ({ @@ -716,11 +723,7 @@ function App() { let liveAgent = managedForSession; let handleId = liveAgent?.handleId; let sessionIdForPlan = liveAgent?.sessionId ?? selectedId; - if ( - !handleId || - liveAgent?.status === "stopped" || - liveAgent?.status === "error" - ) { + if (!handleId || !isLiveManagedStatus(liveAgent?.status)) { const sessionId = selectedId ?? sessions[0]?.id ?? null; if (!sessionId) { setError("Select a task first, or create one with New."); @@ -728,7 +731,7 @@ function App() { } const info = await ensureAttached(sessionId); if (!info) { - setError("Could not connect to this task."); + // Exclusive-session attach is refused; Open in Grok Build is the cue. return; } liveAgent = info; @@ -740,7 +743,7 @@ function App() { try { liveAgent = await applySessionModel(liveAgent); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + setError(formatInvokeError(e)); } } @@ -769,7 +772,9 @@ function App() { } setPinTimelineBottomSeq((n) => n + 1); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + if (!isExclusiveSessionError(e)) { + setError(formatInvokeError(e)); + } } finally { setControlBusy(false); } diff --git a/src/api.ts b/src/api.ts index 64a2cf3..2b2f086 100644 --- a/src/api.ts +++ b/src/api.ts @@ -118,6 +118,27 @@ export async function attachAgent(request: AttachRequest): Promise("attach_agent", { request }); } +/** Tauri `invoke` rejects with a string, Error, or `{ message }`. */ +export function formatInvokeError(error: unknown): string { + if (typeof error === "string" && error.trim()) return error; + if (error instanceof Error && error.message.trim()) return error.message; + if (error && typeof error === "object") { + const rec = error as { message?: unknown; error?: unknown }; + if (typeof rec.message === "string" && rec.message.trim()) { + return rec.message; + } + if (typeof rec.error === "string" && rec.error.trim()) { + return rec.error; + } + } + return String(error); +} + +/** Attach refused because another Grok pid owns the session. */ +export function isExclusiveSessionError(error: unknown): boolean { + return /already open in another Grok process/i.test(formatInvokeError(error)); +} + export async function promptAgent( handleId: string, text: string, diff --git a/src/utils/managedStatus.ts b/src/utils/managedStatus.ts index 736535c..ba3ac9f 100644 --- a/src/utils/managedStatus.ts +++ b/src/utils/managedStatus.ts @@ -2,7 +2,7 @@ import type { ManagedAgentInfo } from "../types"; /** Agent is attached and can own the live ACP tail / timeline buffers. */ export function isLiveManagedStatus( - status: ManagedAgentInfo["status"], + status: ManagedAgentInfo["status"] | null | undefined, ): boolean { return ( status === "ready" || From 579a10de374863ac855348f733925c50f3f8044f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonada?= Date: Wed, 2 Sep 2026 19:57:57 -0400 Subject: [PATCH 05/10] fix(acp): kill grok child when reconnect gives up Giving up after a reconnect burst left the stdio agent in active_sessions.json. PinkCode then treated that pid as Grok Build and stalled until Stop. Kill the child on give-up/fail, wait for it to leave the session lock, and show Disconnected instead of Open in Grok Build. --- src-tauri/src/agent_manager.rs | 19 +++++++++++++------ src-tauri/src/agent_manager/reconnect.rs | 23 ++++++++++++++--------- src-tauri/src/sessions.rs | 21 +++++++++++++++++++++ src/components/SessionList.tsx | 5 ++++- src/utils/managedChrome.test.ts | 1 + src/utils/managedChrome.ts | 1 + src/utils/turnActivity.test.ts | 9 +++++++++ src/utils/turnActivity.ts | 17 +++++++++++++++++ 8 files changed, 80 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/agent_manager.rs b/src-tauri/src/agent_manager.rs index 713165c..51a74aa 100644 --- a/src-tauri/src/agent_manager.rs +++ b/src-tauri/src/agent_manager.rs @@ -907,7 +907,7 @@ impl AgentManager { } } - let (updated, should_reconnect) = { + let (updated, should_reconnect, kill_client) = { let mut agents = inner.agents.lock(); match agents.get_mut(handle_id) { Some(agent) => { @@ -943,15 +943,18 @@ impl AgentManager { agent.info.last_error = Some(if can_reconnect { format!("ACP {failure}; reconnecting after transport loss: {reason}") } else if agent.reconnect_burst >= 3 { - format!( - "ACP {failure}: {reason} (gave up after repeated reconnects; the session may be open in another Grok process)" - ) + format!("ACP {failure}: {reason} (gave up after repeated reconnects)") } else { format!("ACP {failure}: {reason}") }); - (Some(agent.info.clone()), can_reconnect) + let kill_on_give_up = if can_reconnect { + None + } else { + Some(Arc::clone(&agent.client)) + }; + (Some(agent.info.clone()), can_reconnect, kill_on_give_up) } - None => (None, false), + None => (None, false, None), } }; if let Some(info) = updated { @@ -959,6 +962,10 @@ impl AgentManager { } if should_reconnect { Self::spawn_reconnect(Arc::clone(inner), handle_id.to_string(), generation); + } else if let Some(client) = kill_client { + // Giving up without a kill leaves grok in active_sessions.json, + // which the UI then labels "Open in Grok Build". + let _ = client.kill(); } } diff --git a/src-tauri/src/agent_manager/reconnect.rs b/src-tauri/src/agent_manager/reconnect.rs index eb5deea..68d7f57 100644 --- a/src-tauri/src/agent_manager/reconnect.rs +++ b/src-tauri/src/agent_manager/reconnect.rs @@ -304,10 +304,14 @@ fn run(inner: Arc, handle_id: String, failed_generation: u64) { // Reap the failed stdio child before spawning a replacement. Overlapping // `grok agent stdio` processes on the same session fight over session // locks and MCP, which shows up as the task card flipping Live ↔ Starting. - kill_failed_client(&inner, &handle_id, failed_generation); + let killed_pid = kill_failed_client(&inner, &handle_id, failed_generation); + if let Some(pid) = killed_pid { + let _ = crate::sessions::wait_until_dead(pid, Duration::from_secs(2)); + } if let Some(snapshot) = reconnect_snapshot(&inner, &handle_id, failed_generation) { - if let Some(msg) = crate::sessions::session_open_elsewhere_error(&snapshot.session_id, None) + if let Some(msg) = + crate::sessions::session_open_elsewhere_error(&snapshot.session_id, killed_pid) { fail_reconnect(&inner, &handle_id, failed_generation, &msg); return; @@ -419,18 +423,18 @@ struct ReconnectSnapshot { agent_args: Vec, } -fn kill_failed_client(inner: &Inner, handle_id: &str, failed_generation: u64) { +fn kill_failed_client(inner: &Inner, handle_id: &str, failed_generation: u64) -> Option { let client = { let agents = inner.agents.lock(); - let Some(agent) = agents.get(handle_id) else { - return; - }; + let agent = agents.get(handle_id)?; if agent.connection_generation != failed_generation || !agent.reconnecting { - return; + return None; } Arc::clone(&agent.client) }; + let pid = client.pid(); let _ = client.kill(); + Some(pid) } fn reconnect_snapshot( @@ -571,11 +575,12 @@ fn fail_reconnect(inner: &Inner, handle_id: &str, failed_generation: u64, error: agent.info.status = ManagedStatus::Error; agent.info.pid = None; agent.info.last_error = Some(format!("ACP reconnect failed: {error}")); - Some(agent.info.clone()) + Some((agent.info.clone(), Arc::clone(&agent.client))) }) }; - if let Some(info) = updated { + if let Some((info, client)) = updated { AgentManager::emit_status(inner, &info); + let _ = client.kill(); } } diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs index 50869b7..439a460 100644 --- a/src-tauri/src/sessions.rs +++ b/src-tauri/src/sessions.rs @@ -129,6 +129,22 @@ pub fn session_open_elsewhere_error(session_id: &str, ignore_pid: Option) - }) } +/// Poll until `pid` is gone or `timeout` elapses. Used after killing an ACP +/// child so `active_sessions.json` does not still list it as a foreign owner. +pub fn wait_until_dead(pid: u32, timeout: Duration) -> bool { + if pid == 0 || !process_is_alive(pid) { + return true; + } + let start = Instant::now(); + while start.elapsed() < timeout { + std::thread::sleep(Duration::from_millis(50)); + if !process_is_alive(pid) { + return true; + } + } + !process_is_alive(pid) +} + /// Best-effort check that `pid` still refers to a live process. fn process_is_alive(pid: u32) -> bool { if pid == 0 { @@ -1271,6 +1287,11 @@ fn extract_update_unix_secs(msg: &Value) -> Option { mod tests { use super::*; + #[test] + fn wait_until_dead_treats_pid_zero_as_gone() { + assert!(wait_until_dead(0, Duration::from_millis(1))); + } + #[test] fn token_usage_series_returns_window() { let s = token_usage_series(7).expect("series"); diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 8e35d17..23dd090 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -113,7 +113,10 @@ export function SessionList({ {visible.map((s) => { const managedStatus = managedStatuses?.[s.id]; const attached = isPinkcodeAttached(managedStatus); - const openElsewhere = s.isActive && !attached; + // Error means PinkCode owned this task and dropped ACP. A leftover + // grok pid in active_sessions is ours, not Grok Build. + const openElsewhere = + s.isActive && !attached && managedStatus !== "error"; const needsInput = needsInputSessionIds?.has(s.id) ?? false; const state = resolveCardState( managedStatus, diff --git a/src/utils/managedChrome.test.ts b/src/utils/managedChrome.test.ts index efd5b27..9db03b9 100644 --- a/src/utils/managedChrome.test.ts +++ b/src/utils/managedChrome.test.ts @@ -31,6 +31,7 @@ describe("resolveCardState / rankManagedCard", () => { expect(resolveCardState("starting", false)).toBe("starting"); expect(resolveCardState("awaitingPermission", false)).toBe("awaiting"); expect(resolveCardState("ready", false)).toBe("live"); + expect(resolveCardState("error", true)).toBe("idle"); expect(resolveCardState(undefined, true)).toBe("open"); expect(resolveCardState(undefined, false)).toBe("idle"); }); diff --git a/src/utils/managedChrome.ts b/src/utils/managedChrome.ts index 8d5aa28..6e5dbac 100644 --- a/src/utils/managedChrome.ts +++ b/src/utils/managedChrome.ts @@ -50,6 +50,7 @@ export function resolveCardState( if (st === "starting") return "starting"; if (st === "awaitingPermission") return "awaiting"; if (isPinkcodeAttached(st)) return "live"; + if (st === "error") return "idle"; if (openElsewhere) return "open"; return "idle"; } diff --git a/src/utils/turnActivity.test.ts b/src/utils/turnActivity.test.ts index 7faed22..a13c5c7 100644 --- a/src/utils/turnActivity.test.ts +++ b/src/utils/turnActivity.test.ts @@ -152,6 +152,15 @@ describe("resolveTurnActivity", () => { expect(act?.hint).toMatch(/connect/i); }); + it("does not call a PinkCode ACP error Open in Grok Build", () => { + const act = resolveTurnActivity(managed("error"), [], { + sessionIsActive: true, + }); + expect(act?.label).toBe("Disconnected"); + expect(act?.source).toBe("managed"); + expect(act?.kind).toBe("waiting"); + }); + it("does not show external ambient when PinkCode is already connected idle", () => { expect( resolveTurnActivity(managed("ready"), [], { sessionIsActive: true }), diff --git a/src/utils/turnActivity.ts b/src/utils/turnActivity.ts index 0cc31f4..0853079 100644 --- a/src/utils/turnActivity.ts +++ b/src/utils/turnActivity.ts @@ -396,6 +396,23 @@ export function resolveTurnActivity( // PinkCode already attached but idle — no status row. if (isManagedConnected(managed)) return null; + // Our attach died. Do not relabel the leftover grok pid as Grok Build. + if (managed?.status === "error") { + return activity({ + kind: "waiting", + label: "Disconnected", + tone: "danger", + indicator: "danger", + phaseKey: "error", + source: "managed", + hint: managed.lastError?.trim() + ? managed.lastError + : "Agent lost ACP. Stop, then send to reconnect.", + showPhaseTimer: false, + showTurnTimer: false, + }); + } + // External host has the session process open (typical: Grok Build TUI). if (opts?.sessionIsActive) { const inferred = inferActivityFromTimeline(items, "external"); From 69284e5ec26adb4d68b317c4cfeb3cd3c3606629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonada?= Date: Thu, 3 Sep 2026 15:54:13 -0400 Subject: [PATCH 06/10] fix: drop gone selection while keeping off-page tasks Roster pages are 30 cards. Keep the open pane when it is missing from this page but still on disk; drop it when get_session_card says gone. --- src/App.tsx | 40 ++++++++--------- src/utils/pickSelectedId.test.ts | 74 ++++++++++++++++++++++++++++++++ src/utils/pickSelectedId.ts | 32 ++++++++++++++ 3 files changed, 124 insertions(+), 22 deletions(-) create mode 100644 src/utils/pickSelectedId.test.ts create mode 100644 src/utils/pickSelectedId.ts diff --git a/src/App.tsx b/src/App.tsx index 5d5d7cc..fb526e5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -5,6 +5,7 @@ import { formatInvokeError, isExclusiveSessionError, getLastSpawnPermissionMode, + getSessionCard, getSessionDetail, interjectAgent, listManagedAgents, @@ -54,6 +55,7 @@ import { isAttachedManagedStatus, isLiveManagedStatus, } from "./utils/managedStatus"; +import { pickSelectedId } from "./utils/pickSelectedId"; import type { UserQuestionResolvePayload } from "./utils/permissionPayload"; import { joinUnderRoot } from "./utils/paths"; import { @@ -74,26 +76,10 @@ const FS_REFRESH_MIN_MS = 400; /** Slow safety net if FSEvents miss a write (rare). */ const SAFETY_POLL_MS = 90_000; -/** Single selection policy for staged list → managed load. */ -function pickSelectedId( - list: SessionCard[], - prev: string | null, - managed?: ManagedAgentInfo[], -): string | null { - // Keep the open pane. The roster page is not the full list, so "not in - // this page" is not "gone" — treating it as gone remounts from page 1. - if (prev) return prev; - if (managed?.length) { - const managedSid = managed.find((m) => m.sessionId)?.sessionId; - if (managedSid && list.some((s) => s.id === managedSid)) { - return managedSid; - } - } - const live = list.find((s) => s.isActive); - return live?.id ?? list[0]?.id ?? null; -} function App() { const [selectedId, setSelectedId] = useState(null); + const selectedIdRef = useRef(selectedId); + selectedIdRef.current = selectedId; const [detail, setDetail] = useState(null); const [tab, setTab] = useState("timeline"); const [detailLoading, setDetailLoading] = useState(false); @@ -152,13 +138,25 @@ function App() { }); const onRecentSessionsLoaded = useCallback( async (list: SessionCard[]) => { - setSelectedId((previous) => pickSelectedId(list, previous)); + const previous = selectedIdRef.current; + let prevOnDisk: boolean | undefined; + if (previous && !list.some((s) => s.id === previous)) { + try { + await getSessionCard(previous); + prevOnDisk = true; + } catch { + prevOnDisk = false; + } + } + setSelectedId((prev) => pickSelectedId(list, prev, { prevOnDisk })); try { const managed = await listManagedAgents(); for (const item of managed) { upsertManaged(item); } - setSelectedId((previous) => pickSelectedId(list, previous, managed)); + setSelectedId((prev) => + pickSelectedId(list, prev, { prevOnDisk, managed }), + ); } catch { /* managed agents are optional during startup */ } @@ -263,8 +261,6 @@ function App() { [planArmedSelected, effectivePermissionMode], ); - const selectedIdRef = useRef(selectedId); - selectedIdRef.current = selectedId; const detailReqSeq = useRef(0); const lastFsRefreshRef = useRef(0); /** Session id we intentionally focused (spawn); ignore auto-steal otherwise. */ diff --git a/src/utils/pickSelectedId.test.ts b/src/utils/pickSelectedId.test.ts new file mode 100644 index 0000000..d172d81 --- /dev/null +++ b/src/utils/pickSelectedId.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import type { ManagedAgentInfo, SessionCard } from "../types"; +import { pickSelectedId } from "./pickSelectedId"; + +function card(id: string, isActive = false): SessionCard { + return { + id, + cwd: "/tmp", + title: id, + numMessages: 0, + isActive, + status: "idle", + contextTokensUsed: 0, + contextWindowTokens: 0, + contextWindowUsage: 0, + totalTokens: 0, + tokenUsageIncomplete: false, + tokenUsageAvailable: false, + tokenUsagePending: false, + toolCallCount: 0, + turnCount: 0, + toolsUsed: [], + agentLinesAdded: 0, + agentLinesRemoved: 0, + agentFilesTouched: 0, + sessionDurationSeconds: 0, + errorCount: 0, + }; +} + +function managed(sessionId: string): ManagedAgentInfo { + return { + handleId: "h1", + sessionId, + cwd: "/tmp", + status: "ready", + permissionMode: "default", + alwaysApprove: false, + createdAt: "2026-01-01T00:00:00Z", + }; +} + +describe("pickSelectedId", () => { + const page = [card("a"), card("b"), card("c", true)]; + + it("keeps prev when it is on this page", () => { + expect(pickSelectedId(page, "b")).toBe("b"); + expect(pickSelectedId(page, "b", { prevOnDisk: false })).toBe("b"); + }); + + it("keeps prev when it is off this page but still on disk", () => { + expect(pickSelectedId(page, "off-page", { prevOnDisk: true })).toBe( + "off-page", + ); + }); + + it("drops prev when it is absent from disk", () => { + expect(pickSelectedId(page, "gone", { prevOnDisk: false })).toBe("c"); + }); + + it("drops off-page prev when disk presence was not confirmed", () => { + expect(pickSelectedId(page, "unknown")).toBe("c"); + }); + + it("falls back to managed, then live, then first card", () => { + const idle = [card("a"), card("b")]; + expect( + pickSelectedId(idle, null, { managed: [managed("b")] }), + ).toBe("b"); + expect(pickSelectedId(page, null)).toBe("c"); + expect(pickSelectedId(idle, null)).toBe("a"); + expect(pickSelectedId([], null)).toBe(null); + }); +}); diff --git a/src/utils/pickSelectedId.ts b/src/utils/pickSelectedId.ts new file mode 100644 index 0000000..e965746 --- /dev/null +++ b/src/utils/pickSelectedId.ts @@ -0,0 +1,32 @@ +import type { ManagedAgentInfo, SessionCard } from "../types"; + +export type PickSelectedIdOptions = { + managed?: ManagedAgentInfo[]; + /** + * When `prev` is missing from this roster page: true if the task still + * exists on disk, false if it is gone. Omit only when `prev` is in `list`. + */ + prevOnDisk?: boolean; +}; + +/** + * Single selection policy for a paged roster. + * Keep `prev` when it is on this page, or off-page but still on disk. + * Drop it when it is absent from disk so a deleted task cannot stuck-load. + */ +export function pickSelectedId( + list: SessionCard[], + prev: string | null, + options?: PickSelectedIdOptions, +): string | null { + if (prev && list.some((s) => s.id === prev)) return prev; + if (prev && options?.prevOnDisk) return prev; + if (options?.managed?.length) { + const managedSid = options.managed.find((m) => m.sessionId)?.sessionId; + if (managedSid && list.some((s) => s.id === managedSid)) { + return managedSid; + } + } + const live = list.find((s) => s.isActive); + return live?.id ?? list[0]?.id ?? null; +} From a3ddd464c522ff091e73e002f58ddd71e99d6e15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonada?= Date: Thu, 3 Sep 2026 16:37:10 -0400 Subject: [PATCH 07/10] fix: refresh disk-only open pane from updates.jsonl Skip getSessionDetail while ACP owns the tail (starting included). Unattached tasks still silent-refresh so disk writes update the timeline. --- src/App.tsx | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index fb526e5..95596e8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -182,6 +182,8 @@ function App() { const liveOwnsTail = managedForSession != null && isAttachedManagedStatus(managedForSession.status); + const liveOwnsTailRef = useRef(liveOwnsTail); + liveOwnsTailRef.current = liveOwnsTail; const timelineHistory = useTimelineHistory( selectedId, detail, @@ -298,10 +300,12 @@ function App() { if (now - lastFsRefreshRef.current < FS_REFRESH_MIN_MS) return; lastFsRefreshRef.current = now; void refreshList(); - // Roster only. Reloading the open pane from disk replaces recentUpdates - // with the first history page and paints as a session restart. + const id = selectedIdRef.current; + // ACP owns the tail (starting included). Disk-only panes still need + // silent getSessionDetail so updates.jsonl refreshes the open timeline. + if (id && !liveOwnsTailRef.current) void refreshDetail(id, true); setGitRefreshKey((n) => n + 1); - }, [refreshList]); + }, [refreshList, refreshDetail]); const liveManagedCount = useMemo( () => @@ -354,7 +358,13 @@ function App() { // the appended bytes for this card. void refreshCard(payload.sessionId); } - // Do not getSessionDetail the open pane on updates.jsonl. + if ( + selected && + !liveOwnsTailRef.current && + (!payload.sessionId || payload.sessionId === selected) + ) { + void refreshDetail(selected, true); + } }).then((fn) => { if (cancelled) fn(); else unlisten = fn; @@ -363,7 +373,7 @@ function App() { cancelled = true; unlisten?.(); }; - }, [refreshList, refreshCard]); + }, [refreshList, refreshCard, refreshDetail]); // These are advertised ACP capabilities, so consume their notifications // and invalidate the workspace immediately. @@ -387,6 +397,9 @@ function App() { return; } setGitRefreshKey((n) => n + 1); + if (selected && !liveOwnsTailRef.current) { + void refreshDetail(selected, true); + } }).then((fn) => { if (cancelled) fn(); else unlisten = fn; @@ -395,7 +408,7 @@ function App() { cancelled = true; unlisten?.(); }; - }, []); + }, [refreshDetail]); // Focus / tab visible → catch anything the watcher missed (debounced). useEffect(() => { From 2c2ffb2a9cb942983a2a1e13adc07ee3051da836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonada?= Date: Thu, 3 Sep 2026 16:37:20 -0400 Subject: [PATCH 08/10] fix(acp): reuse Starting handle instead of re-attach on send ensureAttached keeps the in-memory Starting agent. Send during reconnect does not call attach_agent with ignore_pid = None, which treated the reconnect child as a foreign Grok pid. --- src/App.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/App.tsx b/src/App.tsx index 95596e8..c6a6136 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -520,7 +520,7 @@ function App() { sessionId: string, ): Promise { const existing = managedList.find( - (m) => m.sessionId === sessionId && isLiveManagedStatus(m.status), + (m) => m.sessionId === sessionId && isAttachedManagedStatus(m.status), ); if (existing) return existing; @@ -748,6 +748,12 @@ function App() { sessionIdForPlan = info.sessionId ?? sessionId; } + // Reconnect / first attach still Starting. Do not prompt (agent not + // ready) and do not attach again with ignore_pid = None. + if (!isLiveManagedStatus(liveAgent?.status)) { + return; + } + if (liveAgent) { try { liveAgent = await applySessionModel(liveAgent); From 24540680cef1e8b9d5ac7cc92486421a89b6da4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonada?= Date: Thu, 3 Sep 2026 16:37:53 -0400 Subject: [PATCH 09/10] fix: typed exclusive-session error and composer refusal attach_agent rejects with { code: session_open_elsewhere, pid, message }. Refused send keeps the draft. The fused turn strip shows red Not sent with a muted reason (Already open in Grok Build / Still connecting), a static mark, and no global banner. --- src-tauri/src/agent_manager.rs | 9 +-- src-tauri/src/agent_manager/reconnect.rs | 4 +- src-tauri/src/lib.rs | 4 +- src-tauri/src/sessions.rs | 75 ++++++++++++++++++++++-- src/App.tsx | 25 ++++---- src/api.test.ts | 42 +++++++++++++ src/api.ts | 15 ++++- src/components/PromptBar.tsx | 25 ++++++-- src/components/SessionDetail.tsx | 13 +++- src/components/TurnStatusBar.tsx | 19 +++--- src/styles/turn-status.css | 13 ++++ src/utils/turnActivity.test.ts | 11 ++++ src/utils/turnActivity.ts | 26 +++++++- 13 files changed, 238 insertions(+), 43 deletions(-) create mode 100644 src/api.test.ts diff --git a/src-tauri/src/agent_manager.rs b/src-tauri/src/agent_manager.rs index 51a74aa..5ce9635 100644 --- a/src-tauri/src/agent_manager.rs +++ b/src-tauri/src/agent_manager.rs @@ -1332,14 +1332,14 @@ impl AgentManager { Ok(info) } - pub fn attach(&self, req: AttachRequest) -> Result { + pub fn attach(&self, req: AttachRequest) -> Result { let cwd = req.cwd.trim().to_string(); let session_id = req.session_id.trim().to_string(); if session_id.is_empty() { return Err("session_id required".into()); } if cwd.is_empty() || !Path::new(&cwd).is_dir() { - return Err(format!("Invalid working directory: {cwd}")); + return Err(format!("Invalid working directory: {cwd}").into()); } let existing = { @@ -1364,7 +1364,7 @@ impl AgentManager { { let mut attaching = self.inner.attaching_sessions.lock(); if !attaching.insert(session_id.clone()) { - return Err(format!("session {session_id} is already attaching")); + return Err(format!("session {session_id} is already attaching").into()); } } @@ -1415,7 +1415,8 @@ impl AgentManager { &handle_id, &mut info, e.user_message(), - )); + ) + .into()); } }; diff --git a/src-tauri/src/agent_manager/reconnect.rs b/src-tauri/src/agent_manager/reconnect.rs index 68d7f57..3011474 100644 --- a/src-tauri/src/agent_manager/reconnect.rs +++ b/src-tauri/src/agent_manager/reconnect.rs @@ -310,10 +310,10 @@ fn run(inner: Arc, handle_id: String, failed_generation: u64) { } if let Some(snapshot) = reconnect_snapshot(&inner, &handle_id, failed_generation) { - if let Some(msg) = + if let Some(err) = crate::sessions::session_open_elsewhere_error(&snapshot.session_id, killed_pid) { - fail_reconnect(&inner, &handle_id, failed_generation, &msg); + fail_reconnect(&inner, &handle_id, failed_generation, &err.message); return; } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7a533f6..9b789da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -201,13 +201,13 @@ async fn spawn_agent( async fn attach_agent( manager: tauri::State<'_, AgentManager>, request: AttachRequest, -) -> Result { +) -> Result { // session/load can take seconds; run off-thread so the webview keeps painting // (breathing light on the task card, etc.). let manager = manager.inner().clone(); tauri::async_runtime::spawn_blocking(move || manager.attach(request)) .await - .map_err(|e| format!("attach task failed: {e}"))? + .map_err(|e| sessions::CommandError::other(format!("attach task failed: {e}")))? } #[tauri::command] diff --git a/src-tauri/src/sessions.rs b/src-tauri/src/sessions.rs index 439a460..62fad35 100644 --- a/src-tauri/src/sessions.rs +++ b/src-tauri/src/sessions.rs @@ -121,12 +121,64 @@ pub fn foreign_active_pid(session_id: &str, ignore_pid: Option) -> Option) -> Option { - foreign_active_pid(session_id, ignore_pid).map(|pid| { - format!( - "session is already open in another Grok process (pid {pid}); close that process or pick a different task" - ) - }) +/// IPC reject payload. Serialized as `{ code, message, pid? }` so the +/// frontend can match a stable code instead of grepping the message. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CommandError { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub pid: Option, +} + +impl CommandError { + pub const SESSION_OPEN_ELSEWHERE: &'static str = "session_open_elsewhere"; + + pub fn other(message: impl Into) -> Self { + Self { + code: "error".into(), + message: message.into(), + pid: None, + } + } + + pub fn session_open_elsewhere(pid: u32) -> Self { + Self { + code: Self::SESSION_OPEN_ELSEWHERE.into(), + pid: Some(pid), + message: format!( + "session is already open in another Grok process (pid {pid}); close that process or pick a different task" + ), + } + } +} + +impl std::fmt::Display for CommandError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message) + } +} + +impl std::error::Error for CommandError {} + +impl From for CommandError { + fn from(message: String) -> Self { + Self::other(message) + } +} + +impl From<&str> for CommandError { + fn from(message: &str) -> Self { + Self::other(message) + } +} + +pub fn session_open_elsewhere_error( + session_id: &str, + ignore_pid: Option, +) -> Option { + foreign_active_pid(session_id, ignore_pid).map(CommandError::session_open_elsewhere) } /// Poll until `pid` is gone or `timeout` elapses. Used after killing an ACP @@ -1292,6 +1344,17 @@ mod tests { assert!(wait_until_dead(0, Duration::from_millis(1))); } + #[test] + fn exclusive_session_error_uses_stable_code() { + let err = CommandError::session_open_elsewhere(4321); + assert_eq!(err.code, CommandError::SESSION_OPEN_ELSEWHERE); + assert_eq!(err.pid, Some(4321)); + let json = serde_json::to_value(&err).expect("serialize"); + assert_eq!(json["code"], "session_open_elsewhere"); + assert_eq!(json["pid"], 4321); + assert!(json["message"].as_str().unwrap().contains("4321")); + } + #[test] fn token_usage_series_returns_window() { let s = token_usage_series(7).expect("series"); diff --git a/src/App.tsx b/src/App.tsx index c6a6136..ccef979 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -22,6 +22,7 @@ import { stopAgent, } from "./api"; import { MacosTitlebarBrand } from "./components/MacosTitlebarBrand"; +import type { SendResult } from "./components/PromptBar"; import { NewTaskModal } from "./components/NewTaskModal"; import { SessionDetailView } from "./components/SessionDetail"; import { SessionList } from "./components/SessionList"; @@ -55,6 +56,7 @@ import { isAttachedManagedStatus, isLiveManagedStatus, } from "./utils/managedStatus"; +import { SEND_REFUSAL_HINT } from "./utils/turnActivity"; import { pickSelectedId } from "./utils/pickSelectedId"; import type { UserQuestionResolvePayload } from "./utils/permissionPayload"; import { joinUnderRoot } from "./utils/paths"; @@ -691,9 +693,9 @@ function App() { } } - async function handleSend(text: string) { + async function handleSend(text: string): Promise { const trimmed = text.trim(); - if (!trimmed) return; + if (!trimmed) return { accepted: false }; setControlBusy(true); setError(null); setTab("timeline"); @@ -723,7 +725,7 @@ function App() { if (result.refreshWeekUsage) { void refreshWeekUsage({ force: true }); } - return; + return { accepted: true }; } } @@ -736,12 +738,12 @@ function App() { const sessionId = selectedId ?? sessions[0]?.id ?? null; if (!sessionId) { setError("Select a task first, or create one with New."); - return; + return { accepted: false }; } const info = await ensureAttached(sessionId); if (!info) { - // Exclusive-session attach is refused; Open in Grok Build is the cue. - return; + // Banner stays off; composer keeps the draft and shows the card cue. + return { accepted: false, hint: SEND_REFUSAL_HINT.openElsewhere }; } liveAgent = info; handleId = info.handleId; @@ -751,7 +753,7 @@ function App() { // Reconnect / first attach still Starting. Do not prompt (agent not // ready) and do not attach again with ignore_pid = None. if (!isLiveManagedStatus(liveAgent?.status)) { - return; + return { accepted: false, hint: SEND_REFUSAL_HINT.connecting }; } if (liveAgent) { @@ -786,10 +788,13 @@ function App() { }); } setPinTimelineBottomSeq((n) => n + 1); + return { accepted: true }; } catch (e) { - if (!isExclusiveSessionError(e)) { - setError(formatInvokeError(e)); + if (isExclusiveSessionError(e)) { + return { accepted: false, hint: SEND_REFUSAL_HINT.openElsewhere }; } + setError(formatInvokeError(e)); + return { accepted: false }; } finally { setControlBusy(false); } @@ -1028,7 +1033,7 @@ function App() { controlBusy={controlBusy} sessionMode={effectiveSessionMode} onSessionModeChange={(m) => void handleSessionModeChange(m)} - onSendPrompt={(t) => void handleSend(t)} + onSendPrompt={handleSend} promptQueue={promptQueue} onResolvePermission={(item, opt, comments, payload) => void handleResolvePermission(item, opt, comments, payload) diff --git a/src/api.test.ts b/src/api.test.ts new file mode 100644 index 0000000..d8a423d --- /dev/null +++ b/src/api.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { formatInvokeError, isExclusiveSessionError } from "./api"; + +describe("isExclusiveSessionError", () => { + it("matches a typed attach reject payload", () => { + expect( + isExclusiveSessionError({ + code: "session_open_elsewhere", + pid: 4321, + message: "session is already open in another Grok process (pid 4321)", + }), + ).toBe(true); + }); + + it("matches a nested code field", () => { + expect( + isExclusiveSessionError({ + error: { code: "session_open_elsewhere", message: "busy" }, + }), + ).toBe(true); + }); + + it("does not match a message-only reject", () => { + expect( + isExclusiveSessionError( + "session is already open in another Grok process (pid 1)", + ), + ).toBe(false); + expect(isExclusiveSessionError({ message: "boom" })).toBe(false); + }); +}); + +describe("formatInvokeError", () => { + it("prefers message on a typed payload", () => { + expect( + formatInvokeError({ + code: "session_open_elsewhere", + message: "session is already open in another Grok process (pid 9)", + }), + ).toBe("session is already open in another Grok process (pid 9)"); + }); +}); diff --git a/src/api.ts b/src/api.ts index 2b2f086..7fde3ae 100644 --- a/src/api.ts +++ b/src/api.ts @@ -134,9 +134,22 @@ export function formatInvokeError(error: unknown): string { return String(error); } +const EXCLUSIVE_SESSION_CODE = "session_open_elsewhere"; + +function invokeErrorCode(error: unknown): string | undefined { + if (!error || typeof error !== "object") return undefined; + const rec = error as { code?: unknown; error?: unknown }; + if (typeof rec.code === "string" && rec.code.trim()) return rec.code; + if (rec.error && typeof rec.error === "object") { + const inner = rec.error as { code?: unknown }; + if (typeof inner.code === "string" && inner.code.trim()) return inner.code; + } + return undefined; +} + /** Attach refused because another Grok pid owns the session. */ export function isExclusiveSessionError(error: unknown): boolean { - return /already open in another Grok process/i.test(formatInvokeError(error)); + return invokeErrorCode(error) === EXCLUSIVE_SESSION_CODE; } export async function promptAgent( diff --git a/src/components/PromptBar.tsx b/src/components/PromptBar.tsx index 0af770b..94b3462 100644 --- a/src/components/PromptBar.tsx +++ b/src/components/PromptBar.tsx @@ -20,6 +20,9 @@ import { selectedReasoningEffort, } from "../utils/reasoningEffort"; +/** Composer send outcome. `accepted: false` keeps the draft in the box. */ +export type SendResult = { accepted: boolean; hint?: string }; + interface Props { managed: ManagedAgentInfo | null; busy: boolean; @@ -29,7 +32,9 @@ interface Props { */ sessionMode: SessionMode; onSessionModeChange: (mode: SessionMode) => void; - onSend: (text: string) => void; + onSend: (text: string) => void | Promise; + /** Send was refused; parent paints the Disconnected-style turn strip. */ + onRefusal?: (hint: string | null) => void; /** Agent-advertised slash commands (ACP available_commands_update). */ availableCommands?: AvailableCommand[]; /** @@ -62,6 +67,7 @@ export function PromptBar({ sessionMode, onSessionModeChange, onSend, + onRefusal, availableCommands = [], timelineItems = [], sessionId = null, @@ -116,7 +122,8 @@ export function PromptBar({ const onSessionReset = useCallback(() => { setMenuOpen(false); suppressMenuRef.current = false; - }, []); + onRefusal?.(null); + }, [onRefusal]); // Session switch: hook owns history + composer text; we only reset slash chrome. const history = usePromptHistoryBrowse({ @@ -173,18 +180,23 @@ export function PromptBar({ focusEnd(next); } - function sendIfReady() { + async function sendIfReady() { const trimmed = text.trim(); if (!trimmed || stopping || busy) return; // First non-local message auto-connects ACP in App.handleSend. const payload = applySessionModeToPrompt(sessionMode, trimmed); + setMenuOpen(false); + suppressMenuRef.current = false; + const result = await Promise.resolve(onSend(payload)); + if (result && result.accepted === false) { + onRefusal?.(result.hint ?? ""); + return; + } // Record wire text (matches timeline user cards after mode prefix). history.recordSent(payload); history.detach(); - onSend(payload); + onRefusal?.(null); setText(""); - setMenuOpen(false); - suppressMenuRef.current = false; } /** True when composer already holds the fully-applied form of `cmd`. */ @@ -270,6 +282,7 @@ export function PromptBar({ // Typing while browsing detaches (keep populated text). if (history.active) history.detach(); suppressMenuRef.current = false; + onRefusal?.(null); setText(next); const q = parseSlashQuery(next); if (q && !q.hasArgs) setMenuOpen(true); diff --git a/src/components/SessionDetail.tsx b/src/components/SessionDetail.tsx index 57aa7f2..00f1b40 100644 --- a/src/components/SessionDetail.tsx +++ b/src/components/SessionDetail.tsx @@ -1,7 +1,9 @@ import { + useEffect, useLayoutEffect, useMemo, useRef, + useState, type CSSProperties, } from "react"; import type { @@ -28,7 +30,7 @@ import type { ResolvePermissionFn } from "../utils/permissionPayload"; import type { PromptQueueController } from "../hooks/usePromptQueueController"; import { DiffPanel } from "./DiffPanel"; import { PermissionGate } from "./PermissionGate"; -import { PromptBar } from "./PromptBar"; +import { PromptBar, type SendResult } from "./PromptBar"; import { PromptQueue } from "./PromptQueue"; import { TimelinePanel } from "./TimelinePanel"; import { TurnStatusBar } from "./TurnStatusBar"; @@ -49,7 +51,7 @@ interface Props { controlBusy: boolean; sessionMode: SessionMode; onSessionModeChange: (mode: SessionMode) => void; - onSendPrompt: (text: string) => void; + onSendPrompt: (text: string) => void | Promise; promptQueue: PromptQueueController; onResolvePermission: ResolvePermissionFn; /** Stop the live agent for this task (confirm handled by parent). */ @@ -102,6 +104,11 @@ export function SessionDetailView({ reasoningEffort = null, }: Props) { const tabBodyRef = useRef(null); + const [sendRefusalHint, setSendRefusalHint] = useState(null); + + useEffect(() => { + setSendRefusalHint(null); + }, [detail?.card.id]); // Timeline pins to bottom; Diff / Raw expect top. Shared .tab-body // scroll container otherwise keeps Timeline scrollTop and hides content. @@ -258,6 +265,7 @@ export function SessionDetailView({ managed={managed} timelineItems={timelineItems} sessionIsActive={Boolean(card?.isActive)} + refusalHint={sendRefusalHint} /> - resolveTurnActivity(managed, timelineItems, { - sessionIsActive, - }), - [managed, timelineItems, sessionIsActive], - ); + const activity = useMemo(() => { + if (refusalHint != null) return sendRefusalActivity(refusalHint); + return resolveTurnActivity(managed, timelineItems, { + sessionIsActive, + }); + }, [managed, timelineItems, sessionIsActive, refusalHint]); const [now, setNow] = useState(() => Date.now()); const [phaseStartedAt, setPhaseStartedAt] = useState(() => Date.now()); @@ -165,7 +168,7 @@ export function TurnStatusBar({ ); } -/** Minimal modern mark: thin arc spinner or soft pulse (wait). */ +/** Busy spin, wait pulse, cancel spin, or a static still dot. */ function ActivityIndicator({ mode }: { mode: TurnIndicatorMode }) { return ( diff --git a/src/styles/turn-status.css b/src/styles/turn-status.css index 93c033a..9fbe170 100644 --- a/src/styles/turn-status.css +++ b/src/styles/turn-status.css @@ -133,6 +133,19 @@ animation-duration: 0.55s; } +/* Terminal refusal: no motion. A spinner here reads as in-flight work. */ +.turn-mark.mode-still .turn-mark-ring { + display: none; + animation: none; +} + +.turn-mark.mode-still .turn-mark-dot { + width: 6px; + height: 6px; + animation: none; + opacity: 1; +} + .turn-status.tone-muted .turn-status-label-main, .turn-status.tone-wait .turn-status-label-main { color: var(--text-muted); diff --git a/src/utils/turnActivity.test.ts b/src/utils/turnActivity.test.ts index a13c5c7..be6cafc 100644 --- a/src/utils/turnActivity.test.ts +++ b/src/utils/turnActivity.test.ts @@ -5,6 +5,8 @@ import { isManagedTurnActive, resolveTurnActivity, resolveTurnStartedAt, + SEND_REFUSAL_HINT, + sendRefusalActivity, } from "./turnActivity"; function managed( @@ -152,6 +154,15 @@ describe("resolveTurnActivity", () => { expect(act?.hint).toMatch(/connect/i); }); + it("paints a send refusal like Disconnected (danger, on the composer)", () => { + const act = sendRefusalActivity(SEND_REFUSAL_HINT.openElsewhere); + expect(act.label).toBe("Not sent"); + expect(act.tone).toBe("danger"); + expect(act.indicator).toBe("still"); + expect(act.hint).toBe("Already open in Grok Build"); + expect(act.showPhaseTimer).toBe(false); + }); + it("does not call a PinkCode ACP error Open in Grok Build", () => { const act = resolveTurnActivity(managed("error"), [], { sessionIsActive: true, diff --git a/src/utils/turnActivity.ts b/src/utils/turnActivity.ts index 0853079..a223ebc 100644 --- a/src/utils/turnActivity.ts +++ b/src/utils/turnActivity.ts @@ -46,8 +46,8 @@ export type TurnActivityKind = export type TurnActivitySource = "managed" | "external"; -/** Indicator motion: busy spin, calm pulse, or snappy cancel. */ -export type TurnIndicatorMode = "spin" | "wait" | "danger"; +/** Indicator motion: busy spin, calm pulse, snappy cancel, or static. */ +export type TurnIndicatorMode = "spin" | "wait" | "danger" | "still"; export interface ResolvedTurnActivity { kind: TurnActivityKind; @@ -153,6 +153,28 @@ function activity( }; } +/** Muted reason under red `Not sent`. Same shape as Disconnected + lastError. */ +export const SEND_REFUSAL_HINT = { + openElsewhere: "Already open in Grok Build", + connecting: "Still connecting", +} as const; + +/** Composer refused a send. Same chrome as ACP Disconnected (red, on the box). */ +export function sendRefusalActivity(hint?: string | null): ResolvedTurnActivity { + const trimmed = hint?.trim(); + return activity({ + kind: "waiting", + label: "Not sent", + tone: "danger", + indicator: "still", + phaseKey: "send-refusal", + source: "managed", + hint: trimmed || undefined, + showPhaseTimer: false, + showTurnTimer: false, + }); +} + function waiting( source: TurnActivitySource, phaseKey: string, From 60f4baae5add9ee5c552dd45f8e911d5b0eebe0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20Bonada?= Date: Thu, 3 Sep 2026 16:42:08 -0400 Subject: [PATCH 10/10] test(acp): cover reconnect burst window Extract last-activate burst counting so three deaths within 5s give up, and a stale activate resets. The pid-zero wait_until_dead test is not this path. --- src-tauri/src/agent_manager.rs | 75 ++++++++++++++++++++++++++++------ 1 file changed, 62 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/agent_manager.rs b/src-tauri/src/agent_manager.rs index 5ce9635..57fc576 100644 --- a/src-tauri/src/agent_manager.rs +++ b/src-tauri/src/agent_manager.rs @@ -55,6 +55,29 @@ struct PendingGate { } const INITIAL_CONNECTION_GENERATION: u64 = 1; +const RECONNECT_UNSTABLE: Duration = Duration::from_secs(5); +const RECONNECT_BURST_LIMIT: u32 = 3; + +/// Count deaths inside the last-activate window. A stale or missing activate +/// resets the burst so a later transport loss can reconnect again. +pub(crate) fn next_reconnect_burst( + last_activate: Option, + now: Instant, + burst: u32, +) -> u32 { + let unstable = last_activate + .map(|t| now.saturating_duration_since(t) < RECONNECT_UNSTABLE) + .unwrap_or(false); + if unstable { + burst.saturating_add(1) + } else { + 0 + } +} + +pub(crate) fn can_reconnect_after_burst(has_session: bool, burst: u32) -> bool { + has_session && burst < RECONNECT_BURST_LIMIT +} fn finish_prompt( in_flight: &mut HashSet, @@ -920,18 +943,15 @@ impl AgentManager { ) { return; } - const UNSTABLE: Duration = Duration::from_secs(5); - let unstable = agent - .last_activate - .map(|t| t.elapsed() < UNSTABLE) - .unwrap_or(false); - if unstable { - agent.reconnect_burst = agent.reconnect_burst.saturating_add(1); - } else { - agent.reconnect_burst = 0; - } - let can_reconnect = - agent.info.session_id.is_some() && agent.reconnect_burst < 3; + agent.reconnect_burst = next_reconnect_burst( + agent.last_activate, + Instant::now(), + agent.reconnect_burst, + ); + let can_reconnect = can_reconnect_after_burst( + agent.info.session_id.is_some(), + agent.reconnect_burst, + ); agent.info.status = if can_reconnect { ManagedStatus::Starting } else { @@ -1702,9 +1722,38 @@ impl AgentManager { #[cfg(test)] mod tests { - use super::{finish_prompt, finish_prompt_status, terminal_prompt_id, ManagedStatus}; + use super::{ + can_reconnect_after_burst, finish_prompt, finish_prompt_status, next_reconnect_burst, + terminal_prompt_id, ManagedStatus, + }; use serde_json::json; use std::collections::HashSet; + use std::time::{Duration, Instant}; + + #[test] + fn reconnect_burst_increments_when_last_activate_is_recent() { + let now = Instant::now(); + let last = Some(now.checked_sub(Duration::from_millis(100)).unwrap()); + assert_eq!(next_reconnect_burst(last, now, 0), 1); + assert_eq!(next_reconnect_burst(last, now, 1), 2); + assert_eq!(next_reconnect_burst(last, now, 2), 3); + } + + #[test] + fn reconnect_burst_resets_when_last_activate_is_stale() { + let now = Instant::now(); + let last = Some(now.checked_sub(Duration::from_secs(6)).unwrap()); + assert_eq!(next_reconnect_burst(last, now, 2), 0); + assert_eq!(next_reconnect_burst(None, now, 2), 0); + } + + #[test] + fn reconnect_gives_up_after_three_unstable_deaths() { + assert!(can_reconnect_after_burst(true, 0)); + assert!(can_reconnect_after_burst(true, 2)); + assert!(!can_reconnect_after_burst(true, 3)); + assert!(!can_reconnect_after_burst(false, 0)); + } #[test] fn queued_prompt_completion_does_not_replace_current_turn() {