diff --git a/crates/ai/src/cli_agents/adapters.rs b/crates/ai/src/cli_agents/adapters.rs index a54d0eb2..9421be0d 100644 --- a/crates/ai/src/cli_agents/adapters.rs +++ b/crates/ai/src/cli_agents/adapters.rs @@ -90,6 +90,23 @@ pub trait AgentAdapter: Send + Sync { fn check_tool_server(&self, _stop: &CancelToken) -> Result<(), ToolError> { Ok(()) } + /// The file in which this computer keeps the agent's saved session `id`, so the project can + /// keep a copy that travels with it. `None`: it keeps none we can copy, or not here. + fn session_file(&self, _id: &str) -> Option { + None + } + /// What a new session needs to know of an earlier one, read from a kept copy of its + /// [`session_file`](AgentAdapter::session_file): what it was asked and what it said, the + /// newest kept, within `max` characters. `None`: nothing to tell. + fn handoff(&self, _saved: &str, _max: usize) -> Option { + None + } + /// Put a kept copy of session `id` back where the agent looks for it, for a run in `cwd`, + /// when this computer doesn't have it, so the same session carries on. `Ok(true)`: put + /// back; `Ok(false)`: nothing to do (it's here already, or this agent keeps none). + fn restore_session(&self, _id: &str, _saved: &[u8], _cwd: &std::path::Path) -> Result { + Ok(false) + } } /// The adapters a run can use. @@ -193,6 +210,18 @@ fn question_args(mut a: Vec, cx: &ArgsCx) -> Vec { a } +/// How Claude Code's result names a `--resume` session it doesn't have (2.1: `errors`, with no +/// turn taken). Its sessions are kept per computer and per folder, so a project copied elsewhere +/// has an id it can't find. +const NO_SUCH_SESSION: &str = "No conversation found with session ID"; + +/// A failed result that only says the session to resume isn't there. +fn no_such_session(v: &Value) -> bool { + let named = |e: &Value| e.as_str().is_some_and(|e| e.contains(NO_SUCH_SESSION)); + let turns = v.get("num_turns").and_then(Value::as_u64).unwrap_or(0); + turns == 0 && (v.get("errors").and_then(Value::as_array).is_some_and(|a| a.iter().any(named)) || v.get("result").is_some_and(named)) +} + /// Claude Code in headless mode (flags checked against `claude --help`). pub struct ClaudeCode; @@ -243,6 +272,9 @@ impl AgentAdapter for ClaudeCode { a } + /// Claude Code's stream-json: text and tool calls as they come, the session id from any line, + /// and from the final `result` its usage and cost, a failure in plain words, or a session to + /// resume that isn't on this computer (`session_missing`, with its own message). fn parse(&self, p: &mut StreamParser, v: &Value) -> Vec { let mut out = Vec::new(); let s = |k: &str| v.get(k).and_then(Value::as_str).unwrap_or_default().to_string(); @@ -284,7 +316,17 @@ impl AgentAdapter for ClaudeCode { .saturating_add(u.get("cache_read_input_tokens").and_then(Value::as_u64).unwrap_or(0)); p.output_tokens = u.get("output_tokens").and_then(Value::as_u64).unwrap_or(0); } - if v.get("is_error").and_then(Value::as_bool).unwrap_or(false) { + if v.get("is_error").and_then(Value::as_bool).unwrap_or(false) && no_such_session(v) { + p.session_missing = true; + out.push(AgentEvent::Error { + message: "Claude Code's earlier conversation about this project isn't on this computer.".into(), + remediation: Some( + "It was started on another computer or in another folder. Ask again: a new conversation starts from what the project already has." + .into(), + ), + log: None, + }); + } else if v.get("is_error").and_then(Value::as_bool).unwrap_or(false) { let why = if s("result").is_empty() { s("subtype") } else { s("result") }; let (message, remediation) = claude_code_failure(&why); out.push(AgentEvent::Error { message, remediation, log: None }); @@ -313,6 +355,122 @@ impl AgentAdapter for ClaudeCode { fn sign_in_fix(&self) -> Option { sign_in_hint("login") } + + /// The session under Claude Code's own folder (see [`claude_session_file`]). + fn session_file(&self, id: &str) -> Option { + claude_session_file(&claude_config_dir()?, id) + } + + /// The kept session's requests and replies (see [`claude_handoff`]). + fn handoff(&self, saved: &str, max: usize) -> Option { + claude_handoff(saved, max) + } + + /// Back under Claude Code's own folder (see [`claude_restore`]); without one there's nowhere + /// to put it, which is said. + fn restore_session(&self, id: &str, saved: &[u8], cwd: &std::path::Path) -> Result { + let config = claude_config_dir().ok_or_else(|| ToolError::new(ErrorCode::BadSettings, "Couldn't find Claude Code's folder on this computer."))?; + claude_restore(&config, id, saved, cwd) + } +} + +/// Claude Code's own folder: `CLAUDE_CONFIG_DIR` when it's set (the person's choice, which the +/// agent we start keeps too), else `~/.claude`. +fn claude_config_dir() -> Option { + let set = std::env::var_os("CLAUDE_CONFIG_DIR").filter(|v| !v.is_empty()).map(std::path::PathBuf::from); + set.or_else(|| dirs::home_dir().map(|h| h.join(".claude"))) +} + +/// Claude Code's name for the folder a session ran in: the path with everything but letters and +/// digits made `-`. It finds a session by its id in any of these folders (2.1), so a long path +/// cut short still works. +fn claude_project_folder(cwd: &std::path::Path) -> String { + cwd.to_string_lossy().chars().map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }).take(200).collect() +} + +/// Put a kept Claude Code session back under `config` (see [`AgentAdapter::restore_session`]). +/// Only a copy of that same session is put back, and never over one that's there. +pub fn claude_restore(config: &std::path::Path, id: &str, saved: &[u8], cwd: &std::path::Path) -> Result { + let valid = !id.is_empty() && id.len() <= 64 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'); + if !valid { + return Err(ToolError::invalid("The project names its builder's session with an id Claude Code doesn't use.")); + } + if claude_session_file(config, id).is_some() { + return Ok(false); + } + let named = format!("\"sessionId\":\"{id}\""); + if !saved.windows(named.len()).any(|w| w == named.as_bytes()) { + return Err(ToolError::invalid("The conversation kept in the project is of another session.")); + } + let path = config.join("projects").join(claude_project_folder(cwd)).join(format!("{id}.jsonl")); + craft_anything_platform::fs::write_atomic_private(&path, saved)?; + Ok(true) +} + +/// Folders under Claude Code's `projects/` looked through for a session. +const SESSION_FOLDERS: usize = 10_000; + +/// Claude Code keeps each session as `projects//.jsonl` under its +/// config folder. An id is a UUID: anything else (a hand-edited manifest) names no file. +pub fn claude_session_file(config: &std::path::Path, id: &str) -> Option { + if id.is_empty() || id.len() > 64 || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') { + return None; + } + let name = format!("{id}.jsonl"); + std::fs::read_dir(config.join("projects")).ok()?.flatten().take(SESSION_FOLDERS).map(|e| e.path().join(&name)).find(|p| p.is_file()) +} + +/// Longest single request or reply in a handoff; a first brief runs to many thousands. +const HANDOFF_ENTRY: usize = 1500; + +/// A Claude Code session read back as a handoff: the messages Craft Anything sent and the text +/// the builder answered with, oldest first, leaving out tool calls, their results, thinking and +/// side tasks. The newest are kept when it's longer than `max` characters. +pub fn claude_handoff(saved: &str, max: usize) -> Option { + let clip = |t: &str| { + let t = t.trim(); + if t.chars().count() > HANDOFF_ENTRY { format!("{}…", t.chars().take(HANDOFF_ENTRY).collect::()) } else { t.to_string() } + }; + let mut entries = Vec::new(); + for v in saved.lines().filter_map(|l| serde_json::from_str::(l).ok()) { + if v.get("isSidechain").and_then(Value::as_bool) == Some(true) { + continue; + } + let content = v.pointer("/message/content"); + match v.get("type").and_then(Value::as_str) { + Some("user") => { + if let Some(t) = content.and_then(Value::as_str).filter(|t| !t.trim().is_empty()) { + entries.push(format!("Craft Anything asked you: {}", clip(t))); + } + } + Some("assistant") => { + let text: String = content + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|b| b.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|b| b.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n"); + if !text.trim().is_empty() && text.trim() != "No response requested." { + entries.push(format!("You answered: {}", clip(&text))); + } + } + _ => {} + } + } + let mut kept = Vec::new(); + let mut used = 0usize; + for e in entries.iter().rev() { + let n = e.chars().count().saturating_add(2); + if used.saturating_add(n) > max { + break; + } + used = used.saturating_add(n); + kept.push(e.as_str()); + } + kept.reverse(); + (!kept.is_empty()).then(|| kept.join("\n\n")) } /// Codex (`codex exec --json`). Flags are checked against `codex exec --help` before use. diff --git a/crates/ai/src/cli_agents/mod.rs b/crates/ai/src/cli_agents/mod.rs index f02f88f3..a3714ede 100644 --- a/crates/ai/src/cli_agents/mod.rs +++ b/crates/ai/src/cli_agents/mod.rs @@ -244,6 +244,9 @@ pub struct StreamParser { pub output_tokens: u64, pub cost_usd: Option, pub saw_result: bool, + /// The run was asked to continue a saved session the agent doesn't have (it lives on another + /// computer, or under another folder), so it did nothing. + pub session_missing: bool, } fn short_tool(name: &str) -> String { @@ -276,8 +279,18 @@ fn summary_of(s: &str) -> String { } impl StreamParser { + /// A parser for one run, reading lines the way `adapter` says. pub fn new(adapter: Arc) -> Self { - StreamParser { adapter, session_id: None, tool_names: HashMap::new(), input_tokens: 0, output_tokens: 0, cost_usd: None, saw_result: false } + StreamParser { + adapter, + session_id: None, + tool_names: HashMap::new(), + input_tokens: 0, + output_tokens: 0, + cost_usd: None, + saw_result: false, + session_missing: false, + } } /// Events for one output line. @@ -422,13 +435,17 @@ pub struct CliConversation { stop: Stop, session: Arc>>, verified: Arc>>, + /// The last run's saved session wasn't there (see [`Conversation::session_lost`]). + lost: Arc, } impl CliConversation { + /// A conversation run as `spec` says; with `spec.resume`, its first message continues that + /// saved session. pub fn new(spec: CliSpec) -> Self { let (tx, rx) = std::sync::mpsc::channel(); let session = Arc::new(Mutex::new(spec.resume.clone())); - CliConversation { spec, tx, rx, busy: Arc::default(), stop: Stop::default(), session, verified: Arc::default() } + CliConversation { spec, tx, rx, busy: Arc::default(), stop: Stop::default(), session, verified: Arc::default(), lost: Arc::default() } } /// The agent's session id once a run has reported it (to continue later). @@ -466,7 +483,34 @@ fn remote_command(program: &str, args: Vec, env: &[(&'static str, String cmd } -fn run_cli(spec: &CliSpec, text: &str, tx: &Sender, stop: &Stop, session: &Mutex>, verified: &Mutex>) { +/// The run's shared state: the session it continues and reports, whether the agent's flags were +/// checked, and whether the session it was to continue was missing. +struct RunState { + session: Arc>>, + verified: Arc>>, + lost: Arc, +} + +/// End a turn: take the next message first, then say the turn is done (`send(done)`), since +/// whoever reads `TurnDone` may send the next message at once. The other order refuses it ("Still +/// working") with no `TurnDone`, and the caller waits forever. +fn end_turn(busy: &AtomicBool, done: AgentEvent, send: impl FnOnce(AgentEvent)) { + busy.store(false, Ordering::SeqCst); + send(done); +} + +/// The `TurnDone` that ends a turn of `backend` with no usage to report. +fn nothing_done(backend: &str) -> AgentEvent { + AgentEvent::TurnDone { backend: backend.to_string(), input_tokens: 0, output_tokens: 0, cost_usd: None, cost_is_estimate: false } +} + +/// One message as one run of the agent's program (here or over SSH), its events sent on `tx` +/// as they come. Returns the `TurnDone` that ends the turn, whatever happened, for the caller to +/// send once the conversation can take the next message. The session it reports is kept for the +/// next message; one it was asked to continue but doesn't have is forgotten and flagged in +/// `state.lost` before the error that says so is sent. +fn run_cli(spec: &CliSpec, text: &str, tx: &Sender, stop: &Stop, state: &RunState) -> AgentEvent { + let RunState { session, verified, lost } = state; let name = spec.config.name.clone(); let agent = spec.config.model.clone(); let program = spec.options.command.clone().unwrap_or_else(|| program_for(&agent)); @@ -501,7 +545,7 @@ fn run_cli(spec: &CliSpec, text: &str, tx: &Sender, stop: &Stop, ses remediation: Some("Ask with Claude Code, or with an AI that has an API key.".into()), log: None, }); - return; + return nothing_done(&name); } let access = match (&spec.builder, spec.deep, spec.question) { (_, true, true) => Access::Question, @@ -525,7 +569,7 @@ fn run_cli(spec: &CliSpec, text: &str, tx: &Sender, stop: &Stop, ses let _ = tx.send(AgentEvent::Status { text: format!("Asking {}{}…", display_for(&agent), remote.as_ref().map(|h| format!(" on {h}")).unwrap_or_default()) }); let parser = Arc::new(Mutex::new(StreamParser::new(adapter.clone()))); - let (p2, tx2, tx_err) = (parser.clone(), tx.clone(), tx.clone()); + let (p2, tx2, tx_err, lost2) = (parser.clone(), tx.clone(), tx.clone(), lost.clone()); let stop2 = stop.clone(); let mut proc = (if remote.is_none() { adapter.process(&prog) } else { Process::new(&prog) }) .args(&argv) @@ -538,7 +582,13 @@ fn run_cli(spec: &CliSpec, text: &str, tx: &Sender, stop: &Stop, ses .stdout_cap(4 * 1024 * 1024) .cancel_when(move || stop2.is_stopped()) .on_stdout_line(move |l| { - for e in p2.lock().unwrap_or_else(PoisonError::into_inner).line(l) { + let mut p = p2.lock().unwrap_or_else(PoisonError::into_inner); + let events = p.line(l); + // Said before the error that follows it, so whoever reads the error can tell. + if p.session_missing { + lost2.store(true, Ordering::SeqCst); + } + for e in events { let _ = tx2.send(e); } }) @@ -556,10 +606,15 @@ fn run_cli(spec: &CliSpec, text: &str, tx: &Sender, stop: &Stop, ses } let result = proc.run(); let p = parser.lock().unwrap_or_else(PoisonError::into_inner); - if let Some(id) = &p.session_id { + if p.session_missing { + // The id it reported is the one it couldn't find: the next message starts a new session. + *session.lock().unwrap_or_else(PoisonError::into_inner) = None; + } else if let Some(id) = &p.session_id { *session.lock().unwrap_or_else(PoisonError::into_inner) = Some(id.clone()); } match result { + // It said why already, and there's nothing more in its output to keep. + Ok(_) if p.session_missing => {} Ok(out) if !out.success && !p.saw_result => { let _ = tx.send(shown.exit_event(&agent, &out)); } @@ -577,13 +632,7 @@ fn run_cli(spec: &CliSpec, text: &str, tx: &Sender, stop: &Stop, ses let _ = tx.send(shown.error_event(&agent, e)); } } - let _ = tx.send(AgentEvent::TurnDone { - backend: name, - input_tokens: p.input_tokens, - output_tokens: p.output_tokens, - cost_usd: p.cost_usd, - cost_is_estimate: true, - }); + AgentEvent::TurnDone { backend: name, input_tokens: p.input_tokens, output_tokens: p.output_tokens, cost_usd: p.cost_usd, cost_is_estimate: true } } /// What's needed to explain a failed CLI run: the command with the person's message left out, and @@ -707,6 +756,15 @@ impl Conversation for CliConversation { CliConversation::session_id(self) } + /// Set by the last run (see `run_cli`) and cleared when the next message is sent. + fn session_lost(&self) -> bool { + self.lost.load(Ordering::SeqCst) + } + + /// Runs the message on a thread of its own; a message while one is running is refused with an + /// error event. Every turn ends with `TurnDone`, a runner that panics or can't start included, + /// and the conversation takes the next message before that's sent: whoever reads it may send + /// the next message at once (a session that wasn't there is followed by a fresh start). fn send(&mut self, text: &str) { if self.busy.swap(true, Ordering::SeqCst) { let _ = self.tx.send(AgentEvent::Error { @@ -717,26 +775,24 @@ impl Conversation for CliConversation { return; } self.stop.reset(); - let (spec, tx, stop, busy, session, verified) = - (self.spec.clone(), self.tx.clone(), self.stop.clone(), self.busy.clone(), self.session.clone(), self.verified.clone()); + self.lost.store(false, Ordering::SeqCst); + let (spec, tx, stop, busy) = (self.spec.clone(), self.tx.clone(), self.stop.clone(), self.busy.clone()); + let state = RunState { session: self.session.clone(), verified: self.verified.clone(), lost: self.lost.clone() }; let text = text.to_string(); let spawned = std::thread::Builder::new().name("ai-cli".into()).spawn(move || { - let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_cli(&spec, &text, &tx, &stop, &session, &verified))); - if r.is_err() { + let done = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_cli(&spec, &text, &tx, &stop, &state))).unwrap_or_else(|_| { let _ = tx.send(AgentEvent::Error { message: "The agent runner hit a bug and stopped safely.".into(), remediation: None, log: None }); - let _ = tx.send(AgentEvent::TurnDone { - backend: spec.config.name.clone(), - input_tokens: 0, - output_tokens: 0, - cost_usd: None, - cost_is_estimate: false, - }); - } - busy.store(false, Ordering::SeqCst); + nothing_done(&spec.config.name) + }); + end_turn(&busy, done, |e| { + let _ = tx.send(e); + }); }); if spawned.is_err() { - self.busy.store(false, Ordering::SeqCst); let _ = self.tx.send(AgentEvent::Error { message: "Couldn't start a worker thread.".into(), remediation: None, log: None }); + end_turn(&self.busy, nothing_done(&self.spec.config.name), |e| { + let _ = self.tx.send(e); + }); } } @@ -1031,6 +1087,98 @@ mod tests { assert_eq!((p.input_tokens, p.output_tokens, p.cost_usd), (10, 7, Some(0.12))); } + /// #446: a project copied from another computer names a session this one doesn't have. The + /// result line is Claude Code 2.1's own. + #[test] + fn a_session_that_is_not_on_this_computer_is_told_apart() { + let missing = json!({"type":"result","subtype":"error_during_execution","is_error":true,"num_turns":0,"session_id":"39b5b312-5678-1234-961c-32112666ffab","total_cost_usd":0,"errors":["No conversation found with session ID: 39b5b312-5678-1234-961c-32112666ffab"]}); + let mut p = StreamParser::new(adapter_for("claude-code")); + let events = p.line(&missing.to_string()); + assert!(p.session_missing); + let [AgentEvent::Error { message, remediation: Some(fix), .. }] = events.as_slice() else { panic!("{events:?}") }; + assert!(message.contains("isn't on this computer") && fix.contains("Ask again"), "{message} / {fix}"); + // Any other failure is still that failure. + let other = json!({"type":"result","subtype":"error_during_execution","is_error":true,"num_turns":3,"errors":["Something else"]}); + let mut p = StreamParser::new(adapter_for("claude-code")); + let events = p.line(&other.to_string()); + assert!(!p.session_missing); + assert!(matches!(events.as_slice(), [AgentEvent::Error { message, .. }] if message.contains("error_during_execution")), "{events:?}"); + } + + /// #451: Claude Code's session is found wherever it ran, and only by a real id. + #[test] + fn a_claude_code_session_is_found_by_its_id_alone() { + let config = tempfile::tempdir().unwrap(); + let folder = config.path().join("projects").join("-Users-someone-Documents-Craft-Anything-notes"); + std::fs::create_dir_all(&folder).unwrap(); + std::fs::write(folder.join("0e4a6b1c-1111-4222-8333-944455556666.jsonl"), "{}\n").unwrap(); + std::fs::write(config.path().join("projects").join("stray.jsonl"), "{}\n").unwrap(); + let found = adapters::claude_session_file(config.path(), "0e4a6b1c-1111-4222-8333-944455556666"); + assert_eq!(found, Some(folder.join("0e4a6b1c-1111-4222-8333-944455556666.jsonl"))); + assert_eq!(adapters::claude_session_file(config.path(), "1b70e5c4-0000-0000-0000-000000000000"), None); + for bad in ["", "../stray", "a/b", &"a".repeat(65)] { + assert_eq!(adapters::claude_session_file(config.path(), bad), None, "{bad:?}"); + } + assert_eq!(adapters::claude_session_file(&config.path().join("nowhere"), "0e4a6b1c-1111-4222-8333-944455556666"), None); + } + + /// #451: a session kept in the project is put back where Claude Code finds it, once, and + /// only as the session it was. + #[test] + fn a_kept_claude_code_session_is_put_back_on_another_computer() { + let config = tempfile::tempdir().unwrap(); + let id = "0e4a6b1c-1111-4222-8333-944455556666"; + let saved = format!("{{\"type\":\"user\",\"sessionId\":\"{id}\",\"message\":{{\"content\":\"Make a notes app.\"}}}}\n"); + let cwd = Path::new("/home/someone/Craft Anything/notes.app"); + assert!(adapters::claude_restore(config.path(), id, saved.as_bytes(), cwd).unwrap()); + let put = config.path().join("projects").join("-home-someone-Craft-Anything-notes-app").join(format!("{id}.jsonl")); + assert_eq!(std::fs::read_to_string(&put).unwrap(), saved); + assert_eq!(adapters::claude_session_file(config.path(), id), Some(put.clone()), "Claude Code finds it by its id"); + // There already: left as it is (it may have moved on since the copy was kept). + std::fs::write(&put, "newer").unwrap(); + assert!(!adapters::claude_restore(config.path(), id, saved.as_bytes(), cwd).unwrap()); + assert_eq!(std::fs::read_to_string(&put).unwrap(), "newer"); + // A copy of another session, or an id that isn't one, puts nothing back. + let other = "1b70e5c4-0000-0000-0000-000000000000"; + assert!(adapters::claude_restore(config.path(), other, saved.as_bytes(), cwd).unwrap_err().message.contains("another session")); + assert!(adapters::claude_restore(config.path(), "../../x", saved.as_bytes(), cwd).is_err()); + assert_eq!(adapters::claude_session_file(config.path(), other), None); + assert!(!adapter_for("codex").restore_session(id, saved.as_bytes(), cwd).unwrap()); + } + + /// #451: a kept session read back as a handoff: what was asked and answered, nothing else. + /// The line shapes are Claude Code 2.1's. + #[test] + fn a_kept_claude_code_session_reads_back_as_its_requests_and_replies() { + let lines = [ + json!({"type":"queue-operation","operation":"enqueue","content":"queued copy"}), + json!({"type":"user","isSidechain":false,"message":{"role":"user","content":"Make a notes app."}}), + json!({"type":"assistant","isSidechain":false,"message":{"content":[{"type":"thinking","thinking":"secret plan"}]}}), + json!({"type":"assistant","isSidechain":false,"message":{"content":[{"type":"tool_use","id":"t1","name":"Bash","input":{"command":"ls"}}]}}), + json!({"type":"user","isSidechain":false,"message":{"content":[{"type":"tool_result","tool_use_id":"t1","content":"app/"}]}}), + json!({"type":"assistant","isSidechain":true,"message":{"content":[{"type":"text","text":"side task"}]}}), + json!({"type":"assistant","isSidechain":false,"message":{"content":[{"type":"text","text":"Built a notes app with a sidebar."}]}}), + json!({"type":"assistant","isSidechain":false,"message":{"content":[{"type":"text","text":"No response requested."}]}}), + json!({"type":"user","isSidechain":false,"message":{"content":"The person asks: \"make it blue\""}}), + json!({"type":"assistant","isSidechain":false,"message":{"content":[{"type":"text","text":"Made it blue."}]}}), + ]; + let saved = lines.iter().map(Value::to_string).collect::>().join("\n") + "\nnot json\n"; + let all = adapters::claude_handoff(&saved, 10_000).unwrap(); + assert_eq!( + all, + "Craft Anything asked you: Make a notes app.\n\nYou answered: Built a notes app with a sidebar.\n\nCraft Anything asked you: The person asks: \"make it blue\"\n\nYou answered: Made it blue." + ); + // Short of room, the newest is kept. + let short = adapters::claude_handoff(&saved, 80).unwrap(); + assert!(short.ends_with("You answered: Made it blue.") && !short.contains("notes app"), "{short}"); + // A first brief runs long: it's cut short. + let long = json!({"type":"user","message":{"content":"x".repeat(10_000)}}).to_string(); + assert!(adapters::claude_handoff(&long, 10_000).unwrap().chars().count() < 1600); + assert_eq!(adapters::claude_handoff("", 10_000), None); + assert_eq!(adapters::claude_handoff("\u{0}garbage", 10_000), None); + assert!(adapter_for("claude-code").handoff(&saved, 10_000).is_some() && adapter_for("codex").handoff(&saved, 10_000).is_none()); + } + #[test] fn parses_codex_and_gemini_streams() { let mut p = StreamParser::new(adapter_for("codex")); @@ -1110,6 +1258,55 @@ mod tests { assert_eq!(text, "read 40000 bytes", "{all:?}"); } + /// Whoever reads `TurnDone` may send the next message at once (a session that wasn't there is + /// followed by a fresh start), so the conversation takes it before the turn is said to be + /// done. The race this prevents lasts nanoseconds, too short to catch from outside, so the + /// order is checked where it's made. + #[test] + fn a_turn_is_said_to_be_done_only_once_the_next_message_can_be_taken() { + let busy = AtomicBool::new(true); + let mut sent = None; + end_turn(&busy, nothing_done("Claude"), |e| { + assert!(!busy.load(Ordering::SeqCst), "TurnDone sent while still busy"); + sent = Some(e); + }); + assert!(matches!(sent, Some(AgentEvent::TurnDone { .. }))); + } + + /// Message after message, each turn ends with `TurnDone`, with nothing refused as "still + /// working" when the next is sent the moment the last is done. + #[cfg(unix)] + #[test] + fn the_next_message_is_taken_as_soon_as_a_turn_is_done() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let program = dir.path().join("claude"); + let reply = r#"{"type":"result","subtype":"success","is_error":false,"num_turns":1,"session_id":"s1"}"#; + std::fs::write(&program, format!("#!/bin/sh\ncat >/dev/null\necho '{reply}'\n")).unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + let config = + BackendConfig { name: "Claude".into(), kind: BackendKind::AgentCli, model: "claude-code".into(), base_url: None, ssh_host: None, enabled: true }; + let options = BackendOptions { command: Some(program.display().to_string()), ..Default::default() }; + let mut c = CliConversation::new(CliSpec::new(config, options, false, None)); + for round in 0..25 { + c.send("go"); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + let mut events = Vec::new(); + loop { + let got = c.poll(); + let done = got.iter().any(|e| matches!(e, AgentEvent::TurnDone { .. })); + events.extend(got); + if done { + assert!(!c.is_busy(), "round {round}: the turn was said to be done while still busy"); + break; + } + assert!(std::time::Instant::now() < deadline, "round {round}: no TurnDone: {events:?}"); + std::hint::spin_loop(); + } + assert!(!events.iter().any(|e| matches!(e, AgentEvent::Error { .. })), "round {round}: {events:?}"); + } + } + /// #91: a Mac app with no CLI beside it reached Claude Code, which stopped with a stack trace. #[test] fn a_missing_or_broken_helper_is_said_plainly() { diff --git a/crates/ai/src/lib.rs b/crates/ai/src/lib.rs index 81a2981c..1502093e 100644 --- a/crates/ai/src/lib.rs +++ b/crates/ai/src/lib.rs @@ -217,6 +217,12 @@ pub trait Conversation: Send { fn session_id(&self) -> Option { None } + /// The saved session the last message was to continue isn't on this computer (the project + /// was started on another one, or in another folder): that message did nothing, and the next + /// one starts a new session, which knows nothing of the old. + fn session_lost(&self) -> bool { + false + } /// Drain new events (non-blocking). fn poll(&mut self) -> Vec; fn answer_consent(&mut self, call_id: &str, allow: bool); diff --git a/crates/ai/src/rebuild/brief.rs b/crates/ai/src/rebuild/brief.rs index 01c20592..15ae226f 100644 --- a/crates/ai/src/rebuild/brief.rs +++ b/crates/ai/src/rebuild/brief.rs @@ -292,6 +292,28 @@ pub(super) fn continue_prompt(kind: &dyn TargetKind) -> String { ) } +/// For an agent whose saved session isn't on this computer (the project was copied from another +/// one, or moved): the whole brief, then what was made so far and where to find it, and the +/// earlier conversation as the project kept it (`handoff`), else its last summary, so it carries +/// on from the project rather than starting over. +pub(super) fn resumed_elsewhere(brief: &str, last_reply: &str, handoff: Option<&str>) -> String { + let mut s = format!( + "{brief}\n\nThis project is already under way: an earlier conversation built it, but that conversation isn't on this computer (the project was started on another computer or in another folder). Everything made so far is in app/. Before changing anything, look through app/ and the open questions (core_unknowns_show) to see what's done, then carry on from there instead of starting again." + ); + if let Some(h) = handoff.map(str::trim).filter(|h| !h.is_empty()) { + s.push_str(&format!( + "\n\nThe earlier conversation, as the project kept it (oldest first; when it was long, the newest part is kept and long messages are cut short):\n\n{h}" + )); + return s; + } + let last = last_reply.trim(); + if !last.is_empty() { + let last: String = last.chars().take(2000).collect(); + s.push_str(&format!("\n\nWhat the earlier conversation last told the person:\n\"{last}\"")); + } + s +} + pub(super) fn follow_up(message: &str, kind: &dyn TargetKind) -> String { let build = kind.rebuild_words(); format!( diff --git a/crates/ai/src/rebuild/mod.rs b/crates/ai/src/rebuild/mod.rs index 2af385d4..ffdfed34 100644 --- a/crates/ai/src/rebuild/mod.rs +++ b/crates/ai/src/rebuild/mod.rs @@ -566,6 +566,11 @@ impl Worker { } } + /// The whole run: checks the builder can start, studies a new project, then builds and checks + /// in rounds until it's good enough or the rounds run out. A coding agent's saved session is + /// put back from the project first when this computer doesn't have it, and the project keeps + /// a copy after every turn; when it can't be resumed, a new one starts from the brief and what + /// the project kept. Returns the summary shown when it's ready. fn run( &mut self, ws: &mut Workspace, @@ -613,6 +618,11 @@ impl Worker { // A coding agent resumes its own session; our loop resumes its kept transcript. let resumable = api.as_ref().is_none_or(crate::agent::ApiConversation::resumed); let api_loop = api.is_some(); + // A coding agent on this computer whose session the project keeps a copy of (#451). + let keeper = (!api_loop && !remote).then(|| opts.adapters.for_agent(&entry.config.model)); + if let Some(a) = &keeper { + self.restore_session(ws, a.as_ref()); + } // A coding agent that never saved a session starts over, so it needs the brief. let has_history = resumable && (api_loop || ws.manifest.agent_session.is_some()); self.step(ws, Step::Build, Status::Building, "Getting the builder ready…")?; @@ -644,6 +654,12 @@ impl Worker { if first && ws.app_dir().join(ORIGINALS_MANIFEST).is_file() { prompt.push_str(ORIGINALS_READY); } + // A saved session may not be on this computer (the project was copied here): then a new + // one starts from the brief and what the project already has. + let mut fresh = (!api_loop && ws.manifest.agent_session.is_some()).then(|| { + let handoff = keeper.as_ref().and_then(|a| a.handoff(&kept_session(ws)?, HANDOFF_CHARS)); + resumed_elsewhere(&brief(kind, &cx, opts.message.as_deref()), &ws.manifest.last_reply, handoff.as_deref()) + }); let mut round = ws.manifest.checks.iter().map(|c| c.round).max().unwrap_or(0); let mut refinements = 0; // Deep mode on a program: decompiling is required, so a build turn without any gets one reminder. @@ -660,7 +676,12 @@ impl Worker { }; self.step(ws, Step::Build, Status::Building, doing)?; ws.manifest.awaiting_check = false; - let reply = self.turn(ws, agent.as_mut(), &prompt)?; + let reply = self.turn(ws, agent.as_mut(), &prompt, fresh.take()); + // Kept even when the turn failed: the conversation is still worth carrying. + if let Some(a) = &keeper { + self.keep_session(ws, a.as_ref()); + } + let reply = reply?; if !reply.trim().is_empty() { ws.manifest.last_reply = reply.trim().to_string(); } @@ -777,16 +798,63 @@ impl Worker { }) } - fn turn(&mut self, ws: &mut Workspace, agent: &mut dyn Conversation, prompt: &str) -> Result { + /// A copy of the builder's session in the project, so the conversation travels with the + /// folder (#451). A copy that can't be made doesn't stop the build, but it's said. + fn keep_session(&self, ws: &Workspace, adapter: &dyn crate::cli_agents::AgentAdapter) { + let Some(from) = ws.manifest.agent_session.as_deref().and_then(|id| adapter.session_file(id)) else { return }; + let kept = match std::fs::metadata(&from) { + Ok(m) if m.len() > MAX_KEPT_SESSION => Err(format!("it's over {} MB", MAX_KEPT_SESSION / 1_000_000)), + Ok(_) => std::fs::read(&from) + .map_err(|e| e.to_string()) + .and_then(|b| craft_anything_platform::fs::write_atomic_private(&ws.agent_session_path(), &b).map_err(|e| e.message)), + Err(e) => Err(e.to_string()), + }; + if let Err(why) = kept { + let text = + format!("Couldn't keep a copy of the builder's conversation in the project ({why}), so it won't go with the folder to another computer."); + self.emit(RunEvent::Agent(AgentEvent::Status { text })); + } + } + + /// The project came from another computer with its builder's session kept in it: put it back + /// where the agent looks, so the same conversation carries on (#451). If that fails, a new one + /// starts from what the project has when the turn finds the session missing. + fn restore_session(&self, ws: &Workspace, adapter: &dyn crate::cli_agents::AgentAdapter) { + let (Some(id), Some(saved)) = (ws.manifest.agent_session.as_deref(), kept_session_bytes(ws)) else { return }; + let text = match adapter.restore_session(id, &saved, &ws.root) { + Ok(false) => return, + Ok(true) => "Brought the builder's earlier conversation over from the project folder.".to_string(), + Err(e) => format!("Couldn't bring the builder's earlier conversation over from the project folder ({}).", e.message), + }; + self.emit(RunEvent::Agent(AgentEvent::Status { text })); + } + + /// `fresh`: what to send instead when the agent's saved session turns out not to be on this + /// computer. + fn turn(&mut self, ws: &mut Workspace, agent: &mut dyn Conversation, prompt: &str, mut fresh: Option) -> Result { let mut prompt = prompt.to_string(); let mut tries = 0u64; loop { let result = self.converse(ws, agent, &prompt); - if let Some(id) = agent.session_id() { + let lost = result.is_err() && agent.session_lost(); + if lost { + // The id is for a session this computer doesn't have: never ask for it again. + ws.manifest.agent_session = None; + ws.save()?; + } else if let Some(id) = agent.session_id() { ws.manifest.agent_session = Some(id); ws.save()?; } match result { + Err(_) if lost && let Some(f) = fresh.take() => { + self.step( + ws, + Step::Build, + Status::Building, + "The builder's earlier conversation isn't on this computer. Starting a new one from what the project already has…", + )?; + prompt = f; + } Err(e) if e.code != ErrorCode::Cancelled && tries < 3 && agent.session_id().is_some() && worth_retrying(&e.message) => { tries += 1; let why = e.message.chars().take(160).collect::(); @@ -848,7 +916,11 @@ impl Worker { self.record_usage(Some((*input_tokens, *output_tokens, *cost_usd, *cost_is_estimate))); } let done = matches!(e, AgentEvent::TurnDone { .. }); - self.emit(RunEvent::Agent(e)); + // A saved session that isn't on this computer isn't the build failing: the turn + // starts a new one (or, with nothing to start it from, the run's error says why). + if !(matches!(e, AgentEvent::Error { .. }) && agent.session_lost()) { + self.emit(RunEvent::Agent(e)); + } if done { self.stop.check()?; // An agent-level error (sign-in, crash, limits) ends the run: checking a @@ -894,6 +966,25 @@ impl Worker { } } +/// Largest builder session the project keeps a copy of. +const MAX_KEPT_SESSION: u64 = 64_000_000; +/// How much of a kept session a new one is handed when the old one isn't on this computer. +const HANDOFF_CHARS: usize = 16_000; + +/// The builder's session as the project kept it, if it did. +fn kept_session_bytes(ws: &Workspace) -> Option> { + let path = ws.agent_session_path(); + if std::fs::metadata(&path).ok()?.len() > MAX_KEPT_SESSION { + return None; + } + std::fs::read(path).ok() +} + +/// The kept session as text, for a handoff (a broken byte doesn't lose the rest). +fn kept_session(ws: &Workspace) -> Option { + kept_session_bytes(ws).map(|b| String::from_utf8_lossy(&b).into_owned()) +} + /// What an API-backend builder is told first: its tools differ from an agent app's. const API_BUILDER: &str = "Your tools: build_files_list, build_file_read, build_file_write and build_file_edit for the project's files (paths relative to the project folder, e.g. app/src/main.rs), and build_command_run for commands, which run in app/ (never start dev servers or watchers). Where the steps below mention Read, Write, Edit or Bash, use these. For the checklist step use build_checklist (the same shape as TodoWrite: todos with content, activeForm and status) and call it again as you go. You can't look at pictures: use capture/README.md, capture/capture.json and the evidence instead. Ask the person with core_person_ask only when a choice really changes the result. Other Craft Anything tools are available too; enable a toolset with enable_toolset when you need it. rebuild_check isn't available in this loop: Craft Anything checks your build after each turn instead."; diff --git a/crates/ai/src/rebuild/question.rs b/crates/ai/src/rebuild/question.rs index c4fcbb72..1acac89e 100644 --- a/crates/ai/src/rebuild/question.rs +++ b/crates/ai/src/rebuild/question.rs @@ -71,19 +71,25 @@ pub fn ask( let cfg = crate::AiConfig::from_settings(settings); let entry = builder_backend(&cfg, project_builder(&cfg, ws))?; let deep = settings.deep_mode; - let (mut conv, from): (Box, AnsweredFrom) = match entry.config.kind { + let cli = |resume: Option| -> Box { + let spec = CliSpec { + question: true, + deep, + logs: logs.clone(), + resume, + preamble: Some(PREAMBLE.into()), + ..CliSpec::new(entry.config.clone(), entry.options.clone(), false, Some(ws.root.clone())) + }; + Box::new(CliConversation::new(spec)) + }; + let (mut conv, mut from): (Box, AnsweredFrom) = match entry.config.kind { BackendKind::AgentCli => { let session = ws.manifest.agent_session.clone(); + if let Some(id) = session.as_deref() { + bring_back_session(ws, &entry, id); + } let from = if session.is_some() { AnsweredFrom::BuildersSession } else { AnsweredFrom::TheFiles }; - let spec = CliSpec { - question: true, - deep, - logs, - resume: session, - preamble: Some(PREAMBLE.into()), - ..CliSpec::new(entry.config.clone(), entry.options.clone(), false, Some(ws.root.clone())) - }; - (Box::new(CliConversation::new(spec)), from) + (cli(session), from) } _ => { // An engine of its own, pointed at this project, so a build running meanwhile keeps its. @@ -97,18 +103,60 @@ pub fn ask( (Box::new(conv), AnsweredFrom::TheFiles) } }; - let prompt = match from { + let prompt = |from| match from { AnsweredFrom::BuildersSession => format!("{}\n\nQuestion: {question}", context(ws)), AnsweredFrom::TheFiles => format!("{PREAMBLE}{}\n\nQuestion: {question}", context(ws)), }; - conv.send(&prompt); - let (answer, cost_usd) = collect(conv.as_mut(), cancel, WAIT)?; + conv.send(&prompt(from)); + let mut answered = collect(conv.as_mut(), cancel, WAIT); + // The builder's session isn't on this computer (the project was copied here): the question + // is answered from the files instead. + if answered.is_err() && from == AnsweredFrom::BuildersSession && conv.session_lost() { + // Forgotten, so later questions don't ask for it again. Not saving it isn't worth failing + // the question over: the next build finds it missing too, forgets it and says so. + if let Some(lost) = ws.manifest.agent_session.as_deref() { + let _ = forget_lost_session(&ws.root, lost); + } + from = AnsweredFrom::TheFiles; + conv = cli(None); + conv.send(&prompt(from)); + answered = collect(conv.as_mut(), cancel, WAIT); + } + let (answer, cost_usd) = answered?; // A copy of a session reports the whole session's running cost, not this question's (a // live run said $22.67 for a few cents of cache reads): say nothing rather than that. let cost_usd = cost_usd.filter(|_| from != AnsweredFrom::BuildersSession); Ok(Answered { answer, from, backend: entry.config.name.clone(), cost_usd }) } +/// The builder's session, kept in the project, put back where its agent looks when this computer +/// doesn't have it (#451), so the question is answered from the builder's own conversation. Only +/// for an agent here (a remote one keeps its sessions there). Without it the question is answered +/// from the files, as when nothing was kept, so a failure here only costs that. +fn bring_back_session(ws: &Workspace, entry: &crate::BackendEntry, id: &str) { + if entry.config.ssh_host.as_deref().is_some_and(|h| !h.trim().is_empty()) { + return; + } + if let Some(saved) = super::kept_session_bytes(ws) { + let adapter = crate::cli_agents::AgentAdapters::default().for_agent(&entry.config.model); + let _ = adapter.restore_session(id, &saved, &ws.root); + } +} + +/// Forget the project's builder session `lost`, which this computer doesn't have, as the build +/// does. A question may be asked while a build runs, so this reads the project again and only +/// clears the id if it's still `lost`: a build may have saved another since. Returns whether it +/// was cleared. +fn forget_lost_session(root: &Path, lost: &str) -> Result { + let mut now = Workspace::open(root)?; + if now.manifest.agent_session.as_deref() != Some(lost) { + return Ok(false); + } + now.manifest.agent_session = None; + now.save()?; + Ok(true) +} + /// The answer's text and cost, once its turn is done. fn collect(conv: &mut dyn Conversation, cancel: &CancelToken, wait: Duration) -> Result<(String, Option), ToolError> { let start = Instant::now(); @@ -216,6 +264,28 @@ fn ago(d: Duration) -> String { mod tests { use super::*; + /// A question that finds the builder's session missing forgets it, unless a build has saved + /// another meanwhile: then that one stays. + #[test] + fn a_lost_session_is_forgotten_but_never_one_a_build_saved_since() { + let d = tempfile::tempdir().unwrap(); + let mut ws = Workspace::create(d.path(), "Notes", "A notes app", TargetRef::None).unwrap(); + ws.manifest.agent_session = Some("gone".into()); + ws.manifest.last_reply = "Built it.".into(); + ws.save().unwrap(); + assert!(forget_lost_session(&ws.root, "gone").unwrap()); + let now = Workspace::open(&ws.root).unwrap(); + assert_eq!(now.manifest.agent_session, None); + assert_eq!(now.manifest.last_reply, "Built it.", "nothing else changes"); + + ws.manifest.agent_session = Some("new-from-a-build".into()); + ws.save().unwrap(); + assert!(!forget_lost_session(&ws.root, "gone").unwrap()); + assert_eq!(Workspace::open(&ws.root).unwrap().manifest.agent_session.as_deref(), Some("new-from-a-build")); + + assert!(forget_lost_session(&d.path().join("no-such-project"), "gone").is_err(), "an error, never a panic"); + } + #[test] fn the_newest_files_come_first_and_build_output_is_left_out() { let d = tempfile::tempdir().unwrap(); diff --git a/crates/ai/src/rebuild/tests.rs b/crates/ai/src/rebuild/tests.rs index 2fc6e4ff..4f56a463 100644 --- a/crates/ai/src/rebuild/tests.rs +++ b/crates/ai/src/rebuild/tests.rs @@ -573,6 +573,7 @@ fn a_picked_game_is_rebuilt_with_its_own_engine_when_we_can() { assert_eq!(choose("Recreate this game", &TargetRef::Local { path: empty.path().to_path_buf() }).1, "Unity", "no engine found: Unity"); } +/// A scripted API model builds with the Build tools in one turn, and leaves no session to resume. #[test] fn an_api_backend_builds_with_the_build_tools() { use crate::agent::tests::{Scripted, turn}; @@ -611,7 +612,7 @@ fn an_api_backend_builds_with_the_build_tools() { let slot = ModelSlot { name: "Scripted".into(), model: Box::new(script), local: false, effort: None, max_turns: 10 }; let enabled = std::collections::BTreeSet::from([craft_anything_engine::Toolset::Core, craft_anything_engine::Toolset::Build]); let mut api = ApiConversation::start_with(Arc::new(EngineHost(engine)), vec![slot], LoopConfig::default(), enabled).unwrap(); - let reply = w.turn(&mut ws, &mut api, "Build it.").unwrap(); + let reply = w.turn(&mut ws, &mut api, "Build it.", None).unwrap(); assert_eq!(reply.trim(), "Built the notes app."); assert_eq!(std::fs::read_to_string(ws.app_dir().join("README.md")).unwrap(), "# Notes\n"); assert!(ws.manifest.agent_session.is_none(), "an API loop has no session to resume"); @@ -1432,6 +1433,7 @@ fn preparing_the_builders_agent_does_not_lock_up() { assert!(logs.is_some()); } +/// The builder's own `rebuild_check`, asked mid-turn, is answered by the run and isn't a round. #[test] fn the_run_answers_the_builders_own_check_during_its_turn() { let d = tempfile::tempdir().unwrap(); @@ -1454,7 +1456,7 @@ fn the_run_answers_the_builders_own_check_during_its_turn() { self_check: Some((Arc::new(SheetKind), 90.0)), }; let mut builder = ChecksItsWork { root: ws.root.clone(), asked: None, reply: None }; - let reply = w.turn(&mut ws, &mut builder, "Build it.").unwrap(); + let reply = w.turn(&mut ws, &mut builder, "Build it.", None).unwrap(); // The same check the run makes after a turn, as round 0, with what to do next. assert!(reply.starts_with("mechanics: failed") && reply.contains("no app/mechanics.json") && reply.contains("check again with rebuild_check"), "{reply}"); assert!(ws.manifest.checks.is_empty(), "the builder's own check isn't a round"); @@ -1744,3 +1746,204 @@ fn a_project_is_built_by_the_ai_it_was_started_with_while_that_one_is_on() { assert_eq!(super::project_builder(&cfg, &ws), None); assert_eq!(builder_backend(&cfg, super::project_builder(&cfg, &ws)).unwrap().config.name, "Claude Code"); } + +/// #446: a project copied from another computer keeps the id of a Claude Code session that lives +/// only there. The builder (a stand-in that answers like Claude Code 2.1) can't resume it, so the +/// turn starts a new session from the brief and what's in app/, and keeps the new id. +#[cfg(unix)] +#[test] +fn a_session_left_on_another_computer_starts_a_new_one_from_the_project() { + use std::os::unix::fs::PermissionsExt; + let d = tempfile::tempdir().unwrap(); + let mut ws = Workspace::create(d.path(), "Notes", "A notes app", TargetRef::None).unwrap(); + ws.manifest.agent_session = Some("39b5b312-5678-1234-961c-32112666ffab".into()); + ws.manifest.last_reply = "Built a notes app with a sidebar.".into(); + ws.save().unwrap(); + let bin = tempfile::tempdir().unwrap(); + let program = bin.path().join("claude"); + let missing = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"num_turns":0,"session_id":"39b5b312-5678-1234-961c-32112666ffab","errors":["No conversation found with session ID: 39b5b312-5678-1234-961c-32112666ffab"]}"#; + let fresh = r#"{"type":"system","subtype":"init","session_id":"new-1"} +{"type":"assistant","message":{"content":[{"type":"text","text":"Carried on: %s"}]}} +{"type":"result","subtype":"success","is_error":false,"num_turns":1,"session_id":"new-1"} +"#; + // Like Claude Code: `--resume` of a session it doesn't have fails at once; otherwise it starts + // one. It keeps what it was sent, so the test can read the new session's first message. + let script = format!( + "#!/bin/sh\ncase \" $* \" in *\" --resume \"*) cat >/dev/null; echo '{missing}'; echo 'No conversation found' >&2; exit 1;; esac\ncat > \"$0.last\"\nprintf '{fresh}' yes\n" + ); + std::fs::write(&program, script).unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + let config = + crate::BackendConfig { name: "Claude".into(), kind: BackendKind::AgentCli, model: "claude-code".into(), base_url: None, ssh_host: None, enabled: true }; + let options = crate::config::BackendOptions { command: Some(program.display().to_string()), ..Default::default() }; + let spec = crate::cli_agents::CliSpec { resume: ws.manifest.agent_session.clone(), ..crate::cli_agents::CliSpec::new(config, options, false, None) }; + let mut agent = crate::cli_agents::CliConversation::new(spec); + let (tx, rx) = std::sync::mpsc::channel(); + let mut w = Worker { + wake: Default::default(), + engine: Arc::new(Engine::new(craft_anything_engine::testing::session())), + tx, + stop: CancelToken::default(), + preview: Arc::default(), + seen: HashSet::new(), + inbox: Inbox::new(ws.inbox_dir()), + editor: None, + notes: Arc::default(), + feed: Arc::new(Mutex::new(feed::FeedLog::open(&ws))), + assets: false, + decompiled: false, + usage: None, + self_check: None, + }; + let first = resumed_elsewhere("THE BRIEF", &ws.manifest.last_reply, None); + let reply = w.turn(&mut ws, &mut agent, "The person asks: make it blue", Some(first)).unwrap(); + assert_eq!(reply.trim(), "Carried on: yes"); + let sent = std::fs::read_to_string(bin.path().join("claude.last")).unwrap(); + assert!( + sent.starts_with("THE BRIEF") && sent.contains("already under way") && sent.contains("app/") && sent.contains("notes app with a sidebar"), + "{sent}" + ); + assert_eq!(ws.manifest.agent_session.as_deref(), Some("new-1"), "the new session is the one kept"); + assert_eq!(Workspace::open(&ws.root).unwrap().manifest.agent_session.as_deref(), Some("new-1")); + let events: Vec = rx.try_iter().collect(); + assert!(!events.iter().any(|e| matches!(e, RunEvent::Agent(AgentEvent::Error { .. }))), "nothing failed, as far as the person can see: {events:?}"); + assert!( + events.iter().any(|e| matches!(e, RunEvent::Step { text, .. } if text.contains("isn't on this computer"))), + "but they're told why it starts afresh" + ); +} + +/// Without anything to start a new session from, the run stops and says why, and the id it +/// couldn't find is forgotten so the next run starts afresh. +#[cfg(unix)] +#[test] +fn a_missing_session_with_nothing_to_start_from_is_said_plainly() { + use std::os::unix::fs::PermissionsExt; + let d = tempfile::tempdir().unwrap(); + let mut ws = Workspace::create(d.path(), "Notes", "A notes app", TargetRef::None).unwrap(); + ws.manifest.agent_session = Some("gone".into()); + let bin = tempfile::tempdir().unwrap(); + let program = bin.path().join("claude"); + let missing = r#"{"type":"result","subtype":"error_during_execution","is_error":true,"num_turns":0,"session_id":"gone","errors":["No conversation found with session ID: gone"]}"#; + std::fs::write(&program, format!("#!/bin/sh\ncat >/dev/null\necho '{missing}'\nexit 1\n")).unwrap(); + std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755)).unwrap(); + let config = + crate::BackendConfig { name: "Claude".into(), kind: BackendKind::AgentCli, model: "claude-code".into(), base_url: None, ssh_host: None, enabled: true }; + let options = crate::config::BackendOptions { command: Some(program.display().to_string()), ..Default::default() }; + let spec = crate::cli_agents::CliSpec { resume: Some("gone".into()), ..crate::cli_agents::CliSpec::new(config, options, false, None) }; + let mut agent = crate::cli_agents::CliConversation::new(spec); + let (tx, _rx) = std::sync::mpsc::channel(); + let mut w = Worker { + wake: Default::default(), + engine: Arc::new(Engine::new(craft_anything_engine::testing::session())), + tx, + stop: CancelToken::default(), + preview: Arc::default(), + seen: HashSet::new(), + inbox: Inbox::new(ws.inbox_dir()), + editor: None, + notes: Arc::default(), + feed: Arc::new(Mutex::new(feed::FeedLog::open(&ws))), + assets: false, + decompiled: false, + usage: None, + self_check: None, + }; + let e = w.turn(&mut ws, &mut agent, "Carry on.", None).unwrap_err(); + assert!(e.message.contains("isn't on this computer"), "{e:?}"); + assert!(ws.manifest.agent_session.is_none()); +} + +/// #451: after each turn the project keeps a copy of the builder's session, and a new session on +/// another computer is handed the conversation it kept. +#[test] +fn the_builders_conversation_travels_with_the_project() { + struct KeepsSessions(PathBuf); + impl crate::cli_agents::AgentAdapter for KeepsSessions { + fn ids(&self) -> &'static [&'static str] { + &["keeps"] + } + fn args(&self, _cx: &crate::cli_agents::ArgsCx) -> Vec { + Vec::new() + } + /// Sessions sit flat in the stand-in store, named by id. + fn session_file(&self, id: &str) -> Option { + Some(self.0.join(format!("{id}.jsonl"))).filter(|p| p.is_file()) + } + /// Read back as Claude Code's are. + fn handoff(&self, saved: &str, max: usize) -> Option { + crate::cli_agents::adapters::claude_handoff(saved, max) + } + /// Put back into the stand-in store, never over one that's there. + fn restore_session(&self, id: &str, saved: &[u8], _cwd: &Path) -> Result { + let path = self.0.join(format!("{id}.jsonl")); + if path.is_file() { + return Ok(false); + } + std::fs::write(path, saved).map(|_| true).map_err(|e| ToolError::invalid(e.to_string())) + } + } + let d = tempfile::tempdir().unwrap(); + let mut ws = Workspace::create(d.path(), "Notes", "A notes app", TargetRef::None).unwrap(); + let store = tempfile::tempdir().unwrap(); + let session = [ + serde_json::json!({"type":"user","message":{"content":"Make a notes app."}}), + serde_json::json!({"type":"assistant","message":{"content":[{"type":"text","text":"Built it with a sidebar."}]}}), + ] + .iter() + .map(|v| v.to_string()) + .collect::>() + .join("\n"); + std::fs::write(store.path().join("s-1.jsonl"), &session).unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + let w = Worker { + wake: Default::default(), + engine: Arc::new(Engine::new(craft_anything_engine::testing::session())), + tx, + stop: CancelToken::default(), + preview: Arc::default(), + seen: HashSet::new(), + inbox: Inbox::new(ws.inbox_dir()), + editor: None, + notes: Arc::default(), + feed: Arc::new(Mutex::new(feed::FeedLog::open(&ws))), + assets: false, + decompiled: false, + usage: None, + self_check: None, + }; + let adapter = KeepsSessions(store.path().to_path_buf()); + // No session yet, or one this computer doesn't have: nothing to keep, nothing said. + w.keep_session(&ws, &adapter); + ws.manifest.agent_session = Some("gone".into()); + w.keep_session(&ws, &adapter); + assert!(!ws.agent_session_path().exists() && rx.try_iter().next().is_none()); + ws.manifest.agent_session = Some("s-1".into()); + w.keep_session(&ws, &adapter); + assert_eq!(std::fs::read_to_string(ws.agent_session_path()).unwrap(), session); + + // On the other computer: the session is put back from the project, and the person is told. + std::fs::remove_file(store.path().join("s-1.jsonl")).unwrap(); + w.restore_session(&ws, &adapter); + assert_eq!(std::fs::read_to_string(store.path().join("s-1.jsonl")).unwrap(), session); + let said: Vec = rx.try_iter().filter_map(|e| if let RunEvent::Agent(AgentEvent::Status { text }) = e { Some(text) } else { None }).collect(); + assert!(said.iter().any(|t| t.contains("Brought the builder's earlier conversation over")), "{said:?}"); + w.restore_session(&ws, &adapter); + assert!(rx.try_iter().next().is_none(), "nothing to say when it's already there"); + + // Where it can't be, the new session's first message carries the kept conversation. + let handoff = crate::cli_agents::AgentAdapter::handoff(&adapter, &kept_session(&ws).unwrap(), HANDOFF_CHARS); + let first = resumed_elsewhere("THE BRIEF", "Last summary.", handoff.as_deref()); + assert!(first.starts_with("THE BRIEF") && first.contains("isn't on this computer"), "{first}"); + assert!(first.contains("Craft Anything asked you: Make a notes app.") && first.contains("You answered: Built it with a sidebar."), "{first}"); + // Without a kept conversation, the last summary stands in for it. + let bare = resumed_elsewhere("THE BRIEF", "Last summary.", None); + assert!(bare.contains("Last summary.") && !bare.contains("as the project kept it"), "{bare}"); + + // A copy that can't be written is said, and the build goes on. + std::fs::remove_file(ws.agent_session_path()).unwrap(); + std::fs::create_dir_all(ws.agent_session_path()).unwrap(); + w.keep_session(&ws, &adapter); + let said: Vec = rx.try_iter().filter_map(|e| if let RunEvent::Agent(AgentEvent::Status { text }) = e { Some(text) } else { None }).collect(); + assert!(said.iter().any(|t| t.contains("Couldn't keep a copy")), "{said:?}"); +} diff --git a/crates/engine/src/workspace.rs b/crates/engine/src/workspace.rs index 96ea5d69..0c56cc74 100644 --- a/crates/engine/src/workspace.rs +++ b/crates/engine/src/workspace.rs @@ -860,17 +860,23 @@ impl Workspace { pub fn inbox_dir(&self) -> PathBuf { self.root.join("inbox") } - /// The API builder's conversation, kept so a follow-up continues it (`ai::context`). /// A builder has worked on this project and its conversation was kept, so a later run carries /// it on instead of starting over. pub fn builder_started(&self) -> bool { self.manifest.agent_session.is_some() || self.transcript_path().is_file() } + /// The API builder's conversation, kept so a follow-up continues it (`ai::context`). pub fn transcript_path(&self) -> PathBuf { self.root.join("transcript.json") } + /// A copy of the coding agent's own session (Claude Code's), kept after every turn so the + /// conversation travels with the project folder to another computer. + pub fn agent_session_path(&self) -> PathBuf { + self.root.join(AGENT_SESSION_FILE) + } + /// Every workspace under `projects_dir`, most recently updated first. Folders without a /// readable manifest are skipped. pub fn list(projects_dir: &Path) -> Vec { @@ -897,6 +903,9 @@ impl Workspace { } } +/// The coding agent's session, copied into the project ([`Workspace::agent_session_path`]). +pub const AGENT_SESSION_FILE: &str = "agent-session.jsonl"; + /// The names Craft Anything keeps in a project folder beside app/. A folder the person picks /// mustn't already have any of them. pub const OWN_NAMES: &[&str] = &[ @@ -909,6 +918,7 @@ pub const OWN_NAMES: &[&str] = &[ ASSETS, "transcript.json", "extract-transcript.json", + AGENT_SESSION_FILE, "inbox", "checks", "capture", @@ -1377,6 +1387,8 @@ mod tests { } } + /// A rename survives a stale save, a copy takes the build but not the conversation (nor its + /// kept session), and a delete leaves the person's own files. #[test] fn a_project_is_renamed_duplicated_and_deleted_without_touching_the_persons_files() { let d = tempfile::tempdir().unwrap(); @@ -1389,6 +1401,7 @@ mod tests { std::fs::write(ws.reference_dir().join("picture-1.png"), "x").unwrap(); ws.manifest.agent_session = Some("old".into()); ws.save().unwrap(); + std::fs::write(ws.agent_session_path(), "{}\n").unwrap(); ws.rename(" Snake II ").unwrap(); assert_eq!(Workspace::open(&ws.root).unwrap().manifest.name, "Snake II"); @@ -1405,6 +1418,7 @@ mod tests { assert!(copy.reference_dir().join("picture-1.png").is_file()); assert!(!copy.app_dir().join("target").exists(), "compiler output is made again, not copied"); assert!(copy.manifest.agent_session.is_none(), "a copy has its own conversation"); + assert!(!copy.agent_session_path().exists(), "and doesn't carry the original's"); ws.delete().unwrap(); assert!(!d.path().join("snake").exists(), "a project of its own goes with its folder"); @@ -1415,11 +1429,12 @@ mod tests { std::fs::write(mine.join("index.html"), "

hi

").unwrap(); let ws = Workspace::create_at(&mine, "Site", "x", TargetRef::None, Some(ExistingFiles::Edit)).unwrap(); std::fs::create_dir_all(ws.inbox_dir()).unwrap(); + std::fs::write(ws.agent_session_path(), "{}\n").unwrap(); let copy = ws.duplicate("Site copy").unwrap(); assert!(copy.app_dir().join("index.html").is_file() && !copy.manifest.app_in_root, "the copy's app is in its app/"); assert!(!copy.app_dir().join(MANIFEST).exists(), "without the original's records"); ws.delete().unwrap(); assert_eq!(std::fs::read_to_string(mine.join("index.html")).unwrap(), "

hi

"); - assert!(!mine.join(MANIFEST).exists() && !mine.join("inbox").exists()); + assert!(!mine.join(MANIFEST).exists() && !mine.join("inbox").exists() && !mine.join(AGENT_SESSION_FILE).exists()); } } diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index b26fea13..1b7156fc 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -528,6 +528,18 @@ Owner: maintainers · Status: in progress (built; live runs need keys) context, no cost); a remote one gets the cloud budget and its price (#374) - [ ] A per-backend context budget in `[[ai.backends]]` (`LoopConfig::context_tokens` exists), for services whose real window differs from 150k (#374) +- [x] A project moved to another computer (or folder) keeps building with Claude Code: when the saved + `agent_session` isn't there (`Conversation::session_lost`), a fresh session starts from the brief, + the builder's last summary and what's in `app/` instead of failing; questions about the build put + the kept session back first, else are answered from the files and forget the missing one (#452) +- [x] The coding agent's conversation travels with the project folder (#451): after every turn the + project keeps a copy of Claude Code's session (`agent-session.jsonl`, `AgentAdapter::session_file`); + on a computer without it, the copy is put back where Claude Code finds it (`restore_session`) so + the same session carries on, and if that fails the new session is handed the kept requests and + replies (`handoff`, up to 16k characters) +- [ ] A live run of a project copied between two real computers with Claude Code (#451, #452); done on + one Mac with a second `CLAUDE_CONFIG_DIR` standing in for the other computer (STATUS.md) +- [ ] Session copies for other agents that keep sessions (ACP agents' `session/load`) (#451) ### R2.2 More coding agents Owner: open · Status: in progress diff --git a/docs/STATUS.md b/docs/STATUS.md index 95ae98c7..693d06bd 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -22,7 +22,7 @@ workspace clippy, layers, fmt and the secret scan of all history were clean. Chi | Stand-in backends | `web_standin_build` turns a HAR into `standin.json` (secrets dropped, varying path parts templated); `web_standin_start` answers from it on loopback; `web_standin_show` lists what it had no answer for. Writes replay what was recorded and don't change later reads. App maps list GraphQL and tRPC operations; `web_walkthrough_check` checks a scripted session without a browser | Unit tests, real loopback requests, and a run with curl on a hand-written HAR. No browser-made recording or real front end yet | | Websites as targets | `web_site_open` keeps a private browser for the session and every browser tool inspects that page; `web_site_capture` records screenshots at each width (desktop, tablet and phone), rendered HTML, design tokens (colours in hex, spacing, content widths), the CSS breakpoints, the layout at each width (page width, heading sizes, each section's padding, gap, content column and items per row), page health (script and console errors, failed requests) and an accessibility outline, layout outline, assets with font files and requests, links and form outlines. In Clean room the capture keeps only what a visitor sees (no HTML, CSS rules, scripts, requests or errors), and opening a site reports no load errors | Live run on example.com; unit tests | | Electron window comparisons | `app_graphs_compare` retains every unassigned window and every construction with a reused local binding. Windows are paired by module, binding and occurrence, so inserting comments does not hide preference changes | Synthetic application graphs exercise changes in either anonymous window, unique and reused bindings, shifted source lines and unequal counts. Pairing remains positional within each binding group; reordered constructions and real application releases were not checked for this correction | -| Rebuild workflow (Study → Build → Check → Refine) | `crates/ai/src/rebuild/` (kinds, checks and stacks are pluggable: docs/extending.md). Three kinds: website, app, game (a project whose stack is a game engine is always a game). The request decides how to build: a language, framework, engine or platform it names wins over the kind's default stack (Rust + egui for apps, Unity for games, the original's for websites), and the build check, the recorded stack and Play follow what was built (an Electron app opens in its own Electron). Websites: Study also captures up to 4 other pages of the site; menus are opened on a computer and on a phone; the check serves the build on loopback and compares every page at 1440, 768 and 390 px (likeness = mean of layout and perceptual detail, SSIM, averaged over pages, leaving out the parts of the original that change by themselves, found by photographing it three times; the note adds text, page structure, the colours, font, text sizes and spacing the build misses, and per width a page that scrolls sideways, heading sizes and sections whose padding, items per row or content column differ, the errors, failed requests and accessibility gaps the build adds, and the opened menus and pop-ups). A stack or library named in the request (Next.js, shadcn/ui, Tailwind CSS…, `stacks.toml`) is what the builder uses; with none named, a website's stack is the builder's choice (told to match what the original is built with) and the stack it used is recorded from app/package.json. The builder leads: it studies, asks the tools follow-up questions, records what it can't find out as unknowns (`core_unknown_add`; the run reports the open ones), and tests its own build mid-turn with `rebuild_check` (the same checks, for every kind); a check with anything it couldn't compare, or any claim that fails (a website page that scrolls sideways, logs new errors, fails to load a file, lacks a landmark, or a menu that doesn't open; for games each self-test step, for Deep-mode sheets each original number in the code, for console apps the same exit and output as the original's runs), isn't good enough; apps and games by whether they build. Up to 6 refinement rounds, or until a website reaches 90% likeness. A turn cut off by a time limit, crash or dropped connection resumes (up to 3 times); builder turns may run 24 h | Unit tests for prompts, stack choice, site detection, feed folding; live runs reached Build and decompiling (below) | +| Rebuild workflow (Study → Build → Check → Refine) | `crates/ai/src/rebuild/` (kinds, checks and stacks are pluggable: docs/extending.md). Three kinds: website, app, game (a project whose stack is a game engine is always a game). The request decides how to build: a language, framework, engine or platform it names wins over the kind's default stack (Rust + egui for apps, Unity for games, the original's for websites), and the build check, the recorded stack and Play follow what was built (an Electron app opens in its own Electron). Websites: Study also captures up to 4 other pages of the site; menus are opened on a computer and on a phone; the check serves the build on loopback and compares every page at 1440, 768 and 390 px (likeness = mean of layout and perceptual detail, SSIM, averaged over pages, leaving out the parts of the original that change by themselves, found by photographing it three times; the note adds text, page structure, the colours, font, text sizes and spacing the build misses, and per width a page that scrolls sideways, heading sizes and sections whose padding, items per row or content column differ, the errors, failed requests and accessibility gaps the build adds, and the opened menus and pop-ups). A stack or library named in the request (Next.js, shadcn/ui, Tailwind CSS…, `stacks.toml`) is what the builder uses; with none named, a website's stack is the builder's choice (told to match what the original is built with) and the stack it used is recorded from app/package.json. The builder leads: it studies, asks the tools follow-up questions, records what it can't find out as unknowns (`core_unknown_add`; the run reports the open ones), and tests its own build mid-turn with `rebuild_check` (the same checks, for every kind); a check with anything it couldn't compare, or any claim that fails (a website page that scrolls sideways, logs new errors, fails to load a file, lacks a landmark, or a menu that doesn't open; for games each self-test step, for Deep-mode sheets each original number in the code, for console apps the same exit and output as the original's runs), isn't good enough; apps and games by whether they build. Up to 6 refinement rounds, or until a website reaches 90% likeness. A turn cut off by a time limit, crash or dropped connection resumes (up to 3 times); builder turns may run 24 h. A project copied from another computer (or moved to another folder) keeps building with Claude Code: the project keeps a copy of the builder's session (`agent-session.jsonl`) after every turn and puts it back where Claude Code finds it on a computer without it, so the same conversation carries on (#451); when the session still isn't there, a new one starts from the brief, what's in app/ and the kept requests and replies (or the last summary), and questions about the build put the kept session back too, else are answered from the files and forget the missing session (#452). Live runs with Claude Code 2.1.316 on one Mac (2026-11-02), a second computer stood in for by a separate signed-in `CLAUDE_CONFIG_DIR`: a copied project's session was put back and resumed (the builder recalled its first reply from memory); a kept copy of another session was refused and the new session used the handoff; a real project synced from Windows whose session was missing started afresh and reached Ready. Not yet run between two real computers | Unit tests for prompts, stack choice, site detection, feed folding; live runs reached Build and decompiling (below) | | Context-summary usage | Successful API context summaries contribute to the message totals consumed by build usage and receive a `model_turn` journal entry with `purpose: context_summary`. Cached-token pricing is retained; an unknown summary price makes the total unknown, and a failed call adds no invented usage | Scripted-model tests exercise the real `ApiConversation`, transcript resumption, exact input/output and cache-price totals, journal entries, empty summaries, local zero cost, unknown prices and failed summaries. No live provider or end-to-end rebuild was used for this correction | | Games | Engine detection for 12 engines (Unreal, Unity, Godot, Source, GameMaker, RPG Maker, Ren'Py, MonoGame, LÖVE, CryEngine, Cocos, Cube) with versions. Unreal and Godot games are rebuilt in the same engine, others in Unity; installed engines are found, Godot installs itself, others get guided installs. Picking an inner folder (Engine, Binaries, Paks…) finds the game's folder. Just look inside a game shows what it's made of (`core_target_triage` on its folder: engine and version, how its code ships, the libraries its Unity assembly names give away, its own code, scenes and size) and opens it in the chat to ask about it. The builder keeps `app/MECHANICS.md` and its structured twin `app/mechanics.json` (checked after each build), in Deep mode only, may reuse the original's readable files for private use (never decrypting; generated pictures and 3D models replace its art when the person turns them on; Clean room copies none of its files), lists them in `CREDITS.md` (checked, with the private-use line in `README.md`); a game is ready only when its `--self-test` shows the player moving, acting and the HUD drawn, and the result can be started from the project page | Unit tests; live runs on Windows | | Clean room and Deep mode | Clean room (the default) runs only the tools in `engine/src/clean_room.rs`: ones that touch no target or watch from outside (the website as a visitor sees it, pictures of a window, its controls, recorded runs, a phone's screen). Every other tool, a plugin's included, is refused in the app, CLI, MCP and agents, with a way to turn on Deep mode. A Clean room build studies an app or a game only from the person's pictures and words (and a picture of its window when runtime study is on); its builder's reads and commands go through an approval rule that keeps it out of the original's folder and site (with Claude Code, and with ACP agents for what they ask about; other agents aren't held to it, see roadmap R2.2). Deep mode (`deep_mode` in Settings, `--deep` for one CLI run) uses every tool: the builder sees them all, and a website's study also reads its JavaScript and watches its start page for ten seconds (capture/how-it-works.md). Ghidra adds its decompiler in Deep mode when it's installed: it is found, or installed after asking. Study starts one analysis of the main program, saved and shared with the builder's tool server through a lock file (up to 8 h, Java gets half the machine's memory or the amount set in Settings → Engines, 4 to 31 GB, four slow passes skipped). The builder must decompile before the build is checked; addresses the built-in analysis can't place go to Ghidra, which makes a function on demand (for this run only) where its analysis has none, e.g. code reached only through tables of pointers. Without Ghidra, Deep mode uses the built-in engines | Live runs with Ghidra 12.1.4 on Windows (a shared analysis, and a decompile in seconds on a large program). Live runs with Ghidra 12.2 on Linux x86-64 (decompiles by name in position-independent programs and shared libraries, whose addresses Ghidra moves). The guided install of Ghidra and its Java is offered on Linux and macOS too, but has only been tried on Windows. Clean room and Deep mode: engine, AI, MCP, CLI and app tests; no live Clean room build run yet | diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 0ee36fea..42bb1dd1 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -395,6 +395,14 @@ PIE program and shared library. Whenever an address crosses between engines, mov image bases (`ghidra::Rebase`, from the base the bridge reports) and say in the evidence which space each address is in. +**An id that only means something on one computer, kept in a folder that travels.** The project +saved Claude Code's session id, but the session itself lives in `~/.claude/projects/` on the +computer (and under the folder) that started it; a project synced to another computer stopped at +"No conversation found" (#452), and the retry for a cut-off turn asked for the same missing session +three more times. Anything a project keeps must still work where the folder goes: keep what the id +points to as well (`agent-session.jsonl`), tell "not here" apart from a failure by a typed signal +(`Conversation::session_lost`), and never retry what can't succeed. + ## Agent tooling **Shell quoting breaks generated edits.** Heredocs carrying Python with quotes, `'''` or `\n` were