diff --git a/rust/crates/runtime/src/worker_boot.rs b/rust/crates/runtime/src/worker_boot.rs index c8ed3d0..15cb72c 100644 --- a/rust/crates/runtime/src/worker_boot.rs +++ b/rust/crates/runtime/src/worker_boot.rs @@ -560,6 +560,7 @@ fn push_event( let timestamp = now_secs(); let seq = worker.events.len() as u64 + 1; worker.updated_at = timestamp; + worker.status = status; worker.events.push(WorkerEvent { seq, kind, @@ -568,6 +569,45 @@ fn push_event( payload, timestamp, }); + emit_state_file(worker); +} + +/// Write current worker state to `.claw/worker-state.json` under the worker's cwd. +/// This is the file-based observability surface: external observers (clawhip, orchestrators) +/// poll this file instead of requiring an HTTP route on the opencode binary. +fn emit_state_file(worker: &Worker) { + let state_dir = std::path::Path::new(&worker.cwd).join(".claw"); + if let Err(_) = std::fs::create_dir_all(&state_dir) { + return; + } + let state_path = state_dir.join("worker-state.json"); + let tmp_path = state_dir.join("worker-state.json.tmp"); + + #[derive(serde::Serialize)] + struct StateSnapshot<'a> { + worker_id: &'a str, + status: WorkerStatus, + is_ready: bool, + trust_gate_cleared: bool, + prompt_in_flight: bool, + last_event: Option<&'a WorkerEvent>, + updated_at: u64, + } + + let snapshot = StateSnapshot { + worker_id: &worker.worker_id, + status: worker.status, + is_ready: worker.status == WorkerStatus::ReadyForPrompt, + trust_gate_cleared: worker.trust_gate_cleared, + prompt_in_flight: worker.prompt_in_flight, + last_event: worker.events.last(), + updated_at: worker.updated_at, + }; + + if let Ok(json) = serde_json::to_string_pretty(&snapshot) { + let _ = std::fs::write(&tmp_path, json); + let _ = std::fs::rename(&tmp_path, &state_path); + } } fn path_matches_allowlist(cwd: &str, trusted_root: &str) -> bool { @@ -1058,6 +1098,38 @@ mod tests { .any(|event| event.kind == WorkerEventKind::Failed)); } + #[test] + fn emit_state_file_writes_worker_status_on_transition() { + let cwd_path = std::env::temp_dir().join(format!("claw-state-test-{}", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos())); + std::fs::create_dir_all(&cwd_path).expect("test dir should create"); + let cwd = cwd_path.to_str().expect("test path should be utf8"); + let registry = WorkerRegistry::new(); + let worker = registry.create(cwd, &[], true); + + // After create the worker is Spawning — state file should exist + let state_path = cwd_path.join(".claw").join("worker-state.json"); + assert!(state_path.exists(), "state file should exist after worker creation"); + + let raw = std::fs::read_to_string(&state_path).expect("state file should be readable"); + let value: serde_json::Value = serde_json::from_str(&raw).expect("state file should be valid JSON"); + assert_eq!(value["status"].as_str(), Some("spawning"), "initial status should be spawning"); + assert_eq!(value["is_ready"].as_bool(), Some(false)); + + // Transition to ReadyForPrompt by observing trust-cleared text + registry + .observe(&worker.worker_id, "Ready for input\n>") + .expect("observe ready should succeed"); + + let raw = std::fs::read_to_string(&state_path).expect("state file should be readable after observe"); + let value: serde_json::Value = serde_json::from_str(&raw).expect("state file should be valid JSON after observe"); + assert_eq!( + value["status"].as_str(), + Some("ready_for_prompt"), + "status should be ready_for_prompt after observe" + ); + assert_eq!(value["is_ready"].as_bool(), Some(true), "is_ready should be true when ReadyForPrompt"); + } + #[test] fn observe_completion_accepts_normal_finish_with_tokens() { let registry = WorkerRegistry::new(); diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index 847b0fa..330afb6 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -211,6 +211,7 @@ fn run() -> Result<(), Box> { CliAction::Login { output_format } => run_login(output_format)?, CliAction::Logout { output_format } => run_logout(output_format)?, CliAction::Doctor { output_format } => run_doctor(output_format)?, + CliAction::State { output_format } => run_worker_state(output_format)?, CliAction::Init { output_format } => run_init(output_format)?, CliAction::Export { session_reference, @@ -293,6 +294,9 @@ enum CliAction { Doctor { output_format: CliOutputFormat, }, + State { + output_format: CliOutputFormat, + }, Init { output_format: CliOutputFormat, }, @@ -611,6 +615,7 @@ fn parse_single_word_command_alias( })), "sandbox" => Some(Ok(CliAction::Sandbox { output_format })), "doctor" => Some(Ok(CliAction::Doctor { output_format })), + "state" => Some(Ok(CliAction::State { output_format })), other => bare_slash_command_guidance(other).map(Err), } } @@ -1322,6 +1327,32 @@ fn run_doctor(output_format: CliOutputFormat) -> Result<(), Box Result<(), Box> { + let cwd = env::current_dir()?; + let state_path = cwd.join(".claw").join("worker-state.json"); + if !state_path.exists() { + match output_format { + CliOutputFormat::Text => println!("No worker state file found at {}", state_path.display()), + CliOutputFormat::Json => println!("{}", serde_json::json!({"error": "no_state_file", "path": state_path.display().to_string()})), + } + return Ok(()); + } + let raw = std::fs::read_to_string(&state_path)?; + match output_format { + CliOutputFormat::Text => println!("{raw}"), + CliOutputFormat::Json => { + // Validate it parses as JSON before re-emitting + let _: serde_json::Value = serde_json::from_str(&raw)?; + println!("{raw}"); + } + } + Ok(()) +} + /// the same surface the in-process agent loop uses. fn run_mcp_serve() -> Result<(), Box> { let tools = mvp_tool_specs() @@ -8547,6 +8578,19 @@ mod tests { output_format: CliOutputFormat::Text, } ); + assert_eq!( + parse_args(&["state".to_string()]).expect("state should parse"), + CliAction::State { + output_format: CliOutputFormat::Text, + } + ); + assert_eq!( + parse_args(&["state".to_string(), "--output-format".to_string(), "json".to_string()]) + .expect("state --output-format json should parse"), + CliAction::State { + output_format: CliOutputFormat::Json, + } + ); assert_eq!( parse_args(&["init".to_string()]).expect("init should parse"), CliAction::Init {