use std::time::Duration;
use tracing::{info, warn};
use crate::runtime::RuntimeKind;
use super::injection_status::InjectionStatus;
use super::manager::{ManagedError, SessionManager};
use super::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
const READY_MAX_ATTEMPTS: u32 = 30;
const READY_BACKOFF_START: Duration = Duration::from_millis(250);
const READY_BACKOFF_MAX: Duration = Duration::from_secs(2);
const MODAL_PROBE_LINES: usize = 60;
const BLOCKING_MODAL_MARKERS: &[&str] = &[
"Do you trust the files in this folder",
"Choose the text style that looks best",
"Enable bypass permissions mode",
"Welcome to Claude Code",
];
pub fn pane_shows_blocking_modal(pane_text: &str) -> bool {
BLOCKING_MODAL_MARKERS.iter().any(|m| pane_text.contains(m))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum PaneReadiness {
Ready,
RuntimeNotReady,
BlockingModal,
}
pub fn should_inject_task(
inject_flag: bool,
task: &str,
runtime: RuntimeKind,
state: &ManagedSessionState,
) -> bool {
inject_flag
&& !task.trim().is_empty()
&& runtime == RuntimeKind::ClaudeCode
&& *state == ManagedSessionState::Active
}
impl SessionManager {
pub async fn inject_task_when_ready(
&self,
id: &ManagedSessionId,
task: &str,
) -> Result<bool, ManagedError> {
if task.trim().is_empty() {
return Ok(false);
}
let initial = self.get(id).await?;
if super::supervisor::skip_protected(&initial, "task injection") {
return Ok(false);
}
let name = initial.tmux_name;
self.set_injection_status(id, InjectionStatus::Pending)
.await;
let mut delay = READY_BACKOFF_START;
let mut ready = false;
for attempt in 0..READY_MAX_ATTEMPTS {
if attempt > 0 {
tokio::time::sleep(delay).await;
delay = (delay * 2).min(READY_BACKOFF_MAX);
}
let record = self.get(id).await?;
let state = record.state.clone();
if matches!(
state,
ManagedSessionState::Stopped
| ManagedSessionState::Errored
| ManagedSessionState::Decommissioned
) {
warn!(
id = %id,
name = %name,
state = %state,
"task injection aborted: session no longer live (task retained as metadata)"
);
self.set_injection_status(id, InjectionStatus::FailedSessionDied)
.await;
return Ok(false);
}
match self.pane_readiness(id, &record) {
Ok(PaneReadiness::Ready) => {
ready = true;
break;
}
Ok(_) => {}
Err(e) => {
warn!(id = %id, name = %name, "task injection aborted: {e}; task retained as metadata");
self.set_injection_status(id, InjectionStatus::FailedSessionDied)
.await;
return Err(e);
}
}
}
if !ready {
warn!(
id = %id,
name = %name,
"runtime not ready within budget (PID absent or blocked by a first-run modal); \
task NOT injected (retained as metadata — deliver it with `tm session send`)"
);
self.set_injection_status(id, InjectionStatus::FailedTimeout)
.await;
return Ok(false);
}
if let Err(e) = self.send_input(id, task).await {
warn!(
id = %id,
name = %name,
"send_input failed after readiness was confirmed: {e}; task retained as metadata"
);
self.set_injection_status(id, InjectionStatus::FailedSessionDied)
.await;
return Err(e);
}
info!(id = %id, name = %name, "injected --task into ready session pane (#1903)");
self.set_injection_status(id, InjectionStatus::Success)
.await;
Ok(true)
}
pub(super) fn capture_readiness_pane(&self, name: &str, pane_id: &str) -> String {
self.tmux
.capture_pane(name, pane_id, MODAL_PROBE_LINES)
.unwrap_or_default()
}
pub(super) fn pane_readiness(
&self,
id: &ManagedSessionId,
record: &SessionRecord,
) -> Result<PaneReadiness, ManagedError> {
let pane_id = self.owned_pane(id, record)?;
let name = record.tmux_name.as_str();
if !self.tmux.runtime_ready(name) {
return Ok(PaneReadiness::RuntimeNotReady);
}
if pane_shows_blocking_modal(&self.capture_readiness_pane(name, &pane_id)) {
return Ok(PaneReadiness::BlockingModal);
}
Ok(PaneReadiness::Ready)
}
async fn set_injection_status(&self, id: &ManagedSessionId, status: InjectionStatus) {
let mut record = match self.get(id).await {
Ok(r) => r,
Err(e) => {
warn!(id = %id, "set_injection_status({status}): record lookup failed: {e}");
return;
}
};
record.injection_status = status;
if let Err(e) = self.store.write().await.upsert(record).await {
warn!(id = %id, "set_injection_status({status}): persist failed: {e}");
}
}
pub(super) async fn check_send_input_ready(
&self,
id: &ManagedSessionId,
) -> Result<(), ManagedError> {
let record = self.get(id).await?;
if matches!(
record.state,
ManagedSessionState::Provisioning | ManagedSessionState::Errored
) {
return Err(ManagedError::TmuxUnavailable(format!(
"session {} is {}; cannot inject input",
record.tmux_name, record.state
)));
}
if record.runtime == RuntimeKind::ClaudeCode
&& pane_shows_blocking_modal(
&self.capture_readiness_pane(&record.tmux_name, &self.owned_pane(id, &record)?),
)
{
return Err(ManagedError::TmuxUnavailable(format!(
"session {} is showing a blocking onboarding/trust modal; \
cannot inject input until it is dismissed",
record.tmux_name
)));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::super::record::ManagedSessionState;
use super::super::tests::{make_manager, make_manager_with_pane};
use super::*;
use tempfile::TempDir;
#[test]
fn should_inject_task_true_for_active_claude_code() {
assert!(should_inject_task(
true,
"implement OAuth2",
RuntimeKind::ClaudeCode,
&ManagedSessionState::Active,
));
}
#[test]
fn should_inject_task_false_when_opted_out() {
assert!(!should_inject_task(
false,
"implement OAuth2",
RuntimeKind::ClaudeCode,
&ManagedSessionState::Active,
));
}
#[test]
fn should_inject_task_false_for_empty_task() {
assert!(!should_inject_task(
true,
" ",
RuntimeKind::ClaudeCode,
&ManagedSessionState::Active,
));
}
#[test]
fn should_inject_task_false_for_tcode() {
assert!(!should_inject_task(
true,
"implement OAuth2",
RuntimeKind::Tcode,
&ManagedSessionState::Active,
));
}
#[test]
fn should_inject_task_false_when_not_active() {
for state in [
ManagedSessionState::Provisioning,
ManagedSessionState::Errored,
ManagedSessionState::Stopped,
ManagedSessionState::Decommissioned,
] {
assert!(!should_inject_task(
true,
"implement OAuth2",
RuntimeKind::ClaudeCode,
&state,
));
}
}
#[tokio::test]
async fn inject_when_ready_sends_task_via_send_seam() {
let dir = TempDir::new().unwrap();
let (mgr, fake) = make_manager_with_pane(&dir).await;
let record = mgr
.create("wire up the widget".into(), None, None, None, None, None)
.await
.expect("create");
{
let mut store = mgr.store.write().await;
let mut r = store.get(&record.id).await.unwrap();
r.state = ManagedSessionState::Active;
store.upsert(r).await.unwrap();
}
let injected = mgr
.inject_task_when_ready(&record.id, "wire up the widget")
.await
.expect("inject");
assert!(
injected,
"task should have been injected into the ready pane"
);
let sends = fake.pane_send_calls.lock().unwrap();
assert_eq!(
sends.len(),
1,
"exactly one send-line should fire: {sends:?}"
);
assert_eq!(sends[0].0, record.tmux_name);
assert_eq!(sends[0].2, "wire up the widget");
}
#[tokio::test]
async fn inject_when_ready_skips_empty_task() {
let dir = TempDir::new().unwrap();
let (mgr, fake) = make_manager(&dir).await;
let record = mgr
.create(" ".into(), None, None, None, None, None)
.await
.expect("create");
let injected = mgr
.inject_task_when_ready(&record.id, " ")
.await
.expect("inject");
assert!(!injected, "empty task must not be injected");
assert!(
fake.send_calls.lock().unwrap().is_empty(),
"no send-line should fire for an empty task"
);
let after = mgr.get(&record.id).await.expect("get");
assert_eq!(after.injection_status, InjectionStatus::NotApplicable);
}
#[test]
fn pane_shows_blocking_modal_true_for_trust_dialog() {
let pane = "some banner text\nDo you trust the files in this folder?\n1. Yes 2. No";
assert!(pane_shows_blocking_modal(pane));
}
#[test]
fn pane_shows_blocking_modal_true_for_onboarding_welcome() {
let pane = "Welcome to Claude Code!\nLet's get you set up.";
assert!(pane_shows_blocking_modal(pane));
}
#[test]
fn pane_shows_blocking_modal_false_for_normal_prompt() {
let pane = "╭──────────────────────╮\n│ > │\n╰──────────────────────╯";
assert!(!pane_shows_blocking_modal(pane));
}
#[test]
fn pane_shows_blocking_modal_false_for_empty_pane() {
assert!(!pane_shows_blocking_modal(""));
}
#[tokio::test]
async fn pane_readiness_ready_when_pid_up_and_no_modal() {
let dir = TempDir::new().unwrap();
let (mgr, _fake) = make_manager_with_pane(&dir).await;
let record = mgr
.create("wire up SSO".into(), None, None, None, None, None)
.await
.expect("create");
assert_eq!(
mgr.pane_readiness(&record.id, &record).expect("owned pane"),
PaneReadiness::Ready
);
}
#[tokio::test]
async fn pane_readiness_not_ready_when_pid_absent() {
let dir = TempDir::new().unwrap();
let (mgr, _fake) = make_manager_with_pane(&dir).await;
let record = mgr
.create("wire up SSO".into(), None, None, None, None, None)
.await
.expect("create");
mgr.tmux_driver()
.kill_session(&record.tmux_name)
.expect("kill_session");
assert_eq!(
mgr.pane_readiness(&record.id, &record).expect("owned pane"),
PaneReadiness::RuntimeNotReady
);
}
#[tokio::test]
async fn pane_readiness_blocking_modal_when_pid_up_but_modal_shown() {
let dir = TempDir::new().unwrap();
let (mgr, fake) = make_manager_with_pane(&dir).await;
let record = mgr
.create("wire up SSO".into(), None, None, None, None, None)
.await
.expect("create");
fake.capture_responses.lock().unwrap().insert(
"%1".to_string(),
"Do you trust the files in this folder?".into(),
);
assert_eq!(
mgr.pane_readiness(&record.id, &record).expect("owned pane"),
PaneReadiness::BlockingModal
);
}
#[tokio::test]
async fn inject_when_ready_marks_success_status() {
let dir = TempDir::new().unwrap();
let (mgr, _fake) = make_manager_with_pane(&dir).await;
let record = mgr
.create("ship the feature".into(), None, None, None, None, None)
.await
.expect("create");
{
let mut store = mgr.store.write().await;
let mut r = store.get(&record.id).await.unwrap();
r.state = ManagedSessionState::Active;
store.upsert(r).await.unwrap();
}
let injected = mgr
.inject_task_when_ready(&record.id, "ship the feature")
.await
.expect("inject");
assert!(injected);
let after = mgr.get(&record.id).await.expect("get");
assert_eq!(after.injection_status, InjectionStatus::Success);
}
#[tokio::test]
async fn inject_when_ready_marks_failed_session_died_status() {
let dir = TempDir::new().unwrap();
let (mgr, fake) = make_manager(&dir).await;
let record = mgr
.create("do the thing".into(), None, None, None, None, None)
.await
.expect("create");
mgr.mark_errored(&record.id, "spawn failed")
.await
.expect("mark_errored");
let injected = mgr
.inject_task_when_ready(&record.id, "do the thing")
.await
.expect("inject");
assert!(!injected, "a dead session must never receive the task");
assert!(
fake.send_calls.lock().unwrap().is_empty(),
"no send-line should fire once the session is Errored"
);
let after = mgr.get(&record.id).await.expect("get");
assert_eq!(after.injection_status, InjectionStatus::FailedSessionDied);
}
#[tokio::test(start_paused = true)]
async fn inject_when_ready_times_out_when_modal_never_clears() {
let dir = TempDir::new().unwrap();
let (mgr, fake) = make_manager_with_pane(&dir).await;
let record = mgr
.create("wire up SSO".into(), None, None, None, None, None)
.await
.expect("create");
fake.capture_responses.lock().unwrap().insert(
"%1".to_string(),
"Do you trust the files in this folder?".into(),
);
let injected = mgr
.inject_task_when_ready(&record.id, "wire up SSO")
.await
.expect("inject");
assert!(
!injected,
"a pane permanently blocked by a modal must never be injected"
);
assert!(
fake.send_calls.lock().unwrap().is_empty(),
"the modal-guarded loop must never call send_input while blocked"
);
let after = mgr.get(&record.id).await.expect("get");
assert_eq!(after.injection_status, InjectionStatus::FailedTimeout);
}
#[tokio::test(start_paused = true)]
async fn inject_when_ready_checks_pane_scoped_capture_when_pane_id_known() {
let dir = TempDir::new().unwrap();
let (mgr, fake) = make_manager(&dir).await;
*fake.pane_id_override.lock().unwrap() = Some("%7".to_string());
let record = mgr
.create("wire up SSO".into(), None, None, None, None, None)
.await
.expect("create");
assert_eq!(
record.pane_id.as_deref(),
Some("%7"),
"create() must have captured the overridden pane_id"
);
fake.capture_responses.lock().unwrap().insert(
"%7".to_string(),
"Do you trust the files in this folder?".into(),
);
let injected = mgr
.inject_task_when_ready(&record.id, "wire up SSO")
.await
.expect("inject");
assert!(
!injected,
"the pane-scoped modal marker must block injection"
);
assert!(
!fake.pane_capture_calls.lock().unwrap().is_empty(),
"the modal probe must have used the pane-scoped capture_pane path"
);
let after = mgr.get(&record.id).await.expect("get");
assert_eq!(after.injection_status, InjectionStatus::FailedTimeout);
}
}