Synced from monorepo

Synced from monorepo

Changes:
- Cache growing transcripts on the messages backend
- Tell the model when a wait was clamped instead of re-inviting it
- Stop the stationarity nudge from claiming results are identical
- Deliver the stationarity nudge after the tool result
- Run auth provider commands through the platform shell (fixes Windows)
- Keep monitor tool stdout short and prescriptive
- Use UUIDs for analytics event insert IDs
- Stop crashing at startup when the host runs out of threads
- Delete the current session from within the session
- Add project forking-settings toggle (backend and deploy-time control)
- Reap a session’s bash and background commands when it closes
- Reap a session’s hook child processes when it closes
- Track coding-data consent decisions
- Fail open the access gate to stop false CLI paywalls
- Ship Agent Dashboard user guide
- Enable doom-loop recovery by default
- Kill agent children and the idle inhibitor when the parent process dies
- Fix multi-process credential wipe and orphaned session log writers

Source-Revision: 6372e41d828b8a6ee82c29e01a69e27ec895cca9
This commit is contained in:
grokkybara[bot] 2026-07-29 17:17:54 +00:00
commit 500129c714
89 changed files with 3841 additions and 771 deletions

View file

@ -85,6 +85,7 @@ pub(crate) fn handle_ask_user_question(
LocalQuestionKind::AgentTypeMismatch { .. } => "model switch",
LocalQuestionKind::ProjectSelect { .. } => "project select",
LocalQuestionKind::DoctorFix { .. } => "/doctor fix",
LocalQuestionKind::DeleteCurrentSession => "/delete",
};
let message = if matches!(kind, LocalQuestionKind::DoctorFix { .. }) {
"/doctor fix was cancelled because another question opened.".to_owned()

View file

@ -56,6 +56,11 @@ pub enum Action {
ExitSession,
/// Exit session without double-press confirmation (e.g., from command palette).
ExitSessionConfirmed,
/// `/delete`: confirm, then delete history and return home.
DeleteCurrentSession,
DeleteCurrentSessionAnswered {
confirmed: bool,
},
/// Open grok.com in the browser for SuperGrok subscription upsell.
OpenSupergrokUrl,
/// Re-check subscription status via the shell's `x.ai/auth/check_subscription`.
@ -1368,6 +1373,14 @@ pub struct DoctorFixTarget {
pub session_binding_epoch: u32,
pub cwd: std::path::PathBuf,
}
/// Aftermath of a successful session delete.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AfterSessionDelete {
/// Picker delete — stay put.
Stay,
/// `/delete` — return to welcome.
Welcome,
}
#[derive(Debug)]
pub enum Effect {
/// Create a new ACP session.
@ -2012,6 +2025,7 @@ pub enum Effect {
source: String,
session_id: String,
cwd: String,
after: AfterSessionDelete,
},
/// Deep-search sessions by content (FTS via ACP).
DeepSearchSessions { query: String, seq: u64 },
@ -2587,6 +2601,7 @@ pub enum TaskResult {
DeleteSessionComplete {
source: String,
session_id: String,
after: AfterSessionDelete,
},
/// Session delete failed.
DeleteSessionFailed {

View file

@ -1733,6 +1733,11 @@ fn translate_local_submit(
InputOutcome::Action(Action::DoctorFixCancelled(target))
}
}
LocalQuestionKind::DeleteCurrentSession => {
InputOutcome::Action(Action::DeleteCurrentSessionAnswered {
confirmed: *idx == 0,
})
}
LocalQuestionKind::ProjectSelect { .. } => unreachable!(),
}
}

View file

@ -448,7 +448,9 @@ impl AgentView {
{
let history = self.combined_prompt_history();
let current_text = self.prompt.text().to_string();
if !history.is_empty() {
// Without a matcher thread the panel can never populate, and filling
// the composer would only be undone by the next Down/Enter.
if !history.is_empty() && self.prompt.history_search.is_available() {
self.prompt
.history_search
.activate_browse(&history, &current_text);

View file

@ -265,7 +265,7 @@ pub enum TickDemand {
pub const SLOW_TICK_INTERVAL: Duration = Duration::from_millis(83);
/// Welcome toast lifetime (wall clock, so the duration holds whether the
/// event loop is ticking Slow or Fast).
const WELCOME_TOAST_DURATION: Duration = Duration::from_secs(4);
const WELCOME_TOAST_DURATION: Duration = Duration::from_secs(2);
/// Which prompt box in-flight voice dictation appends its finalized text to.
/// Captured when recording **starts** so a trailing STT final still lands where
/// the user was dictating, even if they navigate away — or toggle a dashboard
@ -1197,8 +1197,16 @@ fn privacy_banner_reshow_elapsed(acked_at: &str, reshow_days: Option<u64>) -> bo
};
chrono::Utc::now() >= next
}
/// Bottom-right toast overlay on the welcome screen (mirrors agent toast style).
fn paint_welcome_toast(buf: &mut ratatui::buffer::Buffer, area: ratatui::layout::Rect, msg: &str) {
/// Welcome-screen toast overlay (mirrors agent toast style).
///
/// Prefer one row above the prompt, right-aligned to it. Fall back to
/// the view bottom-right when no prompt rect is available (login / gate).
fn paint_welcome_toast(
buf: &mut ratatui::buffer::Buffer,
area: ratatui::layout::Rect,
msg: &str,
prompt_rect: Option<ratatui::layout::Rect>,
) {
let theme = crate::theme::Theme::current();
let max_msg = (area.width as usize).saturating_sub(4);
if max_msg == 0 || area.height == 0 {
@ -1211,8 +1219,16 @@ fn paint_welcome_toast(buf: &mut ratatui::buffer::Buffer, area: ratatui::layout:
format!(" {}", truncated.trim_end())
};
let w = toast.chars().count() as u16;
let x = area.right().saturating_sub(w + 1);
let y = area.bottom().saturating_sub(1);
let (x, y) = if let Some(prompt) = prompt_rect.filter(|r| r.width > 0 && r.y > area.y) {
let max_x = area.right().saturating_sub(w).max(area.x);
let x = prompt.right().saturating_sub(w + 1).clamp(area.x, max_x);
(x, prompt.y.saturating_sub(1))
} else {
(
area.right().saturating_sub(w + 1),
area.bottom().saturating_sub(1),
)
};
for (i, ch) in toast.chars().enumerate() {
if let Some(cell) = buf.cell_mut((x + i as u16, y)) {
cell.set_char(ch);
@ -1964,7 +1980,7 @@ impl AppView {
///
/// From the dashboard, toasts route into the dispatch input's inline
/// error slot. From an agent view the existing per-agent toast machinery
/// fires. On welcome, a bottom-right overlay for
/// fires. On welcome, an overlay above the prompt for
/// [`WELCOME_TOAST_DURATION`].
pub fn show_toast(&mut self, msg: &str) {
match self.active_view {
@ -4373,7 +4389,12 @@ impl AppView {
self.welcome_privacy_banner_policy_rect = result.privacy_banner_policy_rect;
self.welcome_changelog_cta_rect = result.changelog_cta_rect;
if let Some((ref msg, _)) = self.welcome_toast {
paint_welcome_toast(f.buffer_mut(), view_area, msg);
paint_welcome_toast(
f.buffer_mut(),
view_area,
msg,
self.welcome_prompt_rect,
);
}
self.welcome_announcement.truncated = result.announcement_truncated;
self.welcome_announcement.rect = result.announcement_rect;

View file

@ -60,9 +60,10 @@ use super::session::fork::{
dispatch_startup_fork_session,
};
use super::session::lifecycle::{
clear_startup_actions, dispatch_agent_type_mismatch_answered, dispatch_exit_session,
dispatch_new_session, dispatch_new_session_inner, dispatch_new_session_with_id,
dispatch_new_worktree_session, dispatch_trust_folder, open_new_session_question,
clear_startup_actions, dispatch_agent_type_mismatch_answered,
dispatch_delete_current_session_answered, dispatch_exit_session, dispatch_new_session,
dispatch_new_session_inner, dispatch_new_session_with_id, dispatch_new_worktree_session,
dispatch_trust_folder, open_delete_current_session_question, open_new_session_question,
};
use super::session::load::{
dispatch_cycle_session_source_filter, dispatch_load_session, dispatch_pick_content_session,
@ -194,6 +195,10 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::NewSession => dispatch_new_session(app),
Action::ChooseNewSessionMode => open_new_session_question(app),
Action::ExitSession | Action::ExitSessionConfirmed => dispatch_exit_session(app),
Action::DeleteCurrentSession => open_delete_current_session_question(app),
Action::DeleteCurrentSessionAnswered { confirmed } => {
dispatch_delete_current_session_answered(app, confirmed)
}
Action::NewWorktreeSession {
load_session_id,
label,
@ -949,7 +954,11 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
Action::SaveRememberNoteFromModal => dispatch_save_remember_note_from_modal(app),
Action::SendBtw(question) => dispatch_send_btw(app, question),
Action::SendRecap { auto } => dispatch_send_recap(app, auto),
Action::SetCodingDataSharing { opted_in } => set_coding_data_sharing(app, opted_in),
Action::SetCodingDataSharing { opted_in } => set_coding_data_sharing(
app,
opted_in,
xai_grok_telemetry::events::CodingDataConsentSource::Settings,
),
Action::ToggleYolo => dispatch_toggle_yolo(app),
Action::ToggleMultiline => dispatch_toggle_multiline(app),
Action::ToggleCompactMode => dispatch_toggle_compact_mode(app),
@ -1119,6 +1128,7 @@ pub(crate) fn dispatch(action: Action, app: &mut AppView) -> Vec<Effect> {
source,
session_id,
cwd,
after: crate::app::actions::AfterSessionDelete::Stay,
}]
}
Action::Fork(args) => dispatch_fork(app, args),

View file

@ -394,6 +394,108 @@ pub(in crate::app::dispatch) fn dispatch_exit_session(app: &mut AppView) -> Vec<
app.exit_session_pending = None;
effects
}
/// Confirm deleting the parent session (not a subagent view).
pub(in crate::app::dispatch) fn open_delete_current_session_question(
app: &mut AppView,
) -> Vec<Effect> {
use crate::views::question_view::{LocalQuestionKind, QuestionViewState};
use xai_grok_tools::implementations::grok_build::ask_user_question::{
Question, QuestionOption,
};
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some(agent) = app.agents.get_mut(&id) else {
return vec![];
};
if agent.session.session_id.is_none() {
app.show_toast("No active session to delete");
return vec![];
}
if agent.question_view.is_some() {
app.show_toast("Finish answering the current question first");
return vec![];
}
let question = Question {
question: "Delete this session permanently?".into(),
id: None,
options: vec![
QuestionOption {
label: "Delete".into(),
description: "Remove history and return home".into(),
preview: None,
id: None,
},
QuestionOption {
label: "Cancel".into(),
description: "Keep the session".into(),
preview: None,
id: None,
},
],
multi_select: Some(false),
};
let stashed = agent.prompt.stash();
agent.question_view = Some(
QuestionViewState::new(
format!("delete-session-{}", uuid::Uuid::new_v4()),
vec![question],
stashed,
)
.with_local_kind(LocalQuestionKind::DeleteCurrentSession)
.with_no_freeform(),
);
agent.prompt.set_text("");
vec![]
}
pub(in crate::app::dispatch) fn dispatch_delete_current_session_answered(
app: &mut AppView,
confirmed: bool,
) -> Vec<Effect> {
if !confirmed {
return vec![];
}
let ActiveView::Agent(id) = app.active_view else {
return vec![];
};
let Some((session_id, cwd, running_bg_tasks)) = app.agents.get(&id).and_then(|agent| {
let session_id = agent.session.session_id.clone()?;
let cwd = agent.session.cwd.display().to_string();
let running_bg_tasks: Vec<String> = agent
.session
.bg_tasks
.values()
.filter(|t| t.status == crate::app::agent::BgTaskStatus::Running)
.map(|t| t.task_id.clone())
.collect();
Some((session_id, cwd, running_bg_tasks))
}) else {
app.show_toast("No active session to delete");
return vec![];
};
let mut effects = vec![Effect::CancelTurn {
session_id: session_id.clone(),
cancel_subagents: true,
trigger: None,
rewind_if_pristine: false,
}];
effects.extend(
running_bg_tasks
.into_iter()
.map(|task_id| Effect::KillBgTask {
session_id: session_id.clone(),
task_id,
}),
);
app.show_toast("Deleting session\u{2026}");
effects.push(Effect::DeleteSession {
source: "current".into(),
session_id: session_id.to_string(),
cwd,
after: crate::app::actions::AfterSessionDelete::Welcome,
});
effects
}
/// Handle the user accepting the folder-trust question: persist the grant for
/// the workspace (writes `~/.grok/trusted_folders.toml`), mark trust resolved,
/// then replay any deferred session startup (only if auth is also done).

View file

@ -413,6 +413,18 @@ pub(in crate::app::dispatch) fn dispatch_pick_session_in_worktree(
}
dispatch_new_worktree_session(app, Some(session_id), None, None, None, None, None)
}
fn keep_picker_entry(
entry: &crate::app::app_view::SessionPickerEntry,
source: &str,
session_id: &str,
match_id_only: bool,
) -> bool {
if match_id_only {
entry.id != session_id
} else {
entry.source != source || entry.id != session_id
}
}
/// Remove a deleted session identity from the modal session picker and the
/// welcome-screen picker, then re-anchor the selection on a real row.
///
@ -422,6 +434,7 @@ pub(in crate::app::dispatch) fn remove_session_from_pickers(
app: &mut AppView,
source: &str,
session_id: &str,
match_id_only: bool,
) {
use crate::views::modal::ActiveModal;
use crate::views::session_picker::build_entry_map;
@ -447,7 +460,7 @@ pub(in crate::app::dispatch) fn remove_session_from_pickers(
*pending_delete = None;
}
if let Some(list) = entries.as_mut() {
list.retain(|entry| entry.source != source || entry.id != session_id);
list.retain(|entry| keep_picker_entry(entry, source, session_id, match_id_only));
}
if let Some(hits) = content_results.as_mut() {
hits.retain(|h| h.session_id != session_id);
@ -469,7 +482,7 @@ pub(in crate::app::dispatch) fn remove_session_from_pickers(
reanchor_grouped_selection(state, &map);
}
if let Some(list) = app.session_picker_entries.as_mut() {
list.retain(|entry| entry.source != source || entry.id != session_id);
list.retain(|entry| keep_picker_entry(entry, source, session_id, match_id_only));
}
if let Some(hits) = app.session_picker_content_results.as_mut() {
hits.retain(|h| h.session_id != session_id);

View file

@ -110,9 +110,27 @@ fn is_current_coding_data_write(app: &AppView, seq: u64, agent_id: AgentId) -> b
false
}
fn log_coding_data_consent_selected(
source: xai_grok_telemetry::events::CodingDataConsentSource,
opted_in: bool,
previous_opted_in: bool,
) {
use xai_grok_telemetry::events::{CodingDataConsentChoice, CodingDataConsentSelected};
xai_grok_telemetry::session_ctx::log_event(CodingDataConsentSelected {
source,
choice: CodingDataConsentChoice::from_opted_in(opted_in),
previous_choice: CodingDataConsentChoice::from_opted_in(previous_opted_in),
changed: opted_in != previous_opted_in,
});
}
/// Set coding-data-sharing preference. SHELL-owned, auth-metadata-backed
/// (persists via ACP ext-request, NOT `~/.grok/config.toml`).
pub(super) fn set_coding_data_sharing(app: &mut AppView, opted_in: bool) -> Vec<Effect> {
pub(super) fn set_coding_data_sharing(
app: &mut AppView,
opted_in: bool,
source: xai_grok_telemetry::events::CodingDataConsentSource,
) -> Vec<Effect> {
// ── Guard 1: Enterprise ZDR ──────────────────────────────────────
if app.is_zdr {
app.show_toast("\u{2717} Cannot change: Zero Data Retention enabled");
@ -131,6 +149,7 @@ pub(super) fn set_coding_data_sharing(app: &mut AppView, opted_in: bool) -> Vec<
}
let agent_id = coding_data_sharing_agent_id(app);
let prev = !app.coding_data_retention_opt_out;
log_coding_data_consent_selected(source, opted_in, prev);
// ── Idempotent path: skip the ACP round-trip. ────────────────────
if prev == opted_in {
@ -455,7 +474,11 @@ pub(in crate::app::dispatch) fn dispatch_privacy_banner_opt_in(app: &mut AppView
if app.privacy_banner_opt_in_inflight || !app.privacy_banner_should_show() {
return vec![];
}
let effects = set_coding_data_sharing(app, true);
let effects = set_coding_data_sharing(
app,
true,
xai_grok_telemetry::events::CodingDataConsentSource::PrivacyBanner,
);
// should_show guarantees opted-out + unguarded, so effects is only empty
// if a guard regresses; leaving inflight false keeps [Opt in] clickable.
app.privacy_banner_opt_in_inflight = !effects.is_empty();
@ -476,6 +499,12 @@ pub(in crate::app::dispatch) fn dispatch_privacy_banner_opt_out(app: &mut AppVie
if app.privacy_banner_opt_in_inflight || !app.privacy_banner_should_show() {
return vec![];
}
let previous_opted_in = !app.coding_data_retention_opt_out;
log_coding_data_consent_selected(
xai_grok_telemetry::events::CodingDataConsentSource::PrivacyBanner,
false,
previous_opted_in,
);
let mut effects = ack_privacy_banner(app);
effects.push(Effect::SetCodingDataSharing {
agent_id: coding_data_sharing_agent_id(app),

View file

@ -39,6 +39,7 @@ use super::session::load::{
handle_session_loaded, handle_session_restore_failed, handle_session_restored,
handle_session_search_debounce_expired, remove_session_from_pickers,
};
use super::session::modal::remove_agent_and_cleanup;
use super::settings::ui::apply_setting_rollback;
use super::status::{
commit_session_usage_block, handle_coding_data_sharing_failed,
@ -896,10 +897,40 @@ pub(super) fn dispatch_task_result(result: TaskResult, app: &mut AppView) -> Vec
}
vec![]
}
TaskResult::DeleteSessionComplete { source, session_id } => {
remove_session_from_pickers(app, &source, &session_id);
TaskResult::DeleteSessionComplete {
source,
session_id,
after,
} => {
use crate::app::actions::AfterSessionDelete;
remove_session_from_pickers(
app,
&source,
&session_id,
after != AfterSessionDelete::Stay,
);
if after == AfterSessionDelete::Stay {
app.show_toast("Session deleted");
return vec![];
}
let sid = acp::SessionId::new(session_id.clone());
let to_remove: Vec<_> = app
.agents
.iter()
.filter(|(_, agent)| agent.session.session_id.as_ref() == Some(&sid))
.map(|(id, _)| *id)
.collect();
let foreground =
matches!(app.active_view, ActiveView::Agent(id) if to_remove.contains(&id));
for id in to_remove {
remove_agent_and_cleanup(app, id);
}
let mut effects = unregister_session_effect(Some(sid));
if foreground && after == AfterSessionDelete::Welcome {
effects.extend(dispatch_exit_session(app));
}
app.show_toast("Session deleted");
vec![]
effects
}
TaskResult::DeleteSessionFailed {
source,

View file

@ -1681,6 +1681,7 @@ fn translate_local_submit_never_returns_persist_never_for_new_session() {
}
#[test]
fn delete_session_action_emits_delete_effect() {
use crate::app::actions::AfterSessionDelete;
let mut app = test_app_with_agent();
open_session_picker_with(&mut app, vec![make_picker_entry("s1", "/repo")]);
let effects = dispatch(
@ -1691,17 +1692,108 @@ fn delete_session_action_emits_delete_effect() {
},
&mut app,
);
assert!(matches!(
effects.as_slice(),
[Effect::DeleteSession {
source,
session_id,
cwd,
after: AfterSessionDelete::Stay,
}] if source == "local" && session_id == "s1" && cwd == "/repo"
));
}
#[test]
fn delete_current_session_confirm_emits_effect() {
use crate::app::actions::AfterSessionDelete;
let mut app = test_app_with_agent();
{
let a = app.agents.get_mut(&AgentId(0)).unwrap();
a.session.session_id = Some(acp::SessionId::new("sess-current"));
a.session.cwd = std::path::PathBuf::from("/repo");
}
assert!(dispatch(Action::DeleteCurrentSession, &mut app).is_empty());
assert!(matches!(
app.agents[&AgentId(0)]
.question_view
.as_ref()
.unwrap()
.local_kind,
Some(crate::views::question_view::LocalQuestionKind::DeleteCurrentSession)
));
assert!(
dispatch(
Action::DeleteCurrentSessionAnswered { confirmed: false },
&mut app,
)
.is_empty()
);
let effects = dispatch(
Action::DeleteCurrentSessionAnswered { confirmed: true },
&mut app,
);
assert!(
matches!(
effects.as_slice(),
[Effect::DeleteSession {
source,
session_id,
cwd,
}] if source == "local" && session_id == "s1" && cwd == "/repo"
effects.first(),
Some(Effect::CancelTurn {
cancel_subagents: true,
..
})
),
"DeleteSession action must emit exactly one matching DeleteSession effect"
"must cancel the turn/subagents before delete, got {effects:?}"
);
assert!(
matches!(
effects.last(),
Some(Effect::DeleteSession {
session_id,
after: AfterSessionDelete::Welcome,
..
}) if session_id == "sess-current"
),
"got {effects:?}"
);
}
#[test]
fn delete_current_session_complete_welcome_and_guard() {
use crate::app::actions::{AfterSessionDelete, TaskResult};
let mut app = test_app_with_agent();
app.agents.get_mut(&AgentId(0)).unwrap().session.session_id =
Some(acp::SessionId::new("sess-a"));
let effects = dispatch_task_result(
TaskResult::DeleteSessionComplete {
source: "current".into(),
session_id: "sess-a".into(),
after: AfterSessionDelete::Welcome,
},
&mut app,
);
assert!(matches!(app.active_view, ActiveView::Welcome));
assert!(app.agents.is_empty());
assert!(
effects
.iter()
.any(|e| matches!(e, Effect::UnregisterActiveSession { .. }))
);
let mut app = test_app_with_agent();
app.agents.get_mut(&AgentId(0)).unwrap().session.session_id =
Some(acp::SessionId::new("sess-a"));
let other = AgentId(1);
let session = make_test_agent_session(&app, other, "unused");
app.agents
.insert(other, AgentView::new(session, ScrollbackState::new()));
app.agents.get_mut(&other).unwrap().session.session_id = Some(acp::SessionId::new("sess-b"));
app.active_view = ActiveView::Agent(other);
let effects = dispatch_task_result(
TaskResult::DeleteSessionComplete {
source: "current".into(),
session_id: "sess-a".into(),
after: AfterSessionDelete::Welcome,
},
&mut app,
);
assert!(matches!(app.active_view, ActiveView::Agent(id) if id == other));
assert!(!app.agents.contains_key(&AgentId(0)));
assert!(!effects.iter().any(|e| matches!(e, Effect::Quit)));
}
#[test]
fn entry_title_falls_back_to_short_session_id_when_no_prompt() {

View file

@ -1701,6 +1701,7 @@ fn delete_session_complete_removes_only_matching_source_and_id() {
TaskResult::DeleteSessionComplete {
source: "local".into(),
session_id: "s1".into(),
after: crate::app::actions::AfterSessionDelete::Stay,
},
&mut app,
);
@ -1781,6 +1782,7 @@ fn delete_both_session_clears_modal_and_welcome_content_hits() {
TaskResult::DeleteSessionComplete {
source: "both".into(),
session_id: "shared".into(),
after: crate::app::actions::AfterSessionDelete::Stay,
},
&mut app,
);
@ -1878,6 +1880,7 @@ fn delete_remote_session_clears_modal_and_welcome_content_hits() {
TaskResult::DeleteSessionComplete {
source: "remote".into(),
session_id: "remote-only".into(),
after: crate::app::actions::AfterSessionDelete::Stay,
},
&mut app,
);

View file

@ -3247,7 +3247,7 @@ pub(crate) fn execute(
}
});
}
Effect::DeleteSession { source, session_id, cwd } => {
Effect::DeleteSession { source, session_id, cwd, after } => {
let tx = acp_tx.clone();
tasks
.spawn(async move {
@ -3291,6 +3291,7 @@ pub(crate) fn execute(
TaskResult::DeleteSessionComplete {
source,
session_id,
after,
}
}
Err(e) => {

View file

@ -37,16 +37,9 @@ use std::time::Duration;
use agent_client_protocol as acp;
use tempfile::TempDir;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::task::JoinSet;
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
use tokio_util::sync::CancellationToken;
use xai_acp_lib::{
AcpAgentGatewayReceiver as GatewayReceiver, AcpAgentGatewaySender as GatewaySender,
AcpClientRx, LineBufferedRead, acp_send,
};
use xai_grok_shell::agent::config::Config as AgentConfig;
use xai_grok_shell::agent::mvp_agent::MvpAgent;
use xai_acp_lib::{AcpClientRx, acp_send};
use xai_grok_shell::leader::{
ClientCapabilities as LeaderClientCapabilities, ClientMode, ConnectionStatus,
LEADER_SOCKET_ENV, LeaderClient, LeaderEnvUrls, LeaderLock, LeaderReconnector,
@ -63,7 +56,6 @@ use crate::acp::leader_bridge::bridge_channels;
use crate::acp::model_state::ModelState;
use crate::scrollback::block::RenderBlock;
const SIMPLEX_BUF: usize = 8 * 1024 * 1024;
const PUMP_TICK: Duration = Duration::from_millis(10);
const TURN_BUDGET: Duration = Duration::from_secs(60);
@ -358,7 +350,7 @@ impl PagerLeaderCluster {
/// wire a fresh REAL agent behind it.
async fn spawn_leader_generation(&mut self) {
let _ = std::fs::remove_file(&self.sock_path);
let (acp_tx, mut acp_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let (acp_tx, acp_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let (response_tx, response_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
let cancel = CancellationToken::new();
self.server_cancel = cancel.clone();
@ -396,66 +388,10 @@ impl PagerLeaderCluster {
.await;
}));
// Real agent behind the server. Copied from `run_leader`'s
// agent-spawn + IPC/stdout bridge blocks in
// xai-grok-shell/src/agent/app.rs (inside its LocalSet body) — a
// deliberate copy so production stays untouched. Second copy of the
// same wiring: xai-grok-shell/tests/test_leader_soak.rs ("Real agent
// behind it" block) — keep the two copies behaviorally identical.
let (agent_in_read, agent_in_write) = tokio::io::simplex(SIMPLEX_BUF);
let (agent_out_read, agent_out_write) = tokio::io::simplex(SIMPLEX_BUF);
generation_tasks.push(tokio::task::spawn_local(async move {
let agent_config = AgentConfig::default();
let auth_manager = Arc::new(agent_config.create_auth_manager());
let (gw_tx, gw_rx) = tokio::sync::mpsc::unbounded_channel();
let gateway = GatewaySender::new(gw_tx);
let agent = MvpAgent::new(gateway, &agent_config, auth_manager, None)
.expect("valid agent config");
let incoming = LineBufferedRead::spawn_local(agent_in_read.compat());
let (conn, handle_io) = acp::AgentSideConnection::new(
agent,
agent_out_write.compat_write(),
incoming,
|fut| {
tokio::task::spawn_local(fut);
},
);
tokio::task::spawn_local(
GatewayReceiver::new(gw_rx, conn)
.with_on_meta(xai_file_utils::trace_context::span_from_meta_traceparent)
.run(),
);
let _ = handle_io.await;
}));
generation_tasks.push(tokio::task::spawn_local(async move {
let mut agent_in_write = agent_in_write;
while let Some(msg) = acp_rx.recv().await {
if agent_in_write.write_all(msg.as_bytes()).await.is_err()
|| agent_in_write.write_all(b"\n").await.is_err()
{
break;
}
}
}));
generation_tasks.push(tokio::task::spawn_local(async move {
let mut reader = BufReader::new(agent_out_read);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line).await {
Ok(0) => break,
Ok(_) => {
let msg = line.trim_end_matches(['\r', '\n']).to_string();
if !msg.is_empty() && response_tx.send(msg).is_err() {
break;
}
}
Err(_) => break,
}
}
}));
generation_tasks.extend(xai_grok_shell::leader::in_process::spawn_agent(
acp_rx,
response_tx,
));
self.generation_tasks = generation_tasks;
let deadline = tokio::time::Instant::now() + Duration::from_secs(10);

View file

@ -156,6 +156,16 @@ pub static USER_GUIDE: &[Doc] = &[
"Permissions and Safety",
"Modes, authorization order, allow/ask/deny rules, matching, and hooks"
),
guide!(
"23-dashboard.md",
"Agent Dashboard",
"Live multi-session roster: peek, dispatch, pin, stop, and search"
),
guide!(
"24-monitoring-usage.md",
"Monitoring Usage (External OpenTelemetry)",
"Export usage metrics to a customer OpenTelemetry collector"
),
];
/// Non-user-guide reference docs. Separate from USER_GUIDE because they

View file

@ -111,6 +111,13 @@ impl SleepInhibitor {
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null());
xai_tty_utils::detach_std_command(&mut cmd);
// The spawned process is the lock holder: `systemd-inhibit` keeps
// the idle-inhibit fd itself and runs `sleep infinity` as its child
// — it is the same pid `release()` SIGTERMs on a clean turn end.
// Bind that pid to us so a crashed/killed grok (SIGKILL,
// `panic=abort` SIGABRT — no Drop runs) can't leave an immortal
// inhibitor holding the lock and pid slots on shared hosts.
xai_tty_utils::kill_on_parent_death_std(&mut cmd);
let result = cmd.spawn();
match result {

View file

@ -0,0 +1,31 @@
//! `/delete` — delete this session's history and return home.
use crate::app::actions::Action;
use crate::slash::command::{CommandExecCtx, CommandResult, SlashCommand};
pub struct DeleteCommand;
impl SlashCommand for DeleteCommand {
fn name(&self) -> &str {
"delete"
}
fn description(&self) -> &str {
"Delete this session and return home"
}
fn session_scoped(&self) -> bool {
true
}
fn usage(&self) -> &str {
"/delete"
}
fn run(&self, ctx: &mut CommandExecCtx, _args: &str) -> CommandResult {
if ctx.session_id.is_none() {
return CommandResult::Error("No active session to delete".into());
}
CommandResult::Action(Action::DeleteCurrentSession)
}
}

View file

@ -15,6 +15,7 @@ pub mod context;
pub mod copy;
pub mod dashboard;
pub mod debug;
pub mod delete;
pub mod docs;
pub mod doctor;
pub mod edit_prompt;
@ -81,6 +82,7 @@ pub fn builtin_commands() -> Vec<Arc<dyn SlashCommand>> {
Arc::new(help::HelpCommand),
Arc::new(docs::DocsCommand),
Arc::new(home::HomeCommand),
Arc::new(delete::DeleteCommand),
Arc::new(new::NewCommand),
Arc::new(fork::ForkCommand),
Arc::new(compact::CompactCommand),
@ -263,6 +265,7 @@ mod tests {
"cost",
"dashboard",
"debug",
"delete",
"docs",
"doctor",
"edit-prompt",
@ -397,6 +400,19 @@ mod tests {
assert!(matches!(result, CommandResult::Action(Action::ExitSession)));
}
#[test]
fn delete_requires_session_and_dispatches() {
let models = ModelState::default();
let cmd = delete::DeleteCommand;
let mut ctx = make_ctx(&models);
assert!(matches!(cmd.run(&mut ctx, ""), CommandResult::Error(_)));
let session_id = acp::SessionId::new("sess-delete");
ctx.session_id = Some(&session_id);
assert!(matches!(
cmd.run(&mut ctx, ""),
CommandResult::Action(Action::DeleteCurrentSession)
));
}
#[test]
fn view_plan_returns_show_plan_action() {
let models = ModelState::default();
let mut ctx = make_ctx(&models);

View file

@ -63,7 +63,7 @@ enum Msg {
struct Daemon {
shared: Arc<Mutex<Snapshot>>,
tx: SyncSender<Msg>,
_handle: JoinHandle<()>,
handle: Option<JoinHandle<()>>,
}
const MAX_RESULTS: usize = 100;
@ -74,7 +74,7 @@ impl Daemon {
let (tx, rx) = sync_channel::<Msg>(256);
let out = shared.clone();
let handle = thread::spawn(move || {
let worker = move || {
let mut pattern = MultiPattern::new(1);
let mut matcher = Matcher::new(Config::DEFAULT);
let mut items: Vec<(String, Utf32String)> = Vec::new();
@ -82,7 +82,6 @@ impl Daemon {
let mut prev_q = String::new();
while let Ok(msg) = rx.recv() {
// Drain to latest — skip intermediate queries.
let msg = drain_to_latest(msg, &rx);
match msg {
@ -144,13 +143,23 @@ impl Daemon {
Msg::Stop => break,
}
}
});
};
let handle = thread::Builder::new()
.name("history-search".into())
.spawn(worker);
Self {
shared,
tx,
_handle: handle,
}
let handle = match handle {
Ok(h) => Some(h),
Err(e) => {
tracing::error!(
error = %e,
"history search daemon thread spawn failed; history search disabled"
);
None
}
};
Self { shared, tx, handle }
}
}
@ -355,6 +364,9 @@ impl HistorySearchState {
}
fn activate_inner(&mut self, history: &[HistoryEntry], current_text: &str, browse: bool) {
if !self.is_available() {
return;
}
self.active = true;
self.browse = browse;
self.saved_text = current_text.to_string();
@ -369,6 +381,12 @@ impl HistorySearchState {
self.selected = self.snapshot.items.len().saturating_sub(1);
}
/// False when the matcher thread never started, so the overlay cannot open
/// and callers must leave the composer alone.
pub fn is_available(&self) -> bool {
self.daemon.handle.is_some()
}
/// True while the overlay is in browse mode (see [`Self::activate_browse`]).
pub fn is_browse(&self) -> bool {
self.active && self.browse

View file

@ -399,6 +399,11 @@ pub(crate) fn default_palette_entries(
shortcut: "/home".into(),
command: PaletteCommand::Home,
},
PaletteEntry {
label: "Delete This Session".into(),
shortcut: "/delete".into(),
command: PaletteCommand::SlashCommand("/delete".into()),
},
PaletteEntry {
label: "Resume Session".into(),
shortcut: "/resume".into(),

View file

@ -131,6 +131,7 @@ pub enum LocalQuestionKind {
target: crate::app::actions::DoctorFixTarget,
plan: Box<crate::diagnostics::FixPlan>,
},
DeleteCurrentSession,
}
// ── State ──────────────────────────────────────────────────────────────