refactor: make sessions the first-class trace container

Session start gates automatic per-process trace segments so agents no
longer need --trace on every command, while bare --session stays
namespace-only for existing callers.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lahfir 2026-06-30 21:38:43 -07:00
parent c454f4ab1c
commit c1530d5742
25 changed files with 1762 additions and 386 deletions

View file

@ -47,6 +47,7 @@ pub mod screenshot;
pub mod scroll;
pub mod scroll_to;
pub mod select;
pub mod session;
pub mod set_value;
pub mod skills;
pub mod snapshot;

View file

@ -0,0 +1,81 @@
use crate::error::AppError;
use crate::session::{
GcOptions, SessionTraceMode, StartSessionOptions, end_session, gc, list_sessions, start_session,
};
use serde_json::{Value, json};
use std::time::Duration;
#[derive(Debug, Clone)]
pub enum SessionAction {
Start {
name: Option<String>,
no_trace: bool,
force: bool,
},
End {
id: Option<String>,
},
List,
Gc {
older_than_secs: Option<u64>,
ended_only: bool,
},
}
pub fn execute(action: SessionAction) -> Result<Value, AppError> {
match action {
SessionAction::Start {
name,
no_trace,
force,
} => {
let manifest = start_session(StartSessionOptions {
name,
trace: if no_trace {
SessionTraceMode::Off
} else {
SessionTraceMode::On
},
force,
})?;
Ok(json!({
"session_id": manifest.id,
"name": manifest.name,
"trace": manifest.trace,
"created_at": manifest.created_at,
}))
}
SessionAction::End { id } => {
let manifest = end_session(id.as_deref())?;
Ok(json!({
"session_id": manifest.id,
"ended_at": manifest.ended_at,
}))
}
SessionAction::List => {
let sessions: Vec<Value> = list_sessions()?
.into_iter()
.map(|manifest| {
json!({
"session_id": manifest.id,
"name": manifest.name,
"created_at": manifest.created_at,
"ended_at": manifest.ended_at,
"trace": manifest.trace,
})
})
.collect();
Ok(json!({ "sessions": sessions }))
}
SessionAction::Gc {
older_than_secs,
ended_only,
} => {
let report = gc(GcOptions {
ended_only,
older_than: older_than_secs.map(Duration::from_secs),
})?;
Ok(json!({ "removed": report.removed }))
}
}
}

View file

@ -5,6 +5,7 @@ use crate::{
context::CommandContext,
error::AppError,
refs_store::RefStore,
session::{read_current_session_pointer, trace_enabled_for_session},
};
use serde_json::{Value, json};
@ -22,13 +23,23 @@ pub fn execute_with_report_with_context(
.and_then(|s| s.load_latest().ok())
.map(|m| m.len());
let snapshot_id = store.and_then(|s| s.latest_snapshot_id());
let session_id = context
.session_id()
.map(str::to_string)
.or_else(|| read_current_session_pointer().ok().flatten());
let tracing = context.trace_enabled()
|| session_id
.as_deref()
.is_some_and(|id| trace_enabled_for_session(id).unwrap_or(false));
Ok(json!({
"platform": std::env::consts::OS,
"version": env!("CARGO_PKG_VERSION"),
"permissions": permissions,
"snapshot_id": snapshot_id,
"ref_count": ref_count
"ref_count": ref_count,
"session_id": session_id,
"tracing": tracing,
}))
}

View file

@ -1,17 +1,10 @@
use crate::{
action::Action, action_request::ActionRequest, error::AppError,
interaction_policy::InteractionPolicy, trace::TraceConfig,
interaction_policy::InteractionPolicy, refs_store::RefStore, session, trace::TraceConfig,
};
use serde_json::Value;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct WaitSelector {
pub query_raw: String,
pub gone: bool,
pub timeout_ms: u64,
}
#[derive(Debug, Clone, Default)]
pub struct CommandContext {
session_id: Option<String>,
@ -20,6 +13,13 @@ pub struct CommandContext {
wait_selector: Option<WaitSelector>,
}
#[derive(Debug, Clone)]
pub struct WaitSelector {
pub query_raw: String,
pub gone: bool,
pub timeout_ms: u64,
}
impl CommandContext {
pub fn new(
session_id: Option<String>,
@ -29,18 +29,15 @@ impl CommandContext {
if let Some(id) = session_id.as_deref() {
validate_session_id(id)?;
}
let segment_dir = session_segment_dir(session_id.as_deref(), trace_path.is_some())?;
Ok(Self {
session_id,
trace: TraceConfig::new(trace_path, trace_strict)?,
trace: TraceConfig::build(trace_path, segment_dir, trace_strict)?,
headed: false,
wait_selector: None,
})
}
/// Selects headed interaction: ref actions may move the cursor and steal
/// focus, unlocking the physical click/scroll/keypress fallbacks in the
/// action chain. Off by default — the tool is headless-first (Playwright
/// style: headless is the default, headed is opt-in via `--headed`).
pub fn with_headed(mut self, headed: bool) -> Self {
self.headed = headed;
self
@ -55,11 +52,6 @@ impl CommandContext {
self.wait_selector.as_ref()
}
/// Builds the action request for a ref command. Headless (default) uses the
/// command's own `base` policy — its minimum viable policy with no cursor
/// movement (most commands are pure-AX `headless`; `type` is `focus_fallback`
/// because typing requires focus). `--headed` upgrades any base to `headed`,
/// unlocking the cursor/OS-input fallbacks instead of failing closed.
pub fn request(&self, action: Action, base: InteractionPolicy) -> ActionRequest {
ActionRequest {
action,
@ -67,18 +59,11 @@ impl CommandContext {
}
}
/// Builds the action request using the action's canonical CLI base policy
/// from `Action::base_interaction_policy()`, ensuring a single source of
/// truth for the per-action minimum. `--headed` upgrades the base to
/// `headed` exactly as `request()` does.
pub fn request_base(&self, action: Action) -> ActionRequest {
let base = action.base_interaction_policy();
self.request(action, base)
}
/// Policy for raw physical-input commands (hover, drag, mouse-*). They
/// have no semantic action chain, so headless denies both focus stealing
/// and cursor movement unless `--headed` opts in.
pub fn physical_input_policy(&self) -> InteractionPolicy {
self.policy_with_base(InteractionPolicy::headless())
}
@ -96,9 +81,15 @@ impl CommandContext {
if let Some(id) = session_id.as_deref() {
validate_session_id(id)?;
}
let trace = if self.trace.pending_file_path().is_some() {
self.trace.clone()
} else {
let segment_dir = session_segment_dir(session_id.as_deref(), false)?;
self.trace.clone_with_session_segment(segment_dir)?
};
Ok(Self {
session_id,
trace: self.trace.clone(),
trace,
headed: self.headed,
wait_selector: None,
})
@ -116,6 +107,26 @@ impl CommandContext {
pub fn session_id(&self) -> Option<&str> {
self.session_id.as_deref()
}
pub fn trace_enabled(&self) -> bool {
self.trace.has_sink()
}
}
fn session_segment_dir(
session_id: Option<&str>,
explicit_trace: bool,
) -> Result<Option<PathBuf>, AppError> {
if explicit_trace {
return Ok(None);
}
let Some(session_id) = session_id else {
return Ok(None);
};
if !session::trace_enabled_for_session(session_id)? {
return Ok(None);
}
Ok(Some(RefStore::for_session(Some(session_id))?.trace_dir()))
}
pub fn validate_session_id(id: &str) -> Result<(), AppError> {
@ -134,235 +145,5 @@ pub fn validate_session_id(id: &str) -> Result<(), AppError> {
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_filesystem_safe_session_ids() {
assert!(validate_session_id("agent-1_A").is_ok());
}
#[test]
fn rejects_path_like_session_ids() {
assert!(validate_session_id("../agent").is_err());
assert!(validate_session_id("agent/a").is_err());
}
#[test]
fn trace_writes_jsonl_without_stdout_dependency() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-trace-{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let context = CommandContext::new(None, Some(path.clone()), false).unwrap();
context
.trace("ref.resolve.ok", serde_json::json!({ "ref": "@e1" }))
.unwrap();
let body = std::fs::read_to_string(&path).unwrap();
let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
assert_eq!(event["event"], "ref.resolve.ok");
assert_eq!(event["ref"], "@e1");
assert!(event["ts_ms"].as_u64().is_some());
assert!(
event.get("session_id").is_none(),
"session_id must be absent when not set"
);
let _ = std::fs::remove_file(path);
}
#[test]
fn trace_injects_session_id_as_top_level_unredacted_field() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-session-trace-{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let context =
CommandContext::new(Some("my-session".into()), Some(path.clone()), false).unwrap();
context
.trace("ref.resolve.ok", serde_json::json!({ "ref": "@e1" }))
.unwrap();
let body = std::fs::read_to_string(&path).unwrap();
let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
assert_eq!(
event["session_id"], "my-session",
"session_id must be a top-level string"
);
assert_eq!(event["event"], "ref.resolve.ok");
assert!(event["ts_ms"].as_u64().is_some());
assert!(
!body.contains("redacted"),
"session_id must not be redacted"
);
let _ = std::fs::remove_file(path);
}
#[test]
fn trace_write_failure_is_best_effort_unless_strict() {
let missing = std::env::temp_dir()
.join("agent-desktop-missing-dir")
.join("trace.jsonl");
let best_effort = CommandContext::new(None, Some(missing.clone()), false).unwrap();
assert!(best_effort.trace("event", serde_json::json!({})).is_ok());
assert!(CommandContext::new(None, Some(missing), true).is_err());
}
#[test]
fn trace_lazy_does_not_build_fields_when_trace_is_disabled() {
let context = CommandContext::default();
let built = std::cell::Cell::new(false);
context
.trace_lazy("event", || {
built.set(true);
serde_json::json!({})
})
.unwrap();
assert!(!built.get());
}
#[cfg(unix)]
#[test]
fn trace_file_is_private_on_create() {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(format!(
"agent-desktop-private-trace-{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let context = CommandContext::new(None, Some(path.clone()), true).unwrap();
context.trace("event", serde_json::json!({})).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
let _ = std::fs::remove_file(path);
}
#[cfg(unix)]
#[test]
fn trace_rejects_loose_existing_file_permissions() {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(format!(
"agent-desktop-loose-trace-{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, "").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let err = CommandContext::new(None, Some(path.clone()), false).unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
let _ = std::fs::remove_file(path);
}
#[test]
fn trace_redacts_sensitive_text_and_value_fields() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-redacted-trace-{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let context = CommandContext::new(None, Some(path.clone()), true).unwrap();
context
.trace(
"event",
serde_json::json!({
"text": "secret",
"value": "hidden",
"name": "private label",
"description": "private desc",
"message": "diagnostic error",
"post_state": { "value": "deep secret" },
"target_label": "button secret",
"nested": { "expected": "token" },
"title": "private window title",
"url": "https://internal.example/doc",
"help": "private tooltip",
"placeholder": "private placeholder"
}),
)
.unwrap();
let body = std::fs::read_to_string(&path).unwrap();
let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
assert_eq!(event["text"]["redacted"], true);
assert_eq!(event["value"]["redacted"], true);
assert_eq!(event["name"]["redacted"], true);
assert_eq!(event["description"]["redacted"], true);
assert_eq!(event["message"], "diagnostic error");
assert_eq!(event["post_state"]["value"]["redacted"], true);
assert_eq!(event["target_label"]["redacted"], true);
assert_eq!(event["nested"]["expected"]["redacted"], true);
assert_eq!(event["title"]["redacted"], true);
assert_eq!(event["url"]["redacted"], true);
assert_eq!(event["help"]["redacted"], true);
assert_eq!(event["placeholder"]["redacted"], true);
assert!(!body.contains("secret"));
assert!(!body.contains("hidden"));
assert!(!body.contains("private label"));
assert!(!body.contains("private desc"));
assert!(!body.contains("token"));
assert!(!body.contains("private window title"));
assert!(!body.contains("internal.example"));
assert!(!body.contains("private tooltip"));
assert!(!body.contains("private placeholder"));
let _ = std::fs::remove_file(path);
}
#[test]
fn trace_strict_requires_trace_path() {
let err = CommandContext::new(None, None, true).unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
}
#[test]
fn batch_item_clears_wait_selector() {
let parent = CommandContext::default().with_wait_selector(Some(WaitSelector {
query_raw: "button:OK".into(),
gone: false,
timeout_ms: 5_000,
}));
let child = parent.for_batch_item(None).unwrap();
assert!(child.wait_selector().is_none());
}
#[test]
fn batch_item_inherits_or_overrides_session_without_trace_loss() {
let path = std::env::temp_dir().join("agent-desktop-context-test.jsonl");
let _ = std::fs::remove_file(&path);
let parent = CommandContext::new(Some("parent".into()), Some(path.clone()), false).unwrap();
let inherited = parent.for_batch_item(None).unwrap();
let overridden = parent.for_batch_item(Some("child".into())).unwrap();
assert_eq!(inherited.session_id(), Some("parent"));
assert_eq!(overridden.session_id(), Some("child"));
overridden
.trace("batch.child", serde_json::json!({ "ok": true }))
.unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains("batch.child"));
let _ = std::fs::remove_file(path);
}
}
#[path = "context_tests.rs"]
mod tests;

View file

@ -0,0 +1,324 @@
use super::*;
use crate::session::{
SessionTraceMode, StartSessionOptions, start_session, trace_enabled_for_session,
};
use serde_json::json;
#[test]
fn accepts_filesystem_safe_session_ids() {
assert!(validate_session_id("agent-1_A").is_ok());
}
#[test]
fn rejects_path_like_session_ids() {
assert!(validate_session_id("../agent").is_err());
assert!(validate_session_id("agent/a").is_err());
}
#[test]
fn trace_writes_jsonl_without_stdout_dependency() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-trace-{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let context = CommandContext::new(None, Some(path.clone()), false).unwrap();
context
.trace("ref.resolve.ok", json!({ "ref": "@e1" }))
.unwrap();
let body = std::fs::read_to_string(&path).unwrap();
let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
assert_eq!(event["event"], "ref.resolve.ok");
assert_eq!(event["ref"], "@e1");
assert!(event["ts_ms"].as_u64().is_some());
assert!(event.get("session_id").is_none());
let _ = std::fs::remove_file(path);
}
#[test]
fn trace_injects_session_id_as_top_level_unredacted_field() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-session-trace-{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let context =
CommandContext::new(Some("my-session".into()), Some(path.clone()), false).unwrap();
context
.trace("ref.resolve.ok", json!({ "ref": "@e1" }))
.unwrap();
let body = std::fs::read_to_string(&path).unwrap();
let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
assert_eq!(event["session_id"], "my-session");
assert_eq!(event["event"], "ref.resolve.ok");
assert!(event["ts_ms"].as_u64().is_some());
assert!(!body.contains("redacted"));
let _ = std::fs::remove_file(path);
}
#[test]
fn trace_write_failure_is_best_effort_unless_strict() {
let missing = std::env::temp_dir()
.join("agent-desktop-missing-dir")
.join("trace.jsonl");
let best_effort = CommandContext::new(None, Some(missing.clone()), false).unwrap();
assert!(best_effort.trace("event", json!({})).is_ok());
assert!(CommandContext::new(None, Some(missing), false).is_ok());
}
#[test]
fn trace_lazy_does_not_build_fields_when_trace_is_disabled() {
let context = CommandContext::default();
let built = std::cell::Cell::new(false);
context
.trace_lazy("event", || {
built.set(true);
json!({})
})
.unwrap();
assert!(!built.get());
}
#[cfg(unix)]
#[test]
fn trace_file_is_private_on_create() {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(format!(
"agent-desktop-private-trace-{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let context = CommandContext::new(None, Some(path.clone()), true).unwrap();
context.trace("event", json!({})).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
let _ = std::fs::remove_file(path);
}
#[cfg(unix)]
#[test]
fn trace_rejects_loose_existing_file_permissions() {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(format!(
"agent-desktop-loose-trace-{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, "").unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
let context = CommandContext::new(None, Some(path.clone()), false).unwrap();
let err = context.trace("event", json!({})).unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
let _ = std::fs::remove_file(path);
}
#[test]
fn trace_redacts_sensitive_text_and_value_fields() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-redacted-trace-{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let context = CommandContext::new(None, Some(path.clone()), true).unwrap();
context
.trace(
"event",
json!({
"text": "secret",
"value": "hidden",
"name": "private label",
"description": "private desc",
"message": "diagnostic error",
"post_state": { "value": "deep secret" },
"target_label": "button secret",
"nested": { "expected": "token" },
"title": "private window title",
"url": "https://internal.example/doc",
"help": "private tooltip",
"placeholder": "private placeholder"
}),
)
.unwrap();
let body = std::fs::read_to_string(&path).unwrap();
let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
assert_eq!(event["text"]["redacted"], true);
assert_eq!(event["value"]["redacted"], true);
assert_eq!(event["message"], "diagnostic error");
assert!(!body.contains("secret"));
let _ = std::fs::remove_file(path);
}
#[test]
fn trace_strict_requires_trace_path_or_trace_session() {
let err = CommandContext::new(None, None, true).unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
}
#[test]
fn batch_item_clears_wait_selector() {
let parent = CommandContext::default().with_wait_selector(Some(WaitSelector {
query_raw: "button:OK".into(),
gone: false,
timeout_ms: 5_000,
}));
let child = parent.for_batch_item(None).unwrap();
assert!(child.wait_selector().is_none());
}
#[test]
fn batch_item_inherits_or_overrides_session_without_trace_loss() {
let path = std::env::temp_dir().join("agent-desktop-context-test.jsonl");
let _ = std::fs::remove_file(&path);
let parent = CommandContext::new(Some("parent".into()), Some(path.clone()), false).unwrap();
let inherited = parent.for_batch_item(None).unwrap();
let overridden = parent.for_batch_item(Some("child".into())).unwrap();
assert_eq!(inherited.session_id(), Some("parent"));
assert_eq!(overridden.session_id(), Some("child"));
overridden
.trace("batch.child", json!({ "ok": true }))
.unwrap();
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains("batch.child"));
let _ = std::fs::remove_file(path);
}
#[test]
fn bare_session_without_manifest_does_not_trace() {
let _guard = crate::refs_test_support::HomeGuard::new();
let context = CommandContext::new(Some("legacy-session".into()), None, false).unwrap();
assert!(!context.trace_enabled());
context.trace("event", json!({})).unwrap();
let trace_dir = crate::refs_store::RefStore::for_session(Some("legacy-session"))
.unwrap()
.trace_dir();
assert!(!trace_dir.exists());
}
#[test]
fn trace_on_session_writes_segment_without_explicit_trace_flag() {
let _guard = crate::refs_test_support::HomeGuard::new();
let manifest = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
force: false,
})
.unwrap();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
assert!(context.trace_enabled());
context
.trace("session.event", json!({ "ok": true }))
.unwrap();
let trace_dir = crate::refs_store::RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
let entries: Vec<_> = std::fs::read_dir(trace_dir).unwrap().flatten().collect();
assert_eq!(entries.len(), 1);
}
#[test]
fn no_trace_session_still_namespaces_snapshots() {
let _guard = crate::refs_test_support::HomeGuard::new();
let manifest = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::Off,
force: false,
})
.unwrap();
assert!(!trace_enabled_for_session(&manifest.id).unwrap());
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
assert!(!context.trace_enabled());
assert_eq!(context.session_id(), Some(manifest.id.as_str()));
}
#[test]
fn explicit_trace_overrides_session_sink() {
let _guard = crate::refs_test_support::HomeGuard::new();
let manifest = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
force: false,
})
.unwrap();
let path = std::env::temp_dir().join(format!(
"agent-desktop-override-{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let context =
CommandContext::new(Some(manifest.id.clone()), Some(path.clone()), false).unwrap();
context.trace("override.event", json!({})).unwrap();
assert!(path.exists());
let trace_dir = crate::refs_store::RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
let segment_count = std::fs::read_dir(trace_dir)
.map(|entries| entries.flatten().count())
.unwrap_or(0);
assert_eq!(segment_count, 0);
let _ = std::fs::remove_file(path);
}
#[test]
fn batch_item_session_override_uses_its_own_segment_dir() {
let _guard = crate::refs_test_support::HomeGuard::new();
let parent_session = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
force: false,
})
.unwrap();
let child_session = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
force: true,
})
.unwrap();
let parent = CommandContext::new(Some(parent_session.id.clone()), None, false).unwrap();
let child = parent
.for_batch_item(Some(child_session.id.clone()))
.unwrap();
child.trace("child.event", json!({})).unwrap();
let parent_trace = crate::refs_store::RefStore::for_session(Some(&parent_session.id))
.unwrap()
.trace_dir();
let parent_segments = std::fs::read_dir(&parent_trace)
.map(|entries| {
entries
.flatten()
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "jsonl"))
.count()
})
.unwrap_or(0);
assert_eq!(parent_segments, 0);
let child_trace = crate::refs_store::RefStore::for_session(Some(&child_session.id))
.unwrap()
.trace_dir();
assert!(child_trace.is_dir());
}

View file

@ -28,6 +28,7 @@ mod refs_test_support;
pub(crate) mod resolved_element;
pub mod roles;
pub(crate) mod search_text;
pub mod session;
pub mod snapshot;
pub mod snapshot_ref;
pub(crate) mod trace;

View file

@ -186,6 +186,13 @@ fn process_is_alive(_pid: u32) -> Option<bool> {
None
}
pub(crate) fn lock_holder_is_live(lock_path: &Path) -> bool {
match LockSnapshot::read(lock_path) {
Ok(Some(snapshot)) => !snapshot.is_stale(),
Ok(None) | Err(()) => false,
}
}
impl Drop for RefStoreLock {
fn drop(&mut self) {
let should_remove = LockSnapshot::read(&self.path)

View file

@ -226,6 +226,14 @@ impl RefStore {
.join("refmap.json")
}
pub(crate) fn base_dir(&self) -> &Path {
&self.base_dir
}
pub(crate) fn trace_dir(&self) -> PathBuf {
self.base_dir.join("trace")
}
fn snapshots_dir(&self) -> PathBuf {
self.base_dir.join("snapshots")
}

View file

@ -367,3 +367,40 @@ fn duplicate_snapshot_id_across_sessions_is_rejected_on_load() {
assert_eq!(err.code(), "INVALID_ARGS");
assert!(err.to_string().contains("more than one session"));
}
#[test]
fn trace_dir_points_under_session_base() {
let _guard = HomeGuard::new();
let store = RefStore::for_session(Some("run-42")).unwrap();
assert_eq!(store.trace_dir(), store.base_dir().join("trace"));
}
#[test]
fn trace_dir_accessors_create_no_directories() {
let _guard = HomeGuard::new();
let store = RefStore::for_session(Some("run-42")).unwrap();
let _ = store.trace_dir();
assert!(
!store.trace_dir().exists(),
"trace_dir accessor must not create directories"
);
}
#[test]
fn prune_never_removes_trace_segments() {
let _guard = HomeGuard::new();
let store = RefStore::for_session(Some("trace-retention")).unwrap();
let trace_dir = store.trace_dir();
std::fs::create_dir_all(&trace_dir).unwrap();
let segment = trace_dir.join("1234-5678.jsonl");
std::fs::write(&segment, b"{}\n").unwrap();
for index in 0..=MAX_SAVED_SNAPSHOTS {
let snapshot_id = format!("snap-{index:04}");
store
.save_snapshot(&snapshot_id, &map_with(&snapshot_id))
.unwrap();
store.set_latest(&snapshot_id).unwrap();
}
assert!(segment.is_file());
assert!(trace_dir.is_dir());
}

View file

@ -0,0 +1,25 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionManifest {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub created_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ended_at: Option<u64>,
pub trace: SessionTraceMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SessionTraceMode {
On,
Off,
}
impl SessionManifest {
pub fn trace_enabled(&self) -> bool {
matches!(self.trace, SessionTraceMode::On) && self.ended_at.is_none()
}
}

View file

@ -0,0 +1,381 @@
mod manifest;
pub use manifest::{SessionManifest, SessionTraceMode};
use crate::{
context::validate_session_id,
error::AppError,
refs::{home_dir, write_private_file},
refs_lock::lock_holder_is_live,
refs_store::RefStore,
};
use serde_json;
use std::io::{ErrorKind, Read};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
const CURRENT_SESSION_FILE: &str = "current_session";
const SESSION_MANIFEST_FILE: &str = "session.json";
const TRACE_LIVENESS_WINDOW: Duration = Duration::from_secs(300);
static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
pub struct StartSessionOptions {
pub name: Option<String>,
pub trace: SessionTraceMode,
pub force: bool,
}
pub struct GcOptions {
pub ended_only: bool,
pub older_than: Option<Duration>,
}
#[derive(Debug)]
pub struct GcReport {
pub removed: Vec<String>,
}
pub fn agent_desktop_dir() -> Result<PathBuf, AppError> {
let home = home_dir().ok_or_else(|| AppError::Internal("HOME directory not found".into()))?;
Ok(home.join(".agent-desktop"))
}
pub fn session_dir(session_id: &str) -> Result<PathBuf, AppError> {
validate_session_id(session_id)?;
Ok(agent_desktop_dir()?.join("sessions").join(session_id))
}
pub fn trace_dir(session_id: &str) -> Result<PathBuf, AppError> {
Ok(RefStore::for_session(Some(session_id))?.trace_dir())
}
pub fn current_session_path() -> Result<PathBuf, AppError> {
Ok(agent_desktop_dir()?.join(CURRENT_SESSION_FILE))
}
pub fn resolve_active_session(
explicit: Option<&str>,
env: Option<&str>,
) -> Result<Option<String>, AppError> {
if let Some(id) = explicit {
validate_session_id(id)?;
return Ok(Some(id.to_string()));
}
if let Some(id) = env {
if id.is_empty() {
return Err(AppError::invalid_input_with_suggestion(
"AGENT_DESKTOP_SESSION must not be empty",
"Unset the variable or set it to a valid session id.",
));
}
validate_session_id(id)?;
return Ok(Some(id.to_string()));
}
read_current_session_pointer()
}
pub fn read_current_session_pointer() -> Result<Option<String>, AppError> {
let path = current_session_path()?;
let mut file = match open_session_file(&path) {
Ok(file) => file,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
let mut id = String::new();
file.read_to_string(&mut id)?;
let id = id.trim().to_string();
if id.is_empty() {
return Ok(None);
}
validate_session_id(&id)?;
Ok(Some(id))
}
pub fn write_current_session_pointer(session_id: &str) -> Result<(), AppError> {
validate_session_id(session_id)?;
write_private_file(&current_session_path()?, session_id.as_bytes())
}
pub fn clear_current_session_pointer() -> Result<(), AppError> {
match std::fs::remove_file(current_session_path()?) {
Ok(()) => Ok(()),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(()),
Err(err) => Err(err.into()),
}
}
pub fn read_manifest(session_id: &str) -> Result<Option<SessionManifest>, AppError> {
let path = manifest_path(session_id)?;
let mut file = match open_session_file(&path) {
Ok(file) => file,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
};
let mut json = String::new();
file.read_to_string(&mut json)?;
Ok(Some(serde_json::from_str(&json)?))
}
pub fn write_manifest(manifest: &SessionManifest) -> Result<(), AppError> {
validate_session_id(&manifest.id)?;
let json = serde_json::to_string_pretty(manifest)?;
write_private_file(&manifest_path(&manifest.id)?, json.as_bytes())
}
pub fn trace_enabled_for_session(session_id: &str) -> Result<bool, AppError> {
Ok(read_manifest(session_id)?.is_some_and(|manifest| manifest.trace_enabled()))
}
pub fn new_session_id() -> String {
let n = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed);
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
format!("run-{millis}-{n}")
}
pub fn validate_session_name(name: &str) -> Result<String, AppError> {
if name.is_empty() {
return Err(AppError::invalid_input_with_suggestion(
"Session name must not be empty",
"Omit --name or provide a short descriptive label.",
));
}
if name.len() > 128 {
return Err(AppError::invalid_input_with_suggestion(
"Session name must be at most 128 characters",
"Use a shorter session name.",
));
}
if name.chars().any(char::is_control) {
return Err(AppError::invalid_input_with_suggestion(
"Session name must not contain control characters",
"Use printable ASCII or Unicode text for --name.",
));
}
Ok(name.to_string())
}
pub fn pointer_references_live_session() -> Result<bool, AppError> {
let Some(id) = read_current_session_pointer()? else {
return Ok(false);
};
is_live(&id)
}
pub fn is_live(session_id: &str) -> Result<bool, AppError> {
validate_session_id(session_id)?;
let store = RefStore::for_session(Some(session_id))?;
if lock_holder_is_live(&store.base_dir().join("refstore.lock")) {
return Ok(true);
}
if trace_dir_recently_written(&store.trace_dir()) {
return Ok(true);
}
Ok(false)
}
pub fn list_sessions() -> Result<Vec<SessionManifest>, AppError> {
let sessions_root = agent_desktop_dir()?.join("sessions");
let Ok(entries) = std::fs::read_dir(sessions_root) else {
return Ok(Vec::new());
};
let mut manifests = Vec::new();
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if !file_type.is_dir() {
continue;
}
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if validate_session_id(name).is_err() {
continue;
}
if let Some(manifest) = read_manifest(name)? {
manifests.push(manifest);
}
}
manifests.sort_by_key(|manifest| manifest.created_at);
Ok(manifests)
}
pub fn start_session(options: StartSessionOptions) -> Result<SessionManifest, AppError> {
if !options.force && pointer_references_live_session()? {
return Err(AppError::invalid_input_with_suggestion(
"Refusing to clobber the current session pointer while it references a live session",
"Run `session end` first, set AGENT_DESKTOP_SESSION for concurrent work, or pass --force.",
));
}
let id = new_session_id();
validate_session_id(&id)?;
let name = options
.name
.map(|name| validate_session_name(&name))
.transpose()?;
let dir = session_dir(&id)?;
create_session_tree(&dir)?;
let manifest = SessionManifest {
id: id.clone(),
name,
created_at: now_millis(),
ended_at: None,
trace: options.trace,
};
write_manifest(&manifest)?;
write_current_session_pointer(&id)?;
Ok(manifest)
}
pub fn end_session(session_id: Option<&str>) -> Result<SessionManifest, AppError> {
let id = match session_id {
Some(id) => {
validate_session_id(id)?;
id.to_string()
}
None => read_current_session_pointer()?.ok_or_else(|| {
AppError::invalid_input_with_suggestion(
"No active session to end",
"Pass a session id or run `session start` first.",
)
})?,
};
let mut manifest = read_manifest(&id)?.ok_or_else(|| {
AppError::invalid_input_with_suggestion(
format!("Session '{id}' has no manifest"),
"Use `session list` to see known sessions.",
)
})?;
if manifest.ended_at.is_none() {
manifest.ended_at = Some(now_millis());
write_manifest(&manifest)?;
}
if read_current_session_pointer()?.as_deref() == Some(id.as_str()) {
clear_current_session_pointer()?;
}
Ok(manifest)
}
pub fn gc(options: GcOptions) -> Result<GcReport, AppError> {
let pointer = read_current_session_pointer()?;
let mut removed = Vec::new();
for manifest in list_sessions()? {
if pointer.as_deref() == Some(manifest.id.as_str()) {
continue;
}
if is_live(&manifest.id)? {
continue;
}
if options.ended_only && manifest.ended_at.is_none() {
continue;
}
if let Some(older_than) = options.older_than {
let age_reference = manifest.ended_at.unwrap_or(manifest.created_at);
let age_ms = now_millis().saturating_sub(age_reference);
if Duration::from_millis(age_ms) < older_than {
continue;
}
} else if manifest.ended_at.is_none() {
continue;
}
let dir = session_dir(&manifest.id)?;
if remove_session_dir(&dir)? {
removed.push(manifest.id);
}
}
Ok(GcReport { removed })
}
fn create_session_tree(dir: &Path) -> Result<(), AppError> {
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)?;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir.join("trace"))?;
}
#[cfg(not(unix))]
{
std::fs::create_dir_all(dir.join("trace"))?;
}
Ok(())
}
fn manifest_path(session_id: &str) -> Result<PathBuf, AppError> {
Ok(session_dir(session_id)?.join(SESSION_MANIFEST_FILE))
}
fn now_millis() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
fn trace_dir_recently_written(trace_dir: &Path) -> bool {
let Ok(entries) = std::fs::read_dir(trace_dir) else {
return false;
};
let cutoff = SystemTime::now()
.checked_sub(TRACE_LIVENESS_WINDOW)
.unwrap_or(UNIX_EPOCH);
entries.flatten().any(|entry| {
entry
.metadata()
.ok()
.and_then(|metadata| metadata.modified().ok())
.is_some_and(|modified| modified >= cutoff)
})
}
pub(crate) fn remove_session_dir(dir: &Path) -> Result<bool, AppError> {
if !dir.is_dir() {
return Ok(false);
}
if std::fs::symlink_metadata(dir)
.map(|meta| meta.file_type().is_symlink())
.unwrap_or(true)
{
return Err(AppError::invalid_input_with_suggestion(
"Refusing to remove a symlinked session directory",
"Remove the symlink manually before running session gc.",
));
}
std::fs::remove_dir_all(dir)?;
Ok(true)
}
fn open_session_file(path: &Path) -> std::io::Result<std::fs::File> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW)
.open(path)
}
#[cfg(not(unix))]
{
if std::fs::symlink_metadata(path)?.file_type().is_symlink() {
return Err(std::io::Error::new(
ErrorKind::PermissionDenied,
"session path must not be a symlink",
));
}
std::fs::File::open(path)
}
}
#[cfg(test)]
#[path = "session_tests.rs"]
mod tests;

View file

@ -0,0 +1,201 @@
use super::*;
use crate::refs_lock::RefStoreLock;
use crate::refs_test_support::HomeGuard;
use crate::session::SessionTraceMode;
use std::fs;
use std::time::Duration;
#[test]
fn resolve_prefers_explicit_over_env_and_pointer() {
let _guard = HomeGuard::new();
write_current_session_pointer("pointer").unwrap();
unsafe { std::env::set_var("AGENT_DESKTOP_SESSION", "env-session") };
let resolved = resolve_active_session(Some("explicit"), None).unwrap();
assert_eq!(resolved.as_deref(), Some("explicit"));
unsafe { std::env::remove_var("AGENT_DESKTOP_SESSION") };
}
#[test]
fn resolve_prefers_env_over_pointer() {
let _guard = HomeGuard::new();
write_current_session_pointer("pointer").unwrap();
unsafe { std::env::set_var("AGENT_DESKTOP_SESSION", "env-session") };
let resolved = resolve_active_session(None, Some("env-session")).unwrap();
assert_eq!(resolved.as_deref(), Some("env-session"));
unsafe { std::env::remove_var("AGENT_DESKTOP_SESSION") };
}
#[test]
fn resolve_falls_back_to_pointer() {
let _guard = HomeGuard::new();
write_current_session_pointer("pointer").unwrap();
let resolved = resolve_active_session(None, None).unwrap();
assert_eq!(resolved.as_deref(), Some("pointer"));
}
#[test]
fn resolve_none_without_pointer() {
let _guard = HomeGuard::new();
let resolved = resolve_active_session(None, None).unwrap();
assert!(resolved.is_none());
}
#[test]
fn manifest_round_trips_with_optional_fields() {
let _guard = HomeGuard::new();
let manifest = SessionManifest {
id: "run-1".into(),
name: Some("demo".into()),
created_at: 1,
ended_at: None,
trace: SessionTraceMode::On,
};
write_manifest(&manifest).unwrap();
let loaded = read_manifest("run-1").unwrap().expect("manifest");
assert_eq!(loaded, manifest);
}
#[test]
fn validate_session_name_rejects_control_chars() {
let err = validate_session_name("bad\u{1}name").unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
}
#[test]
fn start_creates_tree_manifest_and_pointer() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
name: Some("demo".into()),
trace: SessionTraceMode::On,
force: false,
})
.unwrap();
assert!(session_dir(&manifest.id).unwrap().join("trace").is_dir());
assert_eq!(
read_current_session_pointer().unwrap().as_deref(),
Some(manifest.id.as_str())
);
}
#[test]
fn start_refuses_live_pointer_without_force() {
let _guard = HomeGuard::new();
let first = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
force: false,
})
.unwrap();
let _lock = RefStoreLock::acquire(
&crate::refs_store::RefStore::for_session(Some(&first.id))
.unwrap()
.base_dir()
.join("refstore.lock"),
)
.unwrap();
let err = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
force: false,
})
.unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
}
#[test]
fn end_seals_manifest_and_clears_pointer() {
let _guard = HomeGuard::new();
let _manifest = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
force: false,
})
.unwrap();
let ended = end_session(None).unwrap();
assert!(ended.ended_at.is_some());
assert!(read_current_session_pointer().unwrap().is_none());
}
#[test]
fn gc_removes_ended_sessions_but_not_pointer_or_live() {
let _guard = HomeGuard::new();
let live = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
force: false,
})
.unwrap();
let ended = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
force: true,
})
.unwrap();
end_session(Some(&ended.id)).unwrap();
let report = gc(GcOptions {
ended_only: false,
older_than: None,
})
.unwrap();
assert!(report.removed.contains(&ended.id));
assert!(!report.removed.contains(&live.id));
assert!(session_dir(&live.id).unwrap().is_dir());
assert!(!session_dir(&ended.id).unwrap().exists());
}
#[test]
#[cfg(unix)]
fn remove_session_dir_rejects_symlink() {
let _guard = HomeGuard::new();
let dir = session_dir("symlink-session").unwrap();
let target = dir.with_extension("target");
fs::create_dir_all(&target).unwrap();
std::os::unix::fs::symlink(&target, &dir).unwrap();
let err = super::remove_session_dir(&dir).unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
}
#[test]
fn list_reports_manifest_fields_only() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
name: Some("listed".into()),
trace: SessionTraceMode::On,
force: false,
})
.unwrap();
let listed = list_sessions().unwrap();
assert!(listed.iter().any(|entry| entry.id == manifest.id));
}
#[test]
fn trace_enabled_requires_manifest_on() {
let _guard = HomeGuard::new();
assert!(!trace_enabled_for_session("missing").unwrap());
let manifest = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::Off,
force: false,
})
.unwrap();
assert!(!trace_enabled_for_session(&manifest.id).unwrap());
}
#[test]
fn gc_respects_older_than_threshold() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::Off,
force: false,
})
.unwrap();
end_session(Some(&manifest.id)).unwrap();
clear_current_session_pointer().unwrap();
let report = gc(GcOptions {
ended_only: false,
older_than: Some(Duration::from_secs(3600)),
})
.unwrap();
assert!(report.removed.is_empty());
}

View file

@ -1,37 +1,68 @@
use crate::error::AppError;
use serde_json::{Map, Value, json};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
const MAX_TRACE_FILE_BYTES: u64 = 64 * 1024 * 1024;
static EVENT_SEQ: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone)]
enum TracePending {
None,
File(PathBuf),
SegmentDir(PathBuf),
}
#[derive(Debug, Clone)]
struct TraceState {
pending: TracePending,
opened: Arc<Mutex<Option<Arc<Mutex<std::fs::File>>>>>,
}
#[derive(Debug, Clone, Default)]
pub struct TraceConfig {
strict: bool,
writer: Option<Arc<Mutex<std::fs::File>>>,
state: Arc<TraceState>,
}
impl Default for TraceState {
fn default() -> Self {
Self {
pending: TracePending::None,
opened: Arc::new(Mutex::new(None)),
}
}
}
impl TraceConfig {
pub fn new(path: Option<PathBuf>, strict: bool) -> Result<Self, AppError> {
if strict && path.is_none() {
pub fn build(
explicit_path: Option<PathBuf>,
session_segment_dir: Option<PathBuf>,
strict: bool,
) -> Result<Self, AppError> {
if strict && explicit_path.is_none() && session_segment_dir.is_none() {
return Err(AppError::invalid_input_with_suggestion(
"--trace-strict requires --trace",
"Provide --trace <path> or remove --trace-strict.",
"--trace-strict requires --trace or an active trace-enabled session",
"Provide --trace <path>, start a session with tracing, or remove --trace-strict.",
));
}
let writer = match path.as_deref() {
Some(path) => match open_trace_file(path) {
Ok(file) => Some(Arc::new(Mutex::new(file))),
Err(err) if err.code() == "INVALID_ARGS" => return Err(err),
Err(err) if strict => return Err(err),
Err(err) => {
tracing::warn!("trace open failed: {err}");
None
}
let pending = match explicit_path {
Some(path) => TracePending::File(path),
None => match session_segment_dir {
Some(dir) => TracePending::SegmentDir(dir),
None => TracePending::None,
},
None => None,
};
Ok(Self { strict, writer })
Ok(Self {
strict,
state: Arc::new(TraceState {
pending,
opened: Arc::new(Mutex::new(None)),
}),
})
}
pub fn emit(
@ -49,8 +80,9 @@ impl TraceConfig {
session_id: Option<&str>,
fields: impl FnOnce() -> Value,
) -> Result<(), AppError> {
let Some(writer) = self.writer.as_ref() else {
return Ok(());
let writer = match self.ensure_writer()? {
Some(writer) => writer,
None => return Ok(()),
};
match writer
.lock()
@ -65,6 +97,97 @@ impl TraceConfig {
}
}
}
fn ensure_writer(&self) -> Result<Option<Arc<Mutex<std::fs::File>>>, AppError> {
let mut opened = self
.state
.opened
.lock()
.map_err(|_| AppError::Internal("trace writer lock poisoned".into()))?;
if opened.is_none() {
let file = match &self.state.pending {
TracePending::None => return Ok(None),
TracePending::File(path) => match open_trace_file(path) {
Ok(file) => file,
Err(err) if self.strict => return Err(err),
Err(err) if err.code() == "INVALID_ARGS" => return Err(err),
Err(err) => {
tracing::warn!("trace open failed: {err}");
return Ok(None);
}
},
TracePending::SegmentDir(dir) => match open_segment_trace_file(dir) {
Ok(file) => file,
Err(err) if self.strict => return Err(err),
Err(err) if err.code() == "INVALID_ARGS" => return Err(err),
Err(err) => {
tracing::warn!("trace open failed: {err}");
return Ok(None);
}
},
};
*opened = Some(Arc::new(Mutex::new(file)));
}
Ok(opened.clone())
}
pub(crate) fn has_sink(&self) -> bool {
!matches!(self.state.pending, TracePending::None)
}
pub(crate) fn pending_file_path(&self) -> Option<&Path> {
match &self.state.pending {
TracePending::File(path) => Some(path),
_ => None,
}
}
pub(crate) fn clone_with_session_segment(
&self,
session_segment_dir: Option<PathBuf>,
) -> Result<Self, AppError> {
if self.pending_file_path().is_some() {
return Ok(self.clone());
}
Self::build(None, session_segment_dir, self.strict)
}
}
fn process_segment_suffix() -> &'static str {
static SUFFIX: OnceLock<String> = OnceLock::new();
SUFFIX.get_or_init(|| {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
format!("{}-{ts}", std::process::id())
})
}
pub(crate) fn segment_path_for_dir(dir: &Path) -> PathBuf {
dir.join(format!("{}.jsonl", process_segment_suffix()))
}
fn open_segment_trace_file(dir: &Path) -> Result<std::fs::File, AppError> {
ensure_trace_dir(dir)?;
open_trace_file(&segment_path_for_dir(dir))
}
fn ensure_trace_dir(dir: &Path) -> Result<(), AppError> {
if dir.is_dir() {
return Ok(());
}
#[cfg(unix)]
{
use std::os::unix::fs::DirBuilderExt;
std::fs::DirBuilder::new()
.recursive(true)
.mode(0o700)
.create(dir)?;
}
#[cfg(not(unix))]
std::fs::create_dir_all(dir)?;
Ok(())
}
fn open_trace_file(path: &Path) -> Result<std::fs::File, AppError> {
@ -99,6 +222,10 @@ fn write_event(
.as_millis()
),
);
body.insert(
"seq".to_string(),
json!(EVENT_SEQ.fetch_add(1, Ordering::Relaxed)),
);
if let Value::Object(fields) = sanitize_trace_value(fields) {
for (key, value) in fields {
body.insert(key, value);
@ -107,9 +234,10 @@ fn write_event(
if let Some(sid) = session_id {
body.insert("session_id".to_string(), json!(sid));
}
serde_json::to_writer(&mut *file, &Value::Object(body))?;
use std::io::Write;
file.write_all(b"\n").map_err(AppError::from)
let mut line = Vec::new();
serde_json::to_writer(&mut line, &Value::Object(body))?;
line.push(b'\n');
file.write_all(&line).map_err(AppError::from)
}
fn reject_oversized_trace(file: &std::fs::File) -> Result<(), AppError> {
@ -225,98 +353,5 @@ fn redacted_value(value: Value) -> Value {
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
#[test]
fn trace_open_rejects_symlink_paths() {
let base = std::env::temp_dir().join(format!(
"agent-desktop-trace-symlink-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let target = base.with_extension("target");
let link = base.with_extension("link");
std::fs::write(&target, b"existing").unwrap();
std::os::unix::fs::symlink(&target, &link).unwrap();
let result = open_trace_file(&link);
assert!(result.is_err());
let _ = std::fs::remove_file(&link);
let _ = std::fs::remove_file(&target);
}
#[test]
fn trace_redacts_sensitive_fields_but_preserves_messages() {
let value = sanitize_trace_value(json!({
"text": "secret",
"message": "Target is not actionable: supported_action failed",
"details": { "name": "Private Button" },
"title": "Window"
}));
assert_eq!(value["text"]["redacted"], true);
assert_eq!(value["details"]["name"]["redacted"], true);
assert_eq!(value["title"]["redacted"], true);
assert_eq!(
value["message"],
"Target is not actionable: supported_action failed"
);
}
#[test]
fn trace_redaction_covers_nested_shapes_and_substring_keys() {
let value = sanitize_trace_value(json!({
"action": {
"typed_text": ["secret", "another"],
"api_token": {"kind": "bearer"},
"typedText": "secret",
"apiToken": "secret",
"targetLabel": "secret",
"userName": "secret",
"filename": "report.txt",
"password": null,
"counter": 3
}
}));
assert_eq!(value["action"]["typed_text"]["redacted"], true);
assert_eq!(value["action"]["api_token"]["redacted"], true);
assert_eq!(value["action"]["typedText"]["redacted"], true);
assert_eq!(value["action"]["apiToken"]["redacted"], true);
assert_eq!(value["action"]["targetLabel"]["redacted"], true);
assert_eq!(value["action"]["userName"]["redacted"], true);
assert_eq!(value["action"]["filename"], "report.txt");
assert!(value["action"]["password"].is_null());
assert_eq!(value["action"]["counter"], 3);
}
#[test]
fn trace_write_rejects_files_at_size_cap() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-trace-cap-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let file = std::fs::File::create(&path).unwrap();
file.set_len(MAX_TRACE_FILE_BYTES).unwrap();
drop(file);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
let mut file = open_trace_file(&path).unwrap();
let err = write_event(&mut file, "event", None, json!({})).unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
let _ = std::fs::remove_file(path);
}
}
#[path = "trace_tests.rs"]
mod tests;

View file

@ -0,0 +1,253 @@
use super::*;
use crate::refs_test_support::HomeGuard;
use serde_json::json;
use std::fs;
#[cfg(unix)]
#[test]
fn trace_open_rejects_symlink_paths() {
let base = std::env::temp_dir().join(format!(
"agent-desktop-trace-symlink-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let target = base.with_extension("target");
let link = base.with_extension("link");
fs::write(&target, b"existing").unwrap();
std::os::unix::fs::symlink(&target, &link).unwrap();
let result = open_trace_file(&link);
assert!(result.is_err());
let _ = fs::remove_file(&link);
let _ = fs::remove_file(&target);
}
#[test]
fn trace_redacts_sensitive_fields_but_preserves_messages() {
let value = sanitize_trace_value(json!({
"text": "secret",
"message": "Target is not actionable: supported_action failed",
"details": { "name": "Private Button" },
"title": "Window"
}));
assert_eq!(value["text"]["redacted"], true);
assert_eq!(value["details"]["name"]["redacted"], true);
assert_eq!(value["title"]["redacted"], true);
assert_eq!(
value["message"],
"Target is not actionable: supported_action failed"
);
}
#[test]
fn trace_redaction_covers_nested_shapes_and_substring_keys() {
let value = sanitize_trace_value(json!({
"action": {
"typed_text": ["secret", "another"],
"api_token": {"kind": "bearer"},
"typedText": "secret",
"apiToken": "secret",
"targetLabel": "secret",
"userName": "secret",
"filename": "report.txt",
"password": null,
"counter": 3
}
}));
assert_eq!(value["action"]["typed_text"]["redacted"], true);
assert_eq!(value["action"]["api_token"]["redacted"], true);
assert_eq!(value["action"]["typedText"]["redacted"], true);
assert_eq!(value["action"]["apiToken"]["redacted"], true);
assert_eq!(value["action"]["targetLabel"]["redacted"], true);
assert_eq!(value["action"]["userName"]["redacted"], true);
assert_eq!(value["action"]["filename"], "report.txt");
assert!(value["action"]["password"].is_null());
assert_eq!(value["action"]["counter"], 3);
}
#[test]
fn trace_write_rejects_files_at_size_cap() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-trace-cap-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let file = fs::File::create(&path).unwrap();
file.set_len(MAX_TRACE_FILE_BYTES).unwrap();
drop(file);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
}
let mut file = open_trace_file(&path).unwrap();
let err = write_event(&mut file, "event", None, json!({})).unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
let _ = fs::remove_file(path);
}
#[test]
fn segment_configs_in_same_process_share_filename() {
let dir_a = std::env::temp_dir().join(format!(
"agent-desktop-seg-a-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let dir_b = dir_a.with_extension("b");
let path_a = segment_path_for_dir(&dir_a);
let path_b = segment_path_for_dir(&dir_b);
assert_eq!(path_a.file_name(), path_b.file_name());
}
#[test]
fn lazy_open_writes_no_file_until_first_event() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-lazy-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let _ = fs::remove_file(&path);
let config = TraceConfig::build(Some(path.clone()), None, false).unwrap();
assert!(!path.exists());
config.emit("event", None, json!({})).unwrap();
assert!(path.exists());
let _ = fs::remove_file(path);
}
#[test]
fn segment_lazy_open_creates_trace_dir_on_first_event() {
let _guard = HomeGuard::new();
let trace_dir = crate::refs_store::RefStore::for_session(Some("run-42"))
.unwrap()
.trace_dir();
assert!(!trace_dir.exists());
let config = TraceConfig::build(None, Some(trace_dir.clone()), false).unwrap();
config.emit("event", Some("run-42"), json!({})).unwrap();
assert!(trace_dir.is_dir());
let segment = segment_path_for_dir(&trace_dir);
assert!(segment.is_file());
assert!(segment.metadata().unwrap().len() > 0);
}
#[test]
fn write_event_emits_single_atomic_jsonl_line_with_seq() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-atomic-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let config = TraceConfig::build(Some(path.clone()), None, false).unwrap();
config.emit("first", None, json!({})).unwrap();
config.emit("second", None, json!({})).unwrap();
let body = fs::read_to_string(&path).unwrap();
let lines: Vec<&str> = body.lines().collect();
assert_eq!(lines.len(), 2);
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
assert_eq!(first["event"], "first");
assert_eq!(second["event"], "second");
assert!(first["seq"].as_u64().is_some());
assert!(second["seq"].as_u64().is_some());
assert!(second["seq"].as_u64().unwrap() > first["seq"].as_u64().unwrap());
let _ = fs::remove_file(path);
}
#[test]
fn truncated_final_line_leaves_prior_lines_parseable() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-trunc-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let config = TraceConfig::build(Some(path.clone()), None, false).unwrap();
config.emit("ok", None, json!({})).unwrap();
let mut bytes = fs::read(&path).unwrap();
bytes.extend_from_slice(b"{\"event\":\"partial");
fs::write(&path, bytes).unwrap();
let body = fs::read_to_string(&path).unwrap();
let mut parsed = 0usize;
for line in body.lines() {
if serde_json::from_str::<serde_json::Value>(line).is_ok() {
parsed += 1;
}
}
assert_eq!(parsed, 1);
let _ = fs::remove_file(path);
}
#[test]
fn strict_missing_trace_path_fails_on_first_emit() {
let missing = std::env::temp_dir()
.join("agent-desktop-missing-dir")
.join("trace.jsonl");
let config = TraceConfig::build(Some(missing), None, true).unwrap();
assert!(config.emit("event", None, json!({})).is_err());
}
#[test]
fn best_effort_missing_trace_path_succeeds_silently() {
let missing = std::env::temp_dir()
.join("agent-desktop-missing-dir-best-effort")
.join("trace.jsonl");
let config = TraceConfig::build(Some(missing), None, false).unwrap();
assert!(config.emit("event", None, json!({})).is_ok());
}
#[cfg(unix)]
#[test]
fn trace_file_is_private_on_create() {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(format!(
"agent-desktop-private-trace-{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let config = TraceConfig::build(Some(path.clone()), None, true).unwrap();
config.emit("event", None, json!({})).unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
let _ = fs::remove_file(path);
}
#[cfg(unix)]
#[test]
fn trace_rejects_loose_existing_file_permissions() {
use std::os::unix::fs::PermissionsExt;
let path = std::env::temp_dir().join(format!(
"agent-desktop-loose-trace-{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::write(&path, "").unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
let config = TraceConfig::build(Some(path.clone()), None, false).unwrap();
let err = config.emit("event", None, json!({})).unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
let _ = fs::remove_file(path);
}

View file

@ -0,0 +1,99 @@
mod common;
use agent_desktop_core::session::{
SessionTraceMode, StartSessionOptions, start_session, trace_dir,
};
use common::{ad_adapter_create_with_session, ad_adapter_destroy, ad_check_permissions};
use std::ffi::CString;
use std::fs;
use std::sync::Mutex;
static HOME_LOCK: Mutex<()> = Mutex::new(());
struct TestHome {
_lock: std::sync::MutexGuard<'static, ()>,
dir: std::path::PathBuf,
prev: Option<std::ffi::OsString>,
}
impl TestHome {
fn new() -> Self {
let lock = HOME_LOCK.lock().unwrap();
let dir = std::env::temp_dir().join(format!(
"agent-desktop-ffi-session-trace-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).unwrap();
let prev = std::env::var_os("HOME");
unsafe { std::env::set_var("HOME", &dir) };
Self {
_lock: lock,
dir,
prev,
}
}
}
impl Drop for TestHome {
fn drop(&mut self) {
match self.prev.as_ref() {
Some(prev) => unsafe { std::env::set_var("HOME", prev) },
None => unsafe { std::env::remove_var("HOME") },
}
let _ = fs::remove_dir_all(&self.dir);
}
}
fn trace_dir_for(session_id: &str) -> std::path::PathBuf {
trace_dir(session_id).unwrap()
}
#[test]
fn ffi_trace_on_session_writes_segment() {
let _home = TestHome::new();
let manifest = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
force: false,
})
.unwrap();
unsafe {
let session = CString::new(manifest.id.as_str()).unwrap();
let ptr = ad_adapter_create_with_session(session.as_ptr());
assert!(!ptr.is_null());
let ctx = (*ptr)
.command_context()
.expect("command_context must succeed");
ctx.trace("ffi.event", serde_json::json!({ "ok": true }))
.unwrap();
ad_adapter_destroy(ptr);
}
let trace_dir = trace_dir_for(&manifest.id);
let body: String = fs::read_dir(trace_dir)
.unwrap()
.flatten()
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "jsonl"))
.map(|entry| fs::read_to_string(entry.path()).unwrap())
.collect();
assert!(body.contains("ffi.event"));
}
#[test]
fn ffi_plain_session_writes_no_trace_files() {
let _home = TestHome::new();
unsafe {
let session = CString::new("plain-session").unwrap();
let ptr = ad_adapter_create_with_session(session.as_ptr());
assert!(!ptr.is_null());
let ctx = (*ptr)
.command_context()
.expect("command_context must succeed");
ctx.trace("ffi.event", serde_json::json!({})).unwrap();
ad_check_permissions(ptr);
ad_adapter_destroy(ptr);
}
assert!(!trace_dir_for("plain-session").exists());
}

View file

@ -13,6 +13,7 @@ use serde_json::{Map, Value, json};
use crate::{
cli::Commands,
cli_args::{
session::{SessionAction, SessionArgs, SessionEndArgs, SessionGcArgs, SessionStartArgs},
skills::{SkillsAction, SkillsArgs, SkillsGetArgs},
system::BatchArgs,
},
@ -102,6 +103,7 @@ pub(crate) fn parse_command(item: BatchCommand) -> Result<Commands, AppError> {
"permissions" => decode(command, item.args).map(Commands::Permissions),
"version" => no_args(command, item.args).map(|()| Commands::Version),
"skills" => parse_skills(item.args).map(Commands::Skills),
"session" => parse_session(item.args).map(Commands::Session),
"batch" => Err(AppError::invalid_input_with_suggestion(
"Batch commands cannot be nested",
"Flatten nested batches into one top-level batch array",
@ -185,5 +187,43 @@ fn parse_skills(args: Value) -> Result<SkillsArgs, AppError> {
Ok(SkillsArgs { action })
}
#[derive(Deserialize)]
struct BatchSessionArgs {
#[serde(default)]
action: Option<String>,
name: Option<String>,
#[serde(default)]
no_trace: bool,
#[serde(default)]
force: bool,
id: Option<String>,
older_than: Option<u64>,
#[serde(default)]
ended: bool,
}
fn parse_session(args: Value) -> Result<SessionArgs, AppError> {
let args: BatchSessionArgs = decode("session", args)?;
let action = match args.action.as_deref() {
None | Some("list") => SessionAction::List,
Some("start") => SessionAction::Start(SessionStartArgs {
name: args.name,
no_trace: args.no_trace,
force: args.force,
}),
Some("end") => SessionAction::End(SessionEndArgs { id: args.id }),
Some("gc") => SessionAction::Gc(SessionGcArgs {
older_than: args.older_than,
ended: args.ended,
}),
Some(other) => {
return Err(AppError::invalid_input(format!(
"Unknown session action '{other}'"
)));
}
};
Ok(SessionArgs { action })
}
#[cfg(test)]
mod tests;

View file

@ -28,6 +28,7 @@ const COMMAND_SPECIFIC_TESTS: &[&str] = &[
"list-apps",
"right-click",
"skills",
"session",
"snapshot",
"status",
"wait",

View file

@ -73,10 +73,14 @@ WAIT
wait --notification Block until a new notification arrives (--text filters it)
SYSTEM
status Adapter health, platform, and permission state
status Adapter health, platform, permission, session, and tracing state
permissions Check nested permission states: {state,...}
version Show version, target architecture, and OS
skills Bundled skill docs for AI agents (list, get, path)
session start Create a trace-enabled session and set the active pointer
session end Seal the session manifest and clear the active pointer
session list List session manifests
session gc Remove ended or provably-stale sessions
BATCH
batch <json> Run commands from a JSON array (--stop-on-error)
@ -88,13 +92,16 @@ REF IDs
--snapshot <snapshot_id> to pin a command to a known snapshot; explicit
snapshot IDs do not require --session. When --snapshot is omitted, ref
commands use the active session's latest saved snapshot. Run snapshot again
after UI changes. Use --session <id> as a shared latest-snapshot namespace
for concurrent agents.
after UI changes. Use `session start` once per run for automatic JSONL tracing
under ~/.agent-desktop/sessions/<id>/trace/, or pass --session <id> for
snapshot namespace only (no trace without a session manifest). Set
AGENT_DESKTOP_SESSION for concurrent independent sessions. Explicit --session
overrides the env var and current-session pointer.
Ref actions use strict resolution: stale targets return STALE_REF; duplicate
plausible targets return AMBIGUOUS_TARGET instead of choosing arbitrarily.
Ref actions run actionability checks before dispatch. Use --trace <path> to
write JSONL diagnostics outside stdout; --trace-strict fails on trace setup
and pre-action writes. Post-action success traces are best-effort.
override the session trace sink; --trace-strict fails on trace setup and
pre-action writes. Post-action success traces are best-effort.
KEY COMBOS
Single keys: return, escape, tab, space, delete, up, down, left, right

View file

@ -11,6 +11,7 @@ use crate::cli_args::{
DismissAllNotificationsCliArgs, DismissNotificationCliArgs, ListNotificationsCliArgs,
NotificationActionCliArgs,
},
session::SessionArgs,
skills::SkillsArgs,
system::{
AppRefArgs, BatchArgs, ClipboardSetArgs, CloseAppArgs, FocusWindowArgs, LaunchArgs,
@ -210,6 +211,8 @@ pub(crate) enum Commands {
Batch(BatchArgs),
#[command(about = "Bundled skill docs for AI agents (list, get, path)")]
Skills(SkillsArgs),
#[command(about = "Manage trace-enabled agent sessions (start, end, list, gc)")]
Session(SessionArgs),
}
impl Commands {
@ -269,6 +272,7 @@ impl Commands {
Self::Version => "version",
Self::Batch(_) => "batch",
Self::Skills(_) => "skills",
Self::Session(_) => "session",
}
}
}

View file

@ -3,6 +3,7 @@ use serde::Deserialize;
pub(crate) mod actions;
pub(crate) mod notifications;
pub(crate) mod session;
pub(crate) mod skills;
pub(crate) mod system;

49
src/cli_args/session.rs Normal file
View file

@ -0,0 +1,49 @@
use clap::{Args, Subcommand};
#[derive(Args, Debug)]
pub(crate) struct SessionArgs {
#[command(subcommand)]
pub action: SessionAction,
}
#[derive(Subcommand, Debug)]
pub(crate) enum SessionAction {
#[command(about = "Create a session directory, manifest, and current-session pointer")]
Start(SessionStartArgs),
#[command(about = "Seal the session manifest and clear the current-session pointer")]
End(SessionEndArgs),
#[command(about = "List session manifests")]
List,
#[command(about = "Remove ended or provably-stale sessions")]
Gc(SessionGcArgs),
}
#[derive(Args, Debug)]
pub(crate) struct SessionStartArgs {
#[arg(long, help = "Optional human-readable session label")]
pub name: Option<String>,
#[arg(long, help = "Create the session without automatic tracing")]
pub no_trace: bool,
#[arg(
long,
help = "Override the current-session pointer even when it references a live session"
)]
pub force: bool,
}
#[derive(Args, Debug)]
pub(crate) struct SessionEndArgs {
#[arg(help = "Session id to end; defaults to the current-session pointer")]
pub id: Option<String>,
}
#[derive(Args, Debug)]
pub(crate) struct SessionGcArgs {
#[arg(
long,
help = "Only remove sessions whose ended_at/created_at age exceeds this many seconds"
)]
pub older_than: Option<u64>,
#[arg(long, help = "Only consider sessions that already have ended_at set")]
pub ended: bool,
}

View file

@ -17,7 +17,7 @@ pub(crate) enum PermissionNeed {
pub(crate) fn policy_for(cmd: &Commands) -> PermissionNeed {
use PermissionNeed::{Accessibility, AccessibilityAndScreenRecording, None, ScreenRecording};
match cmd {
Commands::Version | Commands::Skills(_) => None,
Commands::Version | Commands::Skills(_) | Commands::Session(_) => None,
Commands::Status | Commands::Permissions(_) => None,
Commands::ListWindows(_) | Commands::ListApps(_) => None,
Commands::ClipboardGet | Commands::ClipboardSet(_) | Commands::ClipboardClear => None,
@ -192,7 +192,8 @@ fn validate_args(cmd: &Commands) -> Result<(), AppError> {
| Commands::Permissions(_)
| Commands::Version
| Commands::Batch(_)
| Commands::Skills(_) => {}
| Commands::Skills(_)
| Commands::Session(_) => {}
}
Ok(())
}

View file

@ -74,6 +74,7 @@ fn command_name_is_covered(name: &str) -> bool {
| "version"
| "batch"
| "skills"
| "session"
)
}

View file

@ -9,8 +9,9 @@ use agent_desktop_core::{
double_click, drag, expand, find, focus, focus_window, get, helpers, hover, is_check,
key_down, key_up, launch, list_apps, list_surfaces, list_windows, maximize, minimize,
mouse_click, mouse_down, mouse_move, mouse_up, move_window, permissions, press,
resize_window, restore, right_click, screenshot, scroll, scroll_to, select, set_value,
skills, snapshot, status, toggle, triple_click, type_text, uncheck, version, wait,
resize_window, restore, right_click, screenshot, scroll, scroll_to, select, session,
set_value, skills, snapshot, status, toggle, triple_click, type_text, uncheck, version,
wait,
},
context::CommandContext,
error::AppError,
@ -18,6 +19,7 @@ use agent_desktop_core::{
use serde_json::Value;
use crate::cli::Commands;
use crate::cli_args::session::SessionAction;
use crate::cli_args::skills::SkillsAction;
use parse::{
parse_direction, parse_get_property, parse_is_property, parse_mouse_button, parse_xy,
@ -364,6 +366,20 @@ pub(crate) fn dispatch(
}),
},
Commands::Session(a) => match a.action {
SessionAction::Start(s) => session::execute(session::SessionAction::Start {
name: s.name,
no_trace: s.no_trace,
force: s.force,
}),
SessionAction::End(e) => session::execute(session::SessionAction::End { id: e.id }),
SessionAction::List => session::execute(session::SessionAction::List),
SessionAction::Gc(g) => session::execute(session::SessionAction::Gc {
older_than_secs: g.older_than,
ended_only: g.ended,
}),
},
Commands::Batch(a) => crate::batch::execute(a, adapter, permission_report, context),
}
}

View file

@ -9,6 +9,7 @@ use agent_desktop_core::{
context::{CommandContext, WaitSelector},
error::AppError,
output::{ENVELOPE_VERSION, ErrorPayload, Response},
session::resolve_active_session,
};
use clap::{CommandFactory, Parser};
use cli::{Cli, Commands};
@ -57,7 +58,17 @@ fn main() {
init_tracing(cli.verbose);
let wait_selector = build_wait_selector(&cli);
let context = match CommandContext::new(cli.session, cli.trace, cli.trace_strict) {
let session_id = match resolve_active_session(
cli.session.as_deref(),
std::env::var("AGENT_DESKTOP_SESSION").ok().as_deref(),
) {
Ok(session_id) => session_id,
Err(err) => {
finish("unknown", Err(err));
return;
}
};
let context = match CommandContext::new(session_id, cli.trace, cli.trace_strict) {
Ok(context) => context
.with_headed(cli.headed)
.with_wait_selector(wait_selector.clone()),