Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ pilotty spawn --retain-bytes 1048576 <cmd> # Override retained output limit
pilotty kill # Kill default session
pilotty kill -s myapp # Kill specific session
pilotty list-sessions # List all active sessions
pilotty status # Report default session lifecycle status
pilotty status -s myapp # Report a named session's status
pilotty stop # Stop the daemon and all sessions
pilotty daemon # Manually start daemon (usually auto-starts)
pilotty examples # Show end-to-end workflow example
Expand All @@ -134,6 +136,20 @@ minutes: exit metadata, the final full screen, and the last 64 KiB of raw output
process return `SESSION_EXITED`. Tombstones disappear when they expire, are evicted, or
the daemon restarts, and never keep the daemon running.

### Session Status

```bash
pilotty status # Status of the default session
pilotty status -s myapp # Status by session name or ID
```

`status` returns structured JSON with `state: "running"` or `state: "exited"`.
Running sessions include their current geometry, command, working directory, creation
time, idle duration, and retention accounting. Exited sessions include final geometry,
exit code or signal, success and explicit-kill flags, creation and end times, output
completeness, and retained-evidence accounting. Unknown, expired, or evicted sessions
return `SESSION_NOT_FOUND`.

### Screen Capture

```bash
Expand Down
14 changes: 14 additions & 0 deletions crates/pilotty-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ Examples:
/// Print retained raw output for a session
Logs(LogsArgs),

/// Report whether a session is running or exited
#[command(after_help = "\
Examples:
pilotty status # Status of the default session
pilotty status -s editor # Status of a named session")]
Status(StatusArgs),

/// Resize the terminal
Resize(ResizeArgs),

Expand Down Expand Up @@ -158,6 +165,13 @@ pub struct LogsArgs {
pub session: Option<String>,
}

#[derive(Debug, clap::Args)]
pub struct StatusArgs {
/// Target session by name or ID [default: default]
#[arg(short, long, help = SESSION_HELP)]
pub session: Option<String>,
}

#[derive(Debug, clap::Args)]
pub struct SnapshotArgs {
/// Output format
Expand Down
10 changes: 6 additions & 4 deletions crates/pilotty-cli/src/daemon/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ impl DaemonClient {
mod tests {
use super::*;
use crate::daemon::server::DaemonServer;
use pilotty_core::protocol::{Command, ResponseData, PROTOCOL_VERSION};
use pilotty_core::protocol::{Command, ResponseData, PROTOCOL_V1, PROTOCOL_VERSION};
use tokio::net::UnixListener;

#[tokio::test]
Expand Down Expand Up @@ -307,7 +307,7 @@ mod tests {
}

#[tokio::test]
async fn old_daemon_is_probed_and_logs_is_rejected_before_transmission() {
async fn shipped_v1_daemon_is_probed_and_v2_command_is_rejected_before_transmission() {
let socket_path =
std::env::temp_dir().join(format!("pilotty-probe-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&socket_path);
Expand All @@ -326,7 +326,7 @@ mod tests {
success: true,
data: Some(ResponseData::Sessions { sessions: vec![] }),
error: None,
protocol: LEGACY_PROTOCOL_VERSION,
protocol: PROTOCOL_V1,
};
writer
.write_all(
Expand Down Expand Up @@ -363,7 +363,9 @@ mod tests {
.await
.expect_err("old daemon must be rejected");

assert!(error.to_string().contains("protocol 1"));
let message = error.to_string();
assert!(message.contains("speaks protocol 1"), "got: {message}");
assert!(message.contains("requires protocol 2"), "got: {message}");
peer.await.expect("protocol fixture task");
let _ = std::fs::remove_file(&socket_path);
}
Expand Down
143 changes: 142 additions & 1 deletion crates/pilotty-cli/src/daemon/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,8 @@ async fn handle_request(

Command::Logs { session } => handle_logs(&request_id, &sessions, session).await,

Command::Status { session } => handle_status(&request_id, &sessions, session).await,

Command::Kill { session } => handle_kill(&request_id, &sessions, session).await,

Command::Type { text, session } => handle_type(&request_id, &sessions, text, session).await,
Expand Down Expand Up @@ -742,6 +744,18 @@ async fn handle_logs(
}
}

/// Handle status command.
async fn handle_status(
request_id: &str,
sessions: &SessionManager,
session: Option<String>,
) -> Response {
match sessions.session_status(session.as_deref()).await {
Ok(status) => Response::success(request_id, ResponseData::Status(status)),
Err(error) => Response::error(request_id, error),
}
}

/// Minimum useful settle window retained for CLI compatibility.
const MIN_SETTLE_MS: u64 = 50;

Expand Down Expand Up @@ -1504,13 +1518,35 @@ async fn handle_shutdown(
mod tests {
use super::*;
use pilotty_core::error::ErrorCode;
use pilotty_core::protocol::{Command, PROTOCOL_VERSION};
use pilotty_core::protocol::{Command, SessionStatus, PROTOCOL_VERSION};
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use tokio::net::UnixStream;
use tokio::time::timeout;
use uuid::Uuid;

async fn socket_request(
reader: &mut BufReader<OwnedReadHalf>,
writer: &mut OwnedWriteHalf,
request: Request,
) -> Response {
writer
.write_all(
serde_json::to_string(&request)
.expect("serialize request")
.as_bytes(),
)
.await
.expect("write request");
writer.write_all(b"\n").await.expect("write newline");
writer.flush().await.expect("flush request");

let mut line = String::new();
reader.read_line(&mut line).await.expect("read response");
serde_json::from_str(&line).expect("parse response")
}

#[test]
fn retention_environment_value_is_validated() {
assert_eq!(parse_retain_bytes(None).expect("default"), 2 * 1024 * 1024);
Expand Down Expand Up @@ -1542,6 +1578,111 @@ mod tests {
));
}

#[tokio::test]
async fn status_reports_live_then_successful_exit_over_the_socket() {
let socket_path =
std::path::PathBuf::from(format!("/tmp/pilotty-status-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&socket_path);
let pid_path = socket_path.with_extension("pid");
let server = DaemonServer::bind_to(socket_path.clone(), pid_path)
.await
.expect("bind server");
let server_handle = tokio::spawn(async move {
let _ = timeout(Duration::from_secs(4), server.run()).await;
});

let stream = UnixStream::connect(&socket_path)
.await
.expect("connect to server");
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);

let spawn = socket_request(
&mut reader,
&mut writer,
Request::new(
"spawn-status",
Command::Spawn {
command: vec![
"sh".to_string(),
"-c".to_string(),
"printf status-evidence; sleep 1; exit 0".to_string(),
],
session_name: Some("socket-status".to_string()),
cwd: Some("/tmp".to_string()),
retain_bytes: Some(8),
},
),
)
.await;
assert!(spawn.success);

let running = socket_request(
&mut reader,
&mut writer,
Request::new(
"running-status",
Command::Status {
session: Some("socket-status".to_string()),
},
),
)
.await;
assert!(matches!(
running.data,
Some(ResponseData::Status(SessionStatus::Running {
cwd: Some(cwd),
size: TerminalSize { cols: 80, rows: 24 },
..
})) if cwd == "/tmp"
));

let exited = timeout(Duration::from_secs(3), async {
loop {
let response = socket_request(
&mut reader,
&mut writer,
Request::new(
Uuid::new_v4().to_string(),
Command::Status {
session: Some("socket-status".to_string()),
},
),
)
.await;
if matches!(
&response.data,
Some(ResponseData::Status(SessionStatus::Exited { .. }))
) {
break response;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("finalized status");

assert!(matches!(
exited.data,
Some(ResponseData::Status(SessionStatus::Exited {
exit_code: Some(0),
success: true,
killed_by_client: false,
output_complete: true,
retention: pilotty_core::protocol::RetentionAccounting {
total_bytes: 15,
retained_bytes: 8,
dropped_bytes: 7,
truncated: true,
},
..
}))
));

server_handle.abort();
let _ = std::fs::remove_file(&socket_path);
}

#[tokio::test]
async fn finalized_session_serves_evidence_and_rejects_input() {
let sessions = Arc::new(SessionManager::new());
Expand Down
Loading
Loading