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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ pilotty stop
pilotty spawn <command> # Spawn a TUI app (e.g., pilotty spawn vim file.txt)
pilotty spawn --name myapp <cmd> # Spawn with a custom session name
pilotty spawn --cwd /path cmd # Spawn in a specific working directory
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
Expand All @@ -115,6 +116,18 @@ pilotty daemon # Manually start daemon (usually auto-starts)
pilotty examples # Show end-to-end workflow example
```

### Retained Output

```bash
pilotty logs # Raw output bytes from the default session
pilotty logs -s myapp # Raw output bytes from a named session
pilotty logs -s myapp > output.bin # Save raw ANSI evidence without metadata
```

`logs` writes retained output bytes to stdout and exact retention accounting to stderr.
Retention is bounded to 2 MiB per session by default, and reports when older bytes were
dropped.

### Screen Capture

```bash
Expand Down Expand Up @@ -397,6 +410,7 @@ All errors include AI-friendly suggestions:
|----------|-------------|
| `PILOTTY_SESSION` | Default session name |
| `PILOTTY_SOCKET_DIR` | Override socket directory |
| `PILOTTY_RETAIN_BYTES` | Default retained raw output bytes per session (default: 2 MiB) |
| `RUST_LOG` | Logging level (e.g., `debug`, `info`) |

## Usage with AI Agents
Expand Down
34 changes: 34 additions & 0 deletions crates/pilotty-cli/src/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ Examples:
/// List all active sessions
ListSessions,

/// Print retained raw output for a session
Logs(LogsArgs),

/// Resize the terminal
Resize(ResizeArgs),

Expand Down Expand Up @@ -135,6 +138,10 @@ pub struct SpawnArgs {
/// Working directory for the spawned process [default: current directory]
#[arg(long, value_name = "DIR")]
pub cwd: Option<String>,

/// Maximum raw output bytes retained for this session
#[arg(long, value_name = "BYTES")]
pub retain_bytes: Option<u64>,
}

#[derive(Debug, clap::Args)]
Expand All @@ -144,6 +151,13 @@ pub struct KillArgs {
pub session: Option<String>,
}

#[derive(Debug, clap::Args)]
pub struct LogsArgs {
/// 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 Expand Up @@ -305,4 +319,24 @@ mod tests {
_ => panic!("Expected spawn command"),
}
}

#[test]
fn spawn_parses_retention_override() {
let cli = Cli::parse_from(["pilotty", "spawn", "--retain-bytes", "4096", "sh"]);

match cli.command {
Commands::Spawn(args) => assert_eq!(args.retain_bytes, Some(4096)),
_ => panic!("Expected spawn command"),
}
}

#[test]
fn logs_parses_session_target() {
let cli = Cli::parse_from(["pilotty", "logs", "--session", "editor"]);

match cli.command {
Commands::Logs(args) => assert_eq!(args.session.as_deref(), Some("editor")),
_ => panic!("Expected logs command"),
}
}
}
65 changes: 64 additions & 1 deletion crates/pilotty-cli/src/daemon/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,8 @@ impl DaemonClient {
mod tests {
use super::*;
use crate::daemon::server::DaemonServer;
use pilotty_core::protocol::{Command, PROTOCOL_VERSION};
use pilotty_core::protocol::{Command, ResponseData, PROTOCOL_VERSION};
use tokio::net::UnixListener;

#[tokio::test]
async fn test_client_connects_to_running_daemon() {
Expand Down Expand Up @@ -304,4 +305,66 @@ mod tests {
ProtocolAction::Send
);
}

#[tokio::test]
async fn old_daemon_is_probed_and_logs_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);
let listener = UnixListener::bind(&socket_path).expect("bind protocol fixture");
let peer = tokio::spawn(async move {
let (stream, _) = listener.accept().await.expect("accept client");
let (reader, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader);
let mut line = String::new();
reader.read_line(&mut line).await.expect("read probe");
let probe: Request = serde_json::from_str(&line).expect("parse probe");
assert_eq!(probe.command, Command::ListSessions);

let response = Response {
id: probe.id,
success: true,
data: Some(ResponseData::Sessions { sessions: vec![] }),
error: None,
protocol: LEGACY_PROTOCOL_VERSION,
};
writer
.write_all(
serde_json::to_string(&response)
.expect("serialize response")
.as_bytes(),
)
.await
.expect("write response");
writer.write_all(b"\n").await.expect("write newline");
writer.flush().await.expect("flush response");

line.clear();
assert!(
timeout(Duration::from_millis(100), reader.read_line(&mut line))
.await
.is_err(),
"version-gated command must not reach an old daemon"
);
});

let stream = UnixStream::connect(&socket_path)
.await
.expect("connect to protocol fixture");
let mut client = DaemonClient {
stream,
daemon_protocol: None,
};
let error = client
.request(Request::new(
"logs-request",
Command::Logs { session: None },
))
.await
.expect_err("old daemon must be rejected");

assert!(error.to_string().contains("protocol 1"));
peer.await.expect("protocol fixture task");
let _ = std::fs::remove_file(&socket_path);
}
}
1 change: 1 addition & 0 deletions crates/pilotty-cli/src/daemon/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
pub mod client;
pub mod paths;
pub mod pty;
pub mod retention;
pub mod server;
pub mod session;
pub mod terminal;
120 changes: 120 additions & 0 deletions crates/pilotty-cli/src/daemon/retention.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! Bounded retention of raw PTY output.

use std::collections::VecDeque;

/// Default number of raw output bytes retained for each session.
pub(crate) const DEFAULT_RETAIN_BYTES: usize = 2 * 1024 * 1024;

/// A point-in-time copy of a session's retained raw output and accounting.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RetentionSnapshot {
pub(crate) bytes: Vec<u8>,
pub(crate) total_bytes: u64,
pub(crate) retained_bytes: u64,
pub(crate) dropped_bytes: u64,
pub(crate) truncated: bool,
}

/// Bounded tail of raw PTY output.
pub(crate) struct RetentionRing {
bytes: VecDeque<u8>,
capacity: usize,
total_bytes: u64,
}

impl RetentionRing {
pub(crate) fn new(capacity: usize) -> Self {
Self {
bytes: VecDeque::new(),
capacity,
total_bytes: 0,
}
}

pub(crate) fn append(&mut self, output: &[u8]) {
let output_len = u64::try_from(output.len()).unwrap_or(u64::MAX);
self.total_bytes = self.total_bytes.saturating_add(output_len);

if self.capacity == 0 {
self.bytes.clear();
return;
}

if output.len() >= self.capacity {
self.bytes.clear();
self.bytes
.extend(output[output.len() - self.capacity..].iter().copied());
return;
}

let excess = self
.bytes
.len()
.saturating_add(output.len())
.saturating_sub(self.capacity);
if excess > 0 {
self.bytes.drain(..excess);
}
self.bytes.extend(output.iter().copied());
}

pub(crate) fn snapshot(&self) -> RetentionSnapshot {
let bytes: Vec<u8> = self.bytes.iter().copied().collect();
let retained_bytes = u64::try_from(bytes.len()).unwrap_or(u64::MAX);
let dropped_bytes = self.total_bytes.saturating_sub(retained_bytes);
RetentionSnapshot {
bytes,
total_bytes: self.total_bytes,
retained_bytes,
dropped_bytes,
truncated: dropped_bytes > 0,
}
}
}

#[cfg(test)]
mod tests {
use crate::daemon::retention::RetentionRing;

#[test]
fn retains_only_the_newest_bytes_with_exact_accounting() {
let mut retention = RetentionRing::new(5);

retention.append(b"abc");
retention.append(b"defg");

let snapshot = retention.snapshot();
assert_eq!(snapshot.bytes, b"cdefg");
assert_eq!(snapshot.total_bytes, 7);
assert_eq!(snapshot.retained_bytes, 5);
assert_eq!(snapshot.dropped_bytes, 2);
assert!(snapshot.truncated);
}

#[test]
fn oversized_append_keeps_only_its_tail() {
let mut retention = RetentionRing::new(4);

retention.append(b"old");
retention.append(b"123456");

let snapshot = retention.snapshot();
assert_eq!(snapshot.bytes, b"3456");
assert_eq!(snapshot.total_bytes, 9);
assert_eq!(snapshot.dropped_bytes, 5);
}

#[test]
fn zero_capacity_counts_every_byte_as_dropped() {
let mut retention = RetentionRing::new(0);

retention.append(b"evidence");

let snapshot = retention.snapshot();
assert!(snapshot.bytes.is_empty());
assert_eq!(snapshot.total_bytes, 8);
assert_eq!(snapshot.retained_bytes, 0);
assert_eq!(snapshot.dropped_bytes, 8);
assert!(snapshot.truncated);
}
}
Loading
Loading