From 444d0bae4a1eaaae3c2a9f040413ac26b99d6024 Mon Sep 17 00:00:00 2001 From: msmps <7691252+msmps@users.noreply.github.com> Date: Sat, 11 Jul 2026 18:07:11 +0100 Subject: [PATCH] feat(status): report session lifecycle state --- README.md | 16 ++ crates/pilotty-cli/src/args.rs | 14 ++ crates/pilotty-cli/src/daemon/client.rs | 10 +- crates/pilotty-cli/src/daemon/server.rs | 143 ++++++++++++- crates/pilotty-cli/src/daemon/session.rs | 252 ++++++++++++++++++++++- crates/pilotty-cli/src/main.rs | 3 + crates/pilotty-core/src/error.rs | 2 +- crates/pilotty-core/src/protocol.rs | 149 +++++++++++--- npm/README.md | 4 + 9 files changed, 562 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index a1a705f..ef6f2a2 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,8 @@ pilotty spawn --retain-bytes 1048576 # 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 @@ -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 diff --git a/crates/pilotty-cli/src/args.rs b/crates/pilotty-cli/src/args.rs index 0549067..6c06362 100644 --- a/crates/pilotty-cli/src/args.rs +++ b/crates/pilotty-cli/src/args.rs @@ -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), @@ -158,6 +165,13 @@ pub struct LogsArgs { pub session: Option, } +#[derive(Debug, clap::Args)] +pub struct StatusArgs { + /// Target session by name or ID [default: default] + #[arg(short, long, help = SESSION_HELP)] + pub session: Option, +} + #[derive(Debug, clap::Args)] pub struct SnapshotArgs { /// Output format diff --git a/crates/pilotty-cli/src/daemon/client.rs b/crates/pilotty-cli/src/daemon/client.rs index 7c71c20..78211b8 100644 --- a/crates/pilotty-cli/src/daemon/client.rs +++ b/crates/pilotty-cli/src/daemon/client.rs @@ -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] @@ -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); @@ -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( @@ -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); } diff --git a/crates/pilotty-cli/src/daemon/server.rs b/crates/pilotty-cli/src/daemon/server.rs index 935ce2c..0f39fd6 100644 --- a/crates/pilotty-cli/src/daemon/server.rs +++ b/crates/pilotty-cli/src/daemon/server.rs @@ -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, @@ -742,6 +744,18 @@ async fn handle_logs( } } +/// Handle status command. +async fn handle_status( + request_id: &str, + sessions: &SessionManager, + session: Option, +) -> 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; @@ -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, + 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); @@ -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()); diff --git a/crates/pilotty-cli/src/daemon/session.rs b/crates/pilotty-cli/src/daemon/session.rs index a2ba00c..9714133 100644 --- a/crates/pilotty-cli/src/daemon/session.rs +++ b/crates/pilotty-cli/src/daemon/session.rs @@ -14,7 +14,7 @@ use tracing::{debug, info}; use pilotty_core::elements::classify::{detect, ClassifyContext}; use pilotty_core::elements::Element; use pilotty_core::error::ApiError; -use pilotty_core::protocol::SessionInfo; +use pilotty_core::protocol::{RetentionAccounting, SessionInfo, SessionStatus}; use pilotty_core::snapshot::{compute_content_hash, CursorState, ScreenState, TerminalSize}; use crate::daemon::pty::{AsyncPtyHandle, PtySession, TermSize}; @@ -168,6 +168,26 @@ impl Session { } } + async fn status(&self) -> SessionStatus { + let pump_state = self.pump_state(); + let size = self.observed_terminal.lock().await.size; + let retention = self.retention.lock().await.snapshot(); + + SessionStatus::Running { + id: self.id.0.clone(), + name: self.name.clone(), + command: self.command.clone(), + cwd: self.cwd.clone(), + created_at: self.created_at.to_rfc3339(), + size: TerminalSize { + cols: size.cols, + rows: size.rows, + }, + idle_ms: duration_millis(pump_state.last_output_at.elapsed()), + retention: retention_accounting(&retention), + } + } + /// Check if terminal is in application cursor mode. async fn application_cursor(&self) -> bool { self.observed_terminal @@ -723,6 +743,24 @@ impl SessionManager { } } + /// Report live runtime state or finalized evidence through one lifecycle seam. + pub(crate) async fn session_status( + &self, + identifier: Option<&str>, + ) -> Result { + let evidence = self.resolve_evidence(identifier).await?; + match evidence { + SessionEvidence::Live(id) => match self.session(&id).await { + Ok(session) => Ok(session.status().await), + Err(_) => match self.resolve_evidence(identifier).await? { + SessionEvidence::Live(id) => Ok(self.session(&id).await?.status().await), + SessionEvidence::Exited(tombstone) => Ok(tombstone_status(*tombstone)), + }, + }, + SessionEvidence::Exited(tombstone) => Ok(tombstone_status(*tombstone)), + } + } + async fn resolve_tombstone(&self, identifier: &str) -> Option { let mut tombstones = self.tombstones.lock().await; let now = Instant::now(); @@ -906,6 +944,37 @@ fn exit_status_description(session: &Session) -> String { } } +fn duration_millis(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +fn retention_accounting(retention: &RetentionSnapshot) -> RetentionAccounting { + RetentionAccounting { + total_bytes: retention.total_bytes, + retained_bytes: retention.retained_bytes, + dropped_bytes: retention.dropped_bytes, + truncated: retention.truncated, + } +} + +fn tombstone_status(tombstone: Tombstone) -> SessionStatus { + SessionStatus::Exited { + id: tombstone.id.0, + name: tombstone.name, + command: tombstone.command, + cwd: tombstone.cwd, + created_at: tombstone.created_at.to_rfc3339(), + ended_at: tombstone.ended_at.to_rfc3339(), + size: tombstone.final_screen.size, + exit_code: tombstone.exit.code, + signal: tombstone.exit.signal, + success: tombstone.exit.success, + killed_by_client: tombstone.exit.killed_by_client, + output_complete: tombstone.output_complete, + retention: retention_accounting(&tombstone.output), + } +} + #[cfg(test)] mod tests { use super::*; @@ -1605,6 +1674,187 @@ mod tests { manager.kill_session(&id).await.expect("remove session"); } + #[tokio::test] + async fn status_tracks_live_session_through_failed_finalization() { + let manager = Arc::new(SessionManager::with_default_retain_bytes(4)); + let id = manager + .create_session( + vec![ + "sh".to_string(), + "-c".to_string(), + "printf abcdef; sleep 1; exit 7".to_string(), + ], + Some("status-lifecycle".to_string()), + Some(TermSize { + cols: 100, + rows: 30, + }), + Some("/tmp".to_string()), + ) + .await + .expect("create lifecycle session"); + manager.spawn_cleaner(); + + let running = tokio::time::timeout(Duration::from_secs(2), async { + loop { + let status = manager + .session_status(Some("status-lifecycle")) + .await + .expect("read live status"); + if matches!( + &status, + SessionStatus::Running { + retention: RetentionAccounting { total_bytes: 6, .. }, + .. + } + ) { + break status; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("live output retained"); + + match running { + SessionStatus::Running { + id: status_id, + name, + command, + cwd, + size, + retention, + .. + } => { + assert_eq!(status_id, id.0); + assert_eq!(name.as_deref(), Some("status-lifecycle")); + assert_eq!(command, vec!["sh", "-c", "printf abcdef; sleep 1; exit 7"]); + assert_eq!(cwd.as_deref(), Some("/tmp")); + assert_eq!( + size, + TerminalSize { + cols: 100, + rows: 30 + } + ); + assert_eq!(retention.total_bytes, 6); + assert_eq!(retention.retained_bytes, 4); + assert_eq!(retention.dropped_bytes, 2); + assert!(retention.truncated); + } + SessionStatus::Exited { .. } => panic!("session should still be running"), + } + + let exited = tokio::time::timeout(Duration::from_secs(3), async { + loop { + if let Ok(status @ SessionStatus::Exited { .. }) = + manager.session_status(Some("status-lifecycle")).await + { + break status; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("session finalization"); + + match exited { + SessionStatus::Exited { + id: status_id, + name, + command, + cwd, + created_at, + ended_at, + size, + exit_code, + signal, + success, + killed_by_client, + output_complete, + retention, + .. + } => { + assert_eq!(status_id, id.0); + assert_eq!(name.as_deref(), Some("status-lifecycle")); + assert_eq!(command, vec!["sh", "-c", "printf abcdef; sleep 1; exit 7"]); + assert_eq!(cwd.as_deref(), Some("/tmp")); + assert!(DateTime::parse_from_rfc3339(&created_at).is_ok()); + assert!(DateTime::parse_from_rfc3339(&ended_at).is_ok()); + assert_eq!( + size, + TerminalSize { + cols: 100, + rows: 30 + } + ); + assert_eq!(exit_code, Some(7)); + assert_eq!(signal, None); + assert!(!success); + assert!(!killed_by_client); + assert!(output_complete); + assert_eq!(retention.total_bytes, 6); + assert_eq!(retention.retained_bytes, 4); + assert_eq!(retention.dropped_bytes, 2); + assert!(retention.truncated); + } + SessionStatus::Running { .. } => panic!("session should be finalized"), + } + } + + #[tokio::test] + async fn status_reports_explicit_kill_and_live_name_precedence() { + let manager = SessionManager::new(); + let old_id = manager + .create_session( + vec!["cat".to_string()], + Some("status-reused".to_string()), + None, + None, + ) + .await + .expect("create old session"); + manager + .kill_session(&old_id) + .await + .expect("kill old session"); + + match manager + .session_status(Some(&old_id.0)) + .await + .expect("read killed status") + { + SessionStatus::Exited { + killed_by_client, .. + } => assert!(killed_by_client), + SessionStatus::Running { .. } => panic!("killed session should be finalized"), + } + + let new_id = manager + .create_session( + vec!["cat".to_string()], + Some("status-reused".to_string()), + None, + None, + ) + .await + .expect("reuse session name"); + + match manager + .session_status(Some("status-reused")) + .await + .expect("live name should win") + { + SessionStatus::Running { id, .. } => assert_eq!(id, new_id.0), + SessionStatus::Exited { .. } => panic!("live name must win over tombstone name"), + } + + manager + .kill_session(&new_id) + .await + .expect("remove new session"); + } + #[tokio::test] async fn test_is_empty() { let manager = SessionManager::new(); diff --git a/crates/pilotty-cli/src/main.rs b/crates/pilotty-cli/src/main.rs index 598e58a..e02c2be 100644 --- a/crates/pilotty-cli/src/main.rs +++ b/crates/pilotty-cli/src/main.rs @@ -93,6 +93,9 @@ fn cli_to_command(cli: &Cli) -> Option { Commands::Logs(args) => Some(Command::Logs { session: args.session.clone(), }), + Commands::Status(args) => Some(Command::Status { + session: args.session.clone(), + }), Commands::Resize(args) => Some(Command::Resize { cols: args.cols, rows: args.rows, diff --git a/crates/pilotty-core/src/error.rs b/crates/pilotty-core/src/error.rs index 11a8ef7..f3597d5 100644 --- a/crates/pilotty-core/src/error.rs +++ b/crates/pilotty-core/src/error.rs @@ -366,7 +366,7 @@ mod tests { let err = ApiError::session_exited("editor", "exit code 7"); assert_eq!(err.code, ErrorCode::SessionExited); - assert_eq!(err.minimum_protocol(), crate::protocol::PROTOCOL_VERSION); + assert_eq!(err.minimum_protocol(), crate::protocol::PROTOCOL_V2); assert!(err.message.contains("editor")); assert!(err.message.contains("exit code 7")); assert!(err diff --git a/crates/pilotty-core/src/protocol.rs b/crates/pilotty-core/src/protocol.rs index 16c6316..1c432df 100644 --- a/crates/pilotty-core/src/protocol.rs +++ b/crates/pilotty-core/src/protocol.rs @@ -3,26 +3,28 @@ use serde::{Deserialize, Serialize}; use crate::error::{ApiError, ErrorCode}; -use crate::snapshot::ScreenState; +use crate::snapshot::{ScreenState, TerminalSize}; /// Default timeout for snapshot await_change/settle operations (30 seconds). fn default_snapshot_timeout() -> u64 { 30000 } -/// Current daemon protocol version. -/// -/// Carried on every `Request` and `Response` so a client and an -/// already-running daemon can detect version skew (e.g. mid-upgrade). -/// Messages from binaries that predate versioning deserialize as version 0 -/// via `#[serde(default)]`. Bump when a change is incompatible; additive -/// fields and enum variants do not require a bump on their own, but a client -/// must treat a lower daemon version as "this command may not exist yet". -pub const PROTOCOL_VERSION: u32 = 1; - /// Protocol spoken by binaries that predate explicit versioning. pub const LEGACY_PROTOCOL_VERSION: u32 = 0; +/// Versioned envelope shipped in v0.0.8. +pub const PROTOCOL_V1: u32 = 1; + +/// Bounded retention and finalized-session evidence introduced for v0.0.9. +pub const PROTOCOL_V2: u32 = 2; + +/// Current daemon protocol advertised on every request and response. +/// +/// Historical minimum-version mappings below must use the stable version +/// constants, not this moving alias. +pub const PROTOCOL_VERSION: u32 = PROTOCOL_V2; + /// Whether an observed peer protocol satisfies a wire variant's requirement. pub fn supports_protocol(observed: u32, required: u32) -> bool { observed >= required @@ -39,7 +41,7 @@ impl ApiError { | ErrorCode::CommandFailed | ErrorCode::InvalidInput | ErrorCode::InternalError => LEGACY_PROTOCOL_VERSION, - ErrorCode::SessionExited => PROTOCOL_VERSION, + ErrorCode::SessionExited => PROTOCOL_V2, } } } @@ -134,6 +136,8 @@ pub enum Command { ListSessions, /// Get the retained raw output for a session. Logs { session: Option }, + /// Get live or finalized lifecycle status for a session. + Status { session: Option }, /// Resize the terminal. Resize { cols: u16, @@ -162,7 +166,8 @@ impl Command { retain_bytes: Some(_), .. } - | Self::Logs { .. } => PROTOCOL_VERSION, + | Self::Logs { .. } + | Self::Status { .. } => PROTOCOL_V2, Self::Spawn { retain_bytes: None, .. } @@ -283,6 +288,8 @@ pub enum ResponseData { dropped_bytes: u64, truncated: bool, }, + /// Live or finalized lifecycle status for a session. + Status(SessionStatus), } impl ResponseData { @@ -292,7 +299,7 @@ impl ResponseData { /// older client. pub fn minimum_protocol(&self) -> u32 { match self { - Self::Logs { .. } => PROTOCOL_VERSION, + Self::Logs { .. } | Self::Status(_) => PROTOCOL_V2, Self::ScreenState(_) | Self::Snapshot { .. } | Self::SessionCreated { .. } @@ -312,6 +319,46 @@ pub struct SessionInfo { pub created_at: String, } +/// Exact accounting for a session's bounded raw-output evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RetentionAccounting { + pub total_bytes: u64, + pub retained_bytes: u64, + pub dropped_bytes: u64, + pub truncated: bool, +} + +/// Lifecycle status and available evidence for a session. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum SessionStatus { + Running { + id: String, + name: Option, + command: Vec, + cwd: Option, + created_at: String, + size: TerminalSize, + idle_ms: u64, + retention: RetentionAccounting, + }, + Exited { + id: String, + name: Option, + command: Vec, + cwd: Option, + created_at: String, + ended_at: String, + size: TerminalSize, + exit_code: Option, + signal: Option, + success: bool, + killed_by_client: bool, + output_complete: bool, + retention: RetentionAccounting, + }, +} + #[cfg(test)] mod tests { use super::*; @@ -320,7 +367,7 @@ mod tests { fn request_serializes_with_protocol_version() { let request = Request::new("req-1", Command::ListSessions); let json = serde_json::to_string(&request).unwrap(); - assert!(json.contains("\"protocol\":1"), "got: {json}"); + assert!(json.contains("\"protocol\":2"), "got: {json}"); } #[test] @@ -337,9 +384,9 @@ mod tests { // A current client's request must still parse if a field is unknown // to the receiver; serde ignores unknown fields by default. Guard // against someone adding deny_unknown_fields later. - let json = r#"{"id":"req-1","command":{"action":"list_sessions"},"protocol":1,"future_field":true}"#; + let json = r#"{"id":"req-1","command":{"action":"list_sessions"},"protocol":2,"future_field":true}"#; let request: Request = serde_json::from_str(json).unwrap(); - assert_eq!(request.protocol, 1); + assert_eq!(request.protocol, PROTOCOL_V2); } #[test] @@ -389,9 +436,10 @@ mod tests { #[test] fn protocol_support_is_monotonic() { - assert!(supports_protocol(1, 0)); - assert!(supports_protocol(1, 1)); - assert!(!supports_protocol(0, 1)); + assert!(supports_protocol(PROTOCOL_V2, LEGACY_PROTOCOL_VERSION)); + assert!(supports_protocol(PROTOCOL_V2, PROTOCOL_V1)); + assert!(supports_protocol(PROTOCOL_V2, PROTOCOL_V2)); + assert!(!supports_protocol(PROTOCOL_V1, PROTOCOL_V2)); } #[test] @@ -409,7 +457,7 @@ mod tests { } #[test] - fn retention_commands_require_current_protocol() { + fn slice_a_wire_variants_require_protocol_v2() { let plain_spawn = Command::Spawn { command: vec!["sh".to_string()], session_name: None, @@ -422,13 +470,36 @@ mod tests { cwd: None, retain_bytes: Some(1024), }; + let status = SessionStatus::Running { + id: "session-1".to_string(), + name: Some("editor".to_string()), + command: vec!["vi".to_string(), "notes.txt".to_string()], + cwd: Some("/tmp".to_string()), + created_at: "2026-07-11T12:00:00Z".to_string(), + size: TerminalSize { + cols: 120, + rows: 40, + }, + idle_ms: 250, + retention: RetentionAccounting { + total_bytes: 12, + retained_bytes: 10, + dropped_bytes: 2, + truncated: true, + }, + }; assert_eq!(plain_spawn.minimum_protocol(), LEGACY_PROTOCOL_VERSION); - assert_eq!(configured_spawn.minimum_protocol(), PROTOCOL_VERSION); + assert_eq!(configured_spawn.minimum_protocol(), PROTOCOL_V2); assert_eq!( Command::Logs { session: None }.minimum_protocol(), - PROTOCOL_VERSION + PROTOCOL_V2 + ); + assert_eq!( + Command::Status { session: None }.minimum_protocol(), + PROTOCOL_V2 ); + assert_eq!(ResponseData::Status(status).minimum_protocol(), PROTOCOL_V2); } #[test] @@ -441,12 +512,42 @@ mod tests { truncated: true, }; - assert_eq!(response.minimum_protocol(), PROTOCOL_VERSION); + assert_eq!(response.minimum_protocol(), PROTOCOL_V2); let json = serde_json::to_string(&response).expect("serialize logs response"); let decoded: ResponseData = serde_json::from_str(&json).expect("deserialize logs response"); assert_eq!(decoded, response); } + #[test] + fn exited_status_round_trips_with_stable_state_and_evidence_fields() { + let response = ResponseData::Status(SessionStatus::Exited { + id: "session-1".to_string(), + name: Some("job".to_string()), + command: vec!["sh".to_string(), "-c".to_string(), "exit 7".to_string()], + cwd: None, + created_at: "2026-07-11T12:00:00Z".to_string(), + ended_at: "2026-07-11T12:00:01Z".to_string(), + size: TerminalSize { cols: 80, rows: 24 }, + exit_code: Some(7), + signal: None, + success: false, + killed_by_client: false, + output_complete: true, + retention: RetentionAccounting { + total_bytes: 70_000, + retained_bytes: 65_536, + dropped_bytes: 4_464, + truncated: true, + }, + }); + + let json = serde_json::to_string(&response).expect("serialize exited status"); + assert!(json.contains("\"type\":\"status\""), "got: {json}"); + assert!(json.contains("\"state\":\"exited\""), "got: {json}"); + let decoded: ResponseData = serde_json::from_str(&json).expect("deserialize exited status"); + assert_eq!(decoded, response); + } + #[test] fn legacy_spawn_without_retention_field_uses_daemon_default() { let json = r#"{"action":"spawn","command":["sh"],"session_name":null,"cwd":null}"#; diff --git a/npm/README.md b/npm/README.md index 68cde68..db0f597 100644 --- a/npm/README.md +++ b/npm/README.md @@ -62,6 +62,10 @@ pilotty click 10 5 # List active sessions pilotty list-sessions +# Inspect a live or recently exited session +pilotty status +pilotty status -s myapp + # Stop the daemon pilotty stop ```