use std::path::{Component, Path, PathBuf};
fn normalize_lexical(p: &Path) -> PathBuf {
let mut out = PathBuf::new();
for c in p.components() {
match c {
Component::CurDir => {}
Component::ParentDir => {
out.pop();
}
other => out.push(other.as_os_str()),
}
}
out
}
pub fn claude_project_dir_name(working_dir: &Path) -> String {
let s = working_dir.to_string_lossy();
s.replace(['/', '\\'], "-")
}
pub fn session_jsonl_path(working_dir: &Path, claude_session_uuid: &str) -> Option<PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
let absolutized: PathBuf = if working_dir.is_absolute() {
working_dir.to_path_buf()
} else {
std::env::current_dir().ok()?.join(working_dir)
};
let resolved = normalize_lexical(&absolutized);
let project = claude_project_dir_name(&resolved);
Some(
PathBuf::from(home)
.join(".claude")
.join("projects")
.join(project)
.join(format!("{claude_session_uuid}.jsonl")),
)
}
pub fn session_env_lock_path(claude_session_uuid: &str) -> Option<PathBuf> {
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
Some(
PathBuf::from(home)
.join(".claude")
.join("session-env")
.join(claude_session_uuid),
)
}
pub fn sweep_orphan_session_env_lock(claude_session_uuid: &str) -> bool {
let Some(dir) = session_env_lock_path(claude_session_uuid) else {
return false;
};
if !dir.is_dir() {
return false;
}
let is_empty = match std::fs::read_dir(&dir) {
Ok(mut entries) => entries.next().is_none(),
Err(e) => {
tracing::warn!(
uuid = claude_session_uuid,
path = %dir.display(),
error = %e,
"Could not read session-env lock dir; treating as non-empty (skip sweep)"
);
false
}
};
if !is_empty {
return false;
}
match std::fs::remove_dir(&dir) {
Ok(()) => {
tracing::info!(
uuid = claude_session_uuid,
"Swept orphaned claude session-env lock dir"
);
true
}
Err(e) => {
tracing::warn!(
uuid = claude_session_uuid,
error = %e,
"Failed to remove orphaned claude session-env lock"
);
false
}
}
}
pub fn extract_last_tool_use_args(
jsonl_content: &str,
tool_names: &[&str],
) -> Option<serde_json::Value> {
extract_last_tool_use_args_after(jsonl_content, tool_names, 0)
}
pub fn extract_last_tool_use_args_after(
jsonl_content: &str,
tool_names: &[&str],
after_offset: usize,
) -> Option<serde_json::Value> {
let mut last_input: Option<serde_json::Value> = None;
let mut byte_pos = 0usize;
for line in jsonl_content.split_inclusive('\n') {
let line_start = byte_pos;
byte_pos += line.len();
if line_start < after_offset {
continue;
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(rec) = serde_json::from_str::<serde_json::Value>(trimmed) else {
continue;
};
let Some(message) = rec.get("message") else {
continue;
};
let Some(content) = message.get("content").and_then(|v| v.as_array()) else {
continue;
};
for block in content {
let block_type = block.get("type").and_then(|v| v.as_str()).unwrap_or("");
if block_type != "tool_use" {
continue;
}
let name = block.get("name").and_then(|v| v.as_str()).unwrap_or("");
if tool_names.contains(&name)
&& let Some(input) = block.get("input")
{
last_input = Some(input.clone());
}
}
}
last_input
}
pub fn recover_from_session(
working_dir: &Path,
claude_session_uuid: &str,
tool_names: &[&str],
) -> Option<serde_json::Value> {
recover_from_session_after(working_dir, claude_session_uuid, tool_names, 0)
}
pub fn recover_from_session_after(
working_dir: &Path,
claude_session_uuid: &str,
tool_names: &[&str],
after_offset: u64,
) -> Option<serde_json::Value> {
let path = session_jsonl_path(working_dir, claude_session_uuid)?;
let content = std::fs::read_to_string(&path).ok()?;
let offset = usize::try_from(after_offset).unwrap_or(usize::MAX);
extract_last_tool_use_args_after(&content, tool_names, offset)
}
pub fn session_jsonl_size(working_dir: &Path, claude_session_uuid: &str) -> u64 {
let Some(path) = session_jsonl_path(working_dir, claude_session_uuid) else {
return 0;
};
std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0)
}
pub fn unwrap_recovered_input(value: serde_json::Value) -> serde_json::Value {
fn inner(v: serde_json::Value, depth: u8) -> serde_json::Value {
if depth > 4 {
return v;
}
match v {
serde_json::Value::String(s) => match serde_json::from_str::<serde_json::Value>(&s) {
Ok(parsed) if parsed.is_object() || parsed.is_array() => inner(parsed, depth + 1),
_ => serde_json::Value::String(s),
},
serde_json::Value::Object(ref map) => {
if let Some(args) = map.get("arguments")
&& args.is_object()
{
return inner(args.clone(), depth + 1);
}
v
}
other => other,
}
}
inner(value, 0)
}
#[derive(Debug)]
pub enum RecoveryOutcome<T> {
Recovered(T),
Malformed(String),
NotFound,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LastFailureKind {
MissingTerminalCall,
MalformedArgs { reason: String },
Timeout,
}
pub fn retry_feedback_block(failure: &LastFailureKind, terminal_tool: &str) -> String {
let header = "<previous_attempt_failed>";
let footer = "</previous_attempt_failed>";
let body = match failure {
LastFailureKind::MissingTerminalCall => format!(
"Your previous attempt finished without calling the `{terminal_tool}` tool. \
That tool is the ONLY way to submit your result for this phase. \
Re-run your reasoning if you need to, then call `{terminal_tool}` with \
valid JSON arguments to terminate this turn."
),
LastFailureKind::MalformedArgs { reason } => format!(
"Your previous `{terminal_tool}` call failed to deserialize: {reason}. \
Re-emit the call with valid JSON args — pay particular attention to \
string escaping (use `\\\"` for embedded quotes, `\\n` for newlines, \
never raw control characters) and to required fields being non-null."
),
LastFailureKind::Timeout => format!(
"Your previous attempt was cut off before completing. \
Be concise; produce your `{terminal_tool}` call promptly without \
excessive intermediate reasoning."
),
};
format!("{header}\n{body}\n{footer}")
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn project_dir_name_replaces_separators() {
assert_eq!(
claude_project_dir_name(&PathBuf::from("/Users/tim/github/nsed")),
"-Users-tim-github-nsed"
);
assert_eq!(claude_project_dir_name(&PathBuf::from("/work")), "-work");
let mut p = PathBuf::from("");
p.push("C:");
p.push("Users");
p.push("tim");
let mapped = claude_project_dir_name(&p);
assert!(!mapped.contains('/'));
assert!(!mapped.contains('\\'));
}
#[test]
#[serial_test::serial(home_env)]
fn session_jsonl_path_uses_home() {
let prev = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", "/tmp/nsed-test-home");
}
let p = session_jsonl_path(&PathBuf::from("/Users/tim/github/nsed"), "abc-123-uuid")
.expect("HOME set");
assert!(
p.ends_with(PathBuf::from(
".claude/projects/-Users-tim-github-nsed/abc-123-uuid.jsonl"
)),
"got {}",
p.display()
);
unsafe {
match prev {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
#[test]
#[serial_test::serial(home_env)]
fn sweep_orphan_session_env_lock_removes_only_empty() {
let prev = std::env::var_os("HOME");
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var("HOME", tmp.path());
}
let session_env = tmp.path().join(".claude/session-env");
std::fs::create_dir_all(&session_env).unwrap();
let orphan_uuid = "ffffffff-ffff-4fff-8fff-ffffffffffff";
let orphan = session_env.join(orphan_uuid);
std::fs::create_dir(&orphan).unwrap();
assert!(super::sweep_orphan_session_env_lock(orphan_uuid));
assert!(!orphan.exists());
let live_uuid = "11111111-1111-4111-8111-111111111111";
let live = session_env.join(live_uuid);
std::fs::create_dir(&live).unwrap();
std::fs::write(live.join("env.json"), "{}").unwrap();
assert!(!super::sweep_orphan_session_env_lock(live_uuid));
assert!(live.exists(), "live session-env dir must be preserved");
let absent_uuid = "22222222-2222-4222-8222-222222222222";
assert!(!super::sweep_orphan_session_env_lock(absent_uuid));
unsafe {
match prev {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
#[cfg(unix)]
#[test]
#[serial_test::serial(home_env)]
fn sweep_orphan_session_env_lock_handles_unreadable_dir() {
use std::os::unix::fs::PermissionsExt;
let prev = std::env::var_os("HOME");
let tmp = tempfile::tempdir().expect("tempdir");
unsafe {
std::env::set_var("HOME", tmp.path());
}
let session_env = tmp.path().join(".claude/session-env");
std::fs::create_dir_all(&session_env).unwrap();
let uuid = "33333333-3333-4333-8333-333333333333";
let dir = session_env.join(uuid);
std::fs::create_dir(&dir).unwrap();
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o000)).unwrap();
let result = super::sweep_orphan_session_env_lock(uuid);
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(!result, "unreadable dir must not be swept");
assert!(dir.exists(), "unreadable dir must be preserved");
unsafe {
match prev {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
#[test]
fn extract_last_tool_use_args_picks_last_match() {
let content = r#"{"type":"queue-operation","operation":"enqueue"}
{"message":{"content":[{"type":"tool_use","name":"other","input":{"a":1}}]}}
{"message":{"content":[{"type":"tool_use","name":"mcp__nsed__nsed_propose","input":{"thought_process":"first attempt","content":"v1"}}]}}
{"message":{"content":[{"type":"tool_use","name":"mcp__nsed__nsed_propose","input":{"thought_process":"second attempt","content":"v2"}}]}}
{"type":"queue-operation","operation":"dequeue"}
"#;
let got = extract_last_tool_use_args(content, &["mcp__nsed__nsed_propose"])
.expect("should find at least one");
assert_eq!(got["thought_process"], "second attempt");
assert_eq!(got["content"], "v2");
}
#[test]
fn extract_last_tool_use_args_returns_none_when_no_match() {
let content = r#"{"message":{"content":[{"type":"tool_use","name":"some_other_tool","input":{}}]}}
{"message":{"content":[{"type":"text","text":"hello"}]}}
"#;
assert!(extract_last_tool_use_args(content, &["mcp__nsed__nsed_propose"]).is_none());
}
#[test]
fn extract_handles_malformed_lines_gracefully() {
let content = "not json\n\
{\"valid\": \"but no message\"}\n\
{\"message\":{\"content\":[{\"type\":\"tool_use\",\"name\":\"mcp__nsed__nsed_evaluate\",\"input\":{\"evaluations\":[]}}]}}\n\
{malformed} json\n";
let got = extract_last_tool_use_args(content, &["mcp__nsed__nsed_evaluate"]).unwrap();
assert!(got["evaluations"].is_array());
}
#[test]
fn retry_feedback_block_distinguishes_kinds() {
let missing = retry_feedback_block(
&LastFailureKind::MissingTerminalCall,
"mcp__nsed__nsed_propose",
);
assert!(missing.contains("without calling"));
assert!(missing.contains("mcp__nsed__nsed_propose"));
let malformed = retry_feedback_block(
&LastFailureKind::MalformedArgs {
reason: "missing field `content`".to_string(),
},
"mcp__nsed__nsed_propose",
);
assert!(malformed.contains("failed to deserialize"));
assert!(malformed.contains("missing field `content`"));
let timeout = retry_feedback_block(&LastFailureKind::Timeout, "mcp__nsed__nsed_evaluate");
assert!(timeout.contains("cut off"));
}
#[test]
fn unwrap_recovered_input_handles_envelope_and_stringified() {
let clean = serde_json::json!({"content": "hi", "thought_process": "tp"});
assert_eq!(unwrap_recovered_input(clean.clone()), clean);
let envelope = serde_json::json!({
"name": "nsed_propose",
"arguments": {"content": "hi"}
});
let unwrapped = unwrap_recovered_input(envelope);
assert_eq!(unwrapped, serde_json::json!({"content": "hi"}));
let stringified = serde_json::Value::String(r#"{"content": "hi"}"#.to_string());
let unwrapped = unwrap_recovered_input(stringified);
assert_eq!(unwrapped, serde_json::json!({"content": "hi"}));
let doubly =
serde_json::Value::String(r#"{"name":"x","arguments":{"content":"hi"}}"#.to_string());
assert_eq!(
unwrap_recovered_input(doubly),
serde_json::json!({"content": "hi"})
);
let plain = serde_json::Value::String("not json at all".to_string());
assert_eq!(
unwrap_recovered_input(plain.clone()),
serde_json::Value::String("not json at all".to_string())
);
let no_args = serde_json::json!({"foo": 1, "bar": "baz"});
assert_eq!(unwrap_recovered_input(no_args.clone()), no_args);
let bad_args = serde_json::json!({"name": "x", "arguments": "not-an-object"});
assert_eq!(unwrap_recovered_input(bad_args.clone()), bad_args);
}
#[test]
#[serial_test::serial(home_env)]
fn session_jsonl_path_absolutizes_relative_working_dir() {
let prev = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", "/tmp/nsed-test-home-absolutize");
}
let cwd = std::env::current_dir().expect("cwd available");
let abs = session_jsonl_path(&cwd, "abc-uuid").expect("HOME set");
let rel = session_jsonl_path(&PathBuf::from("."), "abc-uuid").expect("HOME set");
assert_eq!(
abs, rel,
"relative working_dir must produce the same session path as the resolved absolute cwd"
);
unsafe {
match prev {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
#[test]
fn extract_after_offset_skips_prior_content() {
let prior = r#"{"message":{"content":[{"type":"tool_use","name":"mcp__nsed__nsed_propose","input":{"thought_process":"prior","content":"v1"}}]}}"#;
let current = r#"{"message":{"content":[{"type":"tool_use","name":"mcp__nsed__nsed_propose","input":{"thought_process":"current","content":"v2"}}]}}"#;
let mut full = String::new();
full.push_str(prior);
full.push('\n');
let after_prior = full.len();
full.push_str(current);
full.push('\n');
let unbounded = extract_last_tool_use_args(&full, &["mcp__nsed__nsed_propose"]).unwrap();
assert_eq!(unbounded["content"], "v2");
let from_zero =
extract_last_tool_use_args_after(&full, &["mcp__nsed__nsed_propose"], 0).unwrap();
assert_eq!(from_zero["content"], "v2");
let from_prior =
extract_last_tool_use_args_after(&full, &["mcp__nsed__nsed_propose"], after_prior)
.unwrap();
assert_eq!(from_prior["content"], "v2");
let pre_current = extract_last_tool_use_args_after(
prior, &["mcp__nsed__nsed_propose"],
after_prior,
);
assert!(
pre_current.is_none(),
"offset past file end must return None — got {pre_current:?}"
);
let past_end =
extract_last_tool_use_args_after(&full, &["mcp__nsed__nsed_propose"], full.len() + 1);
assert!(past_end.is_none());
}
#[test]
#[serial_test::serial(home_env)]
fn session_jsonl_size_returns_zero_for_missing_file() {
let prev = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", "/tmp/nsed-test-home-jsonl-size-missing");
}
let size = session_jsonl_size(
&PathBuf::from("/work"),
"11111111-1111-1111-1111-111111111111",
);
assert_eq!(size, 0);
unsafe {
match prev {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
#[test]
#[serial_test::serial(home_env)]
fn session_jsonl_size_then_recover_after_round_trips() {
let tmp = tempfile::tempdir().expect("tempdir");
let prev = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", tmp.path());
}
let working_dir = PathBuf::from("/round-trip-test");
let uuid = "22222222-2222-2222-2222-222222222222";
let path = session_jsonl_path(&working_dir, uuid).expect("HOME set");
std::fs::create_dir_all(path.parent().unwrap()).expect("mkdir");
let prior = "{\"message\":{\"content\":[{\"type\":\"tool_use\",\"name\":\"mcp__nsed__nsed_propose\",\"input\":{\"content\":\"prior-phase\"}}]}}\n";
std::fs::write(&path, prior).expect("write prior");
let captured_offset = session_jsonl_size(&working_dir, uuid);
assert_eq!(captured_offset as usize, prior.len());
let current = "{\"message\":{\"content\":[{\"type\":\"tool_use\",\"name\":\"mcp__nsed__nsed_propose\",\"input\":{\"content\":\"current-attempt\"}}]}}\n";
let mut combined = std::fs::read_to_string(&path).unwrap();
combined.push_str(current);
std::fs::write(&path, combined).expect("append current");
let unbounded =
recover_from_session(&working_dir, uuid, &["mcp__nsed__nsed_propose"]).unwrap();
assert_eq!(unbounded["content"], "current-attempt");
let bounded = recover_from_session_after(
&working_dir,
uuid,
&["mcp__nsed__nsed_propose"],
captured_offset,
)
.unwrap();
assert_eq!(bounded["content"], "current-attempt");
let post_current_offset = session_jsonl_size(&working_dir, uuid);
let none = recover_from_session_after(
&working_dir,
uuid,
&["mcp__nsed__nsed_propose"],
post_current_offset,
);
assert!(none.is_none());
unsafe {
match prev {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
#[test]
#[serial_test::serial(home_env)]
fn recover_from_session_returns_none_on_missing_file() {
let prev = std::env::var_os("HOME");
unsafe {
std::env::set_var("HOME", "/tmp/nsed-test-home-does-not-exist-xyz");
}
let got = recover_from_session(
&PathBuf::from("/work"),
"00000000-0000-0000-0000-000000000000",
&["mcp__nsed__nsed_propose"],
);
assert!(got.is_none());
unsafe {
match prev {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
}
}
}