diff --git a/crates/botloftd/src/chat/events.rs b/crates/botloftd/src/chat/events.rs index 72708710..c561b296 100644 --- a/crates/botloftd/src/chat/events.rs +++ b/crates/botloftd/src/chat/events.rs @@ -38,7 +38,7 @@ pub(super) fn apply(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value let from_subagent = !event["parent_tool_use_id"].is_null(); match kind { "system" if subtype == Some("init") => { - session(daemon, bot, event); + init(daemon, bot, event); daemon.supervisor.turn_began(bot, generation); } "system" if subtype == Some("background_tasks_changed") => { @@ -72,20 +72,8 @@ fn agent_count(event: &Value) -> u32 { u32::try_from(agents).unwrap_or(u32::MAX) } -/// Every turn starts with the session id; the next start resumes it. -fn session(daemon: &Daemon, bot: &BotId, event: &Value) { - let Some(session) = event["session_id"].as_str() else { - return; - }; - let store = daemon.store(); - let known = store.session_id(bot).ok().flatten(); - if known.as_deref() != Some(session) - && let Err(err) = store.set_session_id(bot, Some(session)) - { - warn!(bot = %bot, "could not save the session id: {err}"); - } - drop(store); - daemon.supervisor.remember_session(bot, session); +/// Every turn starts with the mode and the model in use (spec 7.4). +fn init(daemon: &Daemon, bot: &BotId, event: &Value) { if let Some(mode) = event["permissionMode"].as_str() { service::modes::reported(daemon, bot, mode); } @@ -211,6 +199,7 @@ fn failed_turn(daemon: &Daemon, bot: &BotId, generation: u64, error: &str, event /// The bot began the turn for a message the courier wrote (spec 9.1). fn replay(daemon: &Daemon, bot: &BotId, event: &Value) { + super::session::began(daemon, bot, event); let Some(uuid) = event["uuid"].as_str() else { return; }; @@ -303,6 +292,12 @@ fn rate_limit(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { } fn result(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { + // No turn began, so none ended: nothing of this is for the chat. + if super::session::missing(event) { + debug!(bot = %bot, "Claude Code does not have the conversation to resume"); + daemon.supervisor.session_missing(bot, generation); + return; + } let failed = event["is_error"].as_bool() == Some(true); let error = failed.then(|| { event["terminal_reason"] diff --git a/crates/botloftd/src/chat/mod.rs b/crates/botloftd/src/chat/mod.rs index bca27b17..dfe88bae 100644 --- a/crates/botloftd/src/chat/mod.rs +++ b/crates/botloftd/src/chat/mod.rs @@ -4,6 +4,7 @@ mod events; pub(crate) mod items; +mod session; use botloft_core::ids::BotId; use tracing::debug; diff --git a/crates/botloftd/src/chat/session.rs b/crates/botloftd/src/chat/session.rs new file mode 100644 index 00000000..bf8394fc --- /dev/null +++ b/crates/botloftd/src/chat/session.rs @@ -0,0 +1,69 @@ +//! What the stream says about the conversation on Claude Code's side (spec +//! 7.3): when it is on disk and can be resumed, and when the one a process +//! was told to resume is not there. + +use botloft_core::ids::BotId; +use serde_json::Value; +use tracing::warn; + +use crate::state::Daemon; + +/// How the error of a missing conversation begins (seen with 2.1.284, +/// spec 19); the session id follows. +const NO_CONVERSATION: &str = "No conversation found"; + +/// A turn's message came back, with the session id like every event. Claude +/// Code has the conversation on disk from here on, not from `system/init`, +/// so this is what the next start resumes. +pub(super) fn began(daemon: &Daemon, bot: &BotId, event: &Value) { + let Some(session) = event["session_id"].as_str() else { + return; + }; + // Only the bot's own turns: a subagent works inside one of them. + if !event["parent_tool_use_id"].is_null() { + return; + } + let store = daemon.store(); + let known = store.session_id(bot).ok().flatten(); + if known.as_deref() != Some(session) + && let Err(err) = store.set_session_id(bot, Some(session)) + { + warn!(bot = %bot, "could not save the session id: {err}"); + } + drop(store); + daemon.supervisor.remember_session(bot, session); +} + +/// Whether this `result` is Claude Code saying it could not find the +/// conversation to resume. It comes with no turn before it, and the process +/// exits right after. +pub(super) fn missing(event: &Value) -> bool { + event["subtype"] == "error_during_execution" + && event["errors"].as_array().is_some_and(|errors| { + errors + .iter() + .filter_map(Value::as_str) + .any(|error| error.starts_with(NO_CONVERSATION)) + }) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + fn failed(subtype: &str, error: &str) -> Value { + json!({ "type": "result", "subtype": subtype, "is_error": true, "errors": [error] }) + } + + #[test] + fn only_a_conversation_that_was_not_found_counts_as_missing() { + let not_found = "No conversation found with session ID: 7de9b8c8"; + assert!(missing(&failed("error_during_execution", not_found))); + assert!(!missing(&failed("error_during_execution", "other"))); + assert!(!missing(&failed("error_max_turns", not_found))); + let done = json!({ "type": "result", "subtype": "success", "is_error": false }); + assert!(!missing(&done)); + } +} diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs index 0a4f83c0..d28de0fd 100644 --- a/crates/botloftd/src/supervisor/mod.rs +++ b/crates/botloftd/src/supervisor/mod.rs @@ -7,6 +7,7 @@ mod reconcile; mod relaunch; +mod session; mod settings; mod sign_in; mod slot; @@ -61,11 +62,13 @@ struct Inner { slots: HashMap, /// Token hash of each running generation -> its bot. tokens: HashMap, - /// The conversation each bot resumes, as far as this run knows; the + /// The conversation each bot resumes: one Claude Code has on disk. The /// database has it too (spec 7.3). sessions: HashMap, claude: ClaudeStatus, sign_in: sign_in::SignIn, + /// Conversations a new one took the place of, still in the database. + replaced: Vec<(BotId, String)>, } #[derive(Default)] diff --git a/crates/botloftd/src/supervisor/reconcile.rs b/crates/botloftd/src/supervisor/reconcile.rs index 947a459d..0fff2784 100644 --- a/crates/botloftd/src/supervisor/reconcile.rs +++ b/crates/botloftd/src/supervisor/reconcile.rs @@ -60,6 +60,8 @@ impl Supervisor { self.reconcile_bot(&mut inner, &daemon, crew, bot, now); } } + drop(inner); + self.forget_replaced(&daemon); } fn reconcile_bot( diff --git a/crates/botloftd/src/supervisor/session.rs b/crates/botloftd/src/supervisor/session.rs new file mode 100644 index 00000000..d846c49f --- /dev/null +++ b/crates/botloftd/src/supervisor/session.rs @@ -0,0 +1,45 @@ +//! Which conversation a bot's next process resumes (spec 7.3). Claude Code +//! has nothing on disk for a conversation before a turn began in it, so +//! only one that had a turn is remembered; one that Claude Code lost, or +//! that a new one took the place of, is forgotten. + +use botloft_core::ids::BotId; +use tracing::warn; + +use super::Supervisor; +use crate::state::Daemon; + +impl Supervisor { + /// A turn began in `session`: Claude Code has it on disk from here on, + /// and the next start resumes it. + pub(crate) fn remember_session(&self, bot: &BotId, session: &str) { + self.lock().sessions.insert(bot.clone(), session.to_owned()); + } + + /// Claude Code does not have the conversation this process was told to + /// resume, and is about to exit. The next process starts a new one, + /// however long this one takes to go. + pub(crate) fn session_missing(&self, bot: &BotId, generation: u64) { + self.with_current(bot, generation, |_, slot| slot.fresh_next = true); + } + + /// Takes the conversations that new ones replaced out of the database, + /// so a daemon that restarts does not resume them. Called without the + /// supervisor lock (lock order, see `mod.rs`). + pub(super) fn forget_replaced(&self, daemon: &Daemon) { + let replaced = std::mem::take(&mut self.lock().replaced); + if replaced.is_empty() { + return; + } + let store = daemon.store(); + for (bot, session) in replaced { + // A turn of the new conversation may have been saved already. + let stored = store.session_id(&bot).ok().flatten(); + if stored.as_deref() == Some(session.as_str()) + && let Err(err) = store.set_session_id(&bot, None) + { + warn!(bot = %bot, "could not forget the old session id: {err}"); + } + } + } +} diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs index 0ff7db43..442e5cd4 100644 --- a/crates/botloftd/src/supervisor/spawn.rs +++ b/crates/botloftd/src/supervisor/spawn.rs @@ -40,6 +40,7 @@ impl Supervisor { slots, tokens, sessions, + replaced, claude, .. } = inner; @@ -62,7 +63,13 @@ impl Supervisor { .and_then(|(spec, launch)| Ok((self.runtime.spawn(spec)?, launch))); match launched { Ok((process, launch)) => { - sessions.insert(bot.id.clone(), launch.session.clone()); + // A new conversation is on disk only once it has a turn + // (spec 7.3); the one it takes the place of is left behind. + if !launch.resumed + && let Some(old) = sessions.remove(&bot.id) + { + replaced.push((bot.id.clone(), old)); + } slot.generation = Some(generation); slot.restart_at = None; slot.fresh_next = false; @@ -130,7 +137,7 @@ impl Supervisor { match slot.stop.take() { Some(StopIntent::Restart { fresh }) => { - slot.fresh_next = fresh; + slot.fresh_next |= fresh; slot.restart_at = Some(Instant::now()); } Some(StopIntent::Halt(state)) => { @@ -160,7 +167,6 @@ impl Supervisor { struct Launch { token_hash: String, resumed: bool, - session: String, } /// Command line and environment of spec 7.4, with a fresh bot token. @@ -195,7 +201,7 @@ fn launch_spec( .map(OsString::from) .collect(); args.push(if resumed { "--resume" } else { "--session-id" }.into()); - args.push(session.clone().into()); + args.push(session.into()); for arg in [ "--setting-sources", "project,local", @@ -248,7 +254,6 @@ fn launch_spec( Launch { token_hash: TokenHash::of(&token).to_hex(), resumed, - session, }, )) } diff --git a/crates/botloftd/src/supervisor/turns.rs b/crates/botloftd/src/supervisor/turns.rs index 2143bf13..802b723b 100644 --- a/crates/botloftd/src/supervisor/turns.rs +++ b/crates/botloftd/src/supervisor/turns.rs @@ -11,7 +11,12 @@ use super::{Inner, Supervisor}; impl Supervisor { /// Runs `change` on the slot of `bot` if `generation` is still running. - fn with_current(&self, bot: &BotId, generation: u64, change: impl FnOnce(&Self, &mut Slot)) { + pub(super) fn with_current( + &self, + bot: &BotId, + generation: u64, + change: impl FnOnce(&Self, &mut Slot), + ) { let mut inner = self.lock(); let Inner { slots, .. } = &mut *inner; if let Some(slot) = slots.get_mut(bot) @@ -22,12 +27,6 @@ impl Supervisor { } } - /// The conversation the bot is in, from `system/init`; the next start - /// resumes it (spec 7.3). - pub(crate) fn remember_session(&self, bot: &BotId, session: &str) { - self.lock().sessions.insert(bot.clone(), session.to_owned()); - } - /// The process lived through its first moments: it is ready. pub(crate) fn ready(&self, bot: &BotId, generation: u64) { self.with_current(bot, generation, |supervisor, slot| { diff --git a/crates/botloftd/tests/bot_models.rs b/crates/botloftd/tests/bot_models.rs index 3597f1b6..d1079398 100644 --- a/crates/botloftd/tests/bot_models.rs +++ b/crates/botloftd/tests/bot_models.rs @@ -45,7 +45,7 @@ async fn an_idle_bot_restarts_on_its_new_model_in_the_same_conversation() { let s = setup().await; let first = s.runtime.process(1).await; s.until(BotState::Idle).await; - let session = arg_after(&first, "--session-id").expect("session"); + let session = s.turn(&first).await; set_model(&s, BotModel::Haiku); let second = s.runtime.process(2).await; diff --git a/crates/botloftd/tests/common/stream.rs b/crates/botloftd/tests/common/stream.rs index 4d8bdb45..b23128ee 100644 --- a/crates/botloftd/tests/common/stream.rs +++ b/crates/botloftd/tests/common/stream.rs @@ -18,6 +18,34 @@ pub fn replay(line: &Value) -> Value { }) } +/// The replay that opens a turn in `session`. From here on Claude Code has +/// the conversation on disk and it can be resumed (spec 7.3). +pub fn began(session: &str) -> Value { + json!({ + "type": "user", + "isReplay": true, + "uuid": "u", + "session_id": session, + "message": { "role": "user", "content": "hi" }, + "parent_tool_use_id": null, + }) +} + +/// What Claude Code 2.1.284 prints, before it exits, when the conversation +/// it was told to resume is not on disk (spec 19). +pub fn no_conversation(session: &str) -> Value { + json!({ + "type": "result", + "subtype": "error_during_execution", + "is_error": true, + "duration_ms": 0, + "num_turns": 0, + "session_id": session, + "total_cost_usd": 0, + "errors": [format!("No conversation found with session ID: {session}")], + }) +} + pub fn text(text: &str) -> Value { json!({ "type": "assistant", diff --git a/crates/botloftd/tests/common/supervised.rs b/crates/botloftd/tests/common/supervised.rs index abfc3fcf..f374079a 100644 --- a/crates/botloftd/tests/common/supervised.rs +++ b/crates/botloftd/tests/common/supervised.rs @@ -13,7 +13,7 @@ use botloftd::state::Daemon; use botloftd::supervisor; use serde_json::json; -use super::{new_daemon, test_settings}; +use super::{new_daemon, stream, test_settings}; pub struct Setup { pub daemon: Arc, @@ -86,6 +86,25 @@ impl Setup { .write_message(&self.bot, format!("{line}\n").into()) .expect("running") } + + /// One whole turn in the conversation `process` is in. Returns its + /// session id, which Claude Code now has on disk (spec 7.3). + pub async fn turn(&self, process: &FakeProcess) -> String { + let session = session_of(process); + self.message("hi"); + process.emit(stream::init(&session)).await; + process.emit(stream::began(&session)).await; + process.emit(stream::result(false)).await; + self.until(BotState::Idle).await; + session + } +} + +/// The conversation the process started or resumed. +pub fn session_of(process: &FakeProcess) -> String { + arg_after(process, "--session-id") + .or_else(|| arg_after(process, "--resume")) + .expect("a session") } /// The value after `flag` on the process's command line. diff --git a/crates/botloftd/tests/permission_modes.rs b/crates/botloftd/tests/permission_modes.rs index 62b0f52b..d9071351 100644 --- a/crates/botloftd/tests/permission_modes.rs +++ b/crates/botloftd/tests/permission_modes.rs @@ -9,7 +9,7 @@ use botloft_core::protocol::{BotState, BotsSetPermissionModeParams, PermissionMo use botloftd::runtime::fake::FakeProcess; use botloftd::service::modes; use common::stream; -use common::supervised::{Setup, setup}; +use common::supervised::{Setup, session_of, setup}; use serde_json::json; fn arg_after(process: &FakeProcess, flag: &str) -> Option { @@ -58,7 +58,7 @@ async fn an_idle_bot_restarts_into_its_new_mode_in_the_same_conversation() { let s = setup().await; let first = s.runtime.process(1).await; s.until(BotState::Idle).await; - let session = arg_after(&first, "--session-id").expect("session"); + let session = s.turn(&first).await; set_mode(&s, PermissionMode::AcceptEdits); let second = s.runtime.process(2).await; @@ -98,7 +98,7 @@ async fn approving_a_plan_moves_the_bot_out_of_plan_mode() { set_mode(&s, PermissionMode::Plan); let planning = s.runtime.process(2).await; s.until(BotState::Idle).await; - let session = arg_after(&planning, "--resume").expect("session"); + let session = session_of(&planning); // Claude Code left plan mode when the plan was approved. planning.emit(init_with_mode(&session, "default")).await; @@ -135,7 +135,7 @@ async fn a_plan_approved_while_a_new_model_waits_still_leaves_plan_mode() { set_mode(&s, PermissionMode::Plan); let planning = s.runtime.process(2).await; s.until(BotState::Idle).await; - let session = arg_after(&planning, "--resume").expect("session"); + let session = session_of(&planning); s.message("plan it"); botloftd::service::models::set_model( &s.daemon, diff --git a/crates/botloftd/tests/sessions.rs b/crates/botloftd/tests/sessions.rs new file mode 100644 index 00000000..aa92c1ee --- /dev/null +++ b/crates/botloftd/tests/sessions.rs @@ -0,0 +1,138 @@ +//! Which conversation a bot's next process resumes (spec 7.3), on a +//! FakeRuntime: Claude Code has nothing on disk for a conversation before +//! its first turn began, and may have lost one it had. + +mod common; + +use std::time::Duration; + +use botloft_core::protocol::{ + BotState, BotsRestartParams, BotsSetPermissionModeParams, ChatBody, PermissionMode, +}; +use botloftd::service::{bots, modes}; +use common::stream; +use common::supervised::{Setup, arg_after, session_of, setup}; + +fn set_mode(s: &Setup, mode: PermissionMode) { + modes::set_permission_mode( + &s.daemon, + BotsSetPermissionModeParams { + bot_id: s.bot.clone(), + mode, + }, + ) + .expect("set mode"); +} + +/// The conversation a daemon that starts now would resume. +fn stored(s: &Setup) -> Option { + s.daemon.store().session_id(&s.bot).expect("read") +} + +#[tokio::test(start_paused = true)] +async fn a_bot_restarted_before_its_first_turn_starts_a_new_conversation() { + let s = setup().await; + let first = s.runtime.process(1).await; + // The owner picks a mode right after creating the bot. + set_mode(&s, PermissionMode::AcceptEdits); + + let second = s.runtime.process(2).await; + assert!(first.killed()); + assert_eq!( + arg_after(&second, "--permission-mode").as_deref(), + Some("acceptEdits") + ); + assert_eq!( + arg_after(&second, "--resume"), + None, + "Claude Code saved nothing to resume" + ); + assert!(arg_after(&second, "--session-id").is_some()); +} + +#[tokio::test(start_paused = true)] +async fn a_conversation_is_resumed_once_a_turn_began_in_it_not_before() { + let s = setup().await; + let first = s.runtime.process(1).await; + s.until(BotState::Idle).await; + // Gone between `system/init` and the replay of the message: with the + // real Claude Code, that conversation cannot be resumed. + s.message("hi"); + first.emit(stream::init(&session_of(&first))).await; + first.exit(1).await; + + let second = s.runtime.process(2).await; + assert_eq!(arg_after(&second, "--resume"), None); + assert_eq!(stored(&s), None); + + s.until(BotState::Idle).await; + let session = session_of(&second); + s.message("hi"); + second.emit(stream::init(&session)).await; + second.emit(stream::began(&session)).await; + second.exit(1).await; + + let third = s.runtime.process(3).await; + assert_eq!(arg_after(&third, "--resume"), Some(session.clone())); + assert_eq!(stored(&s), Some(session)); +} + +#[tokio::test(start_paused = true)] +async fn a_conversation_claude_code_lost_is_replaced_without_a_failed_turn() { + let s = setup().await; + let first = s.runtime.process(1).await; + s.until(BotState::Idle).await; + let session = s.turn(&first).await; + first.exit(0).await; + + let resumed = s.runtime.process(2).await; + assert_eq!(arg_after(&resumed, "--resume"), Some(session.clone())); + resumed.emit(stream::no_conversation(&session)).await; + // Slower to exit than `fresh_start_if_dies_within`. + tokio::time::sleep(Duration::from_secs(20)).await; + resumed.exit(1).await; + + let fresh = s.runtime.process(3).await; + assert_eq!(arg_after(&fresh, "--resume"), None); + assert_ne!(session_of(&fresh), session); + s.until(BotState::Idle).await; + assert_eq!(stored(&s), None, "a daemon restart would not try it again"); + + let history = s + .daemon + .store() + .chat_history(&s.bot, None, 50) + .expect("history"); + let turns: Vec<_> = history + .iter() + .filter_map(|item| match &item.body { + ChatBody::Turn(turn) => Some(turn.error.clone()), + _ => None, + }) + .collect(); + assert_eq!(turns, [None], "only the turn the bot really had"); +} + +#[tokio::test(start_paused = true)] +async fn a_fresh_restart_leaves_the_old_conversation_for_good() { + let s = setup().await; + let first = s.runtime.process(1).await; + s.until(BotState::Idle).await; + let session = s.turn(&first).await; + assert_eq!(stored(&s), Some(session.clone())); + + let restart = BotsRestartParams { + bot_id: s.bot.clone(), + fresh: Some(true), + }; + bots::restart(&s.daemon, restart).expect("restart"); + let second = s.runtime.process(2).await; + assert_ne!(session_of(&second), session); + s.until(BotState::Idle).await; + assert_eq!(stored(&s), None, "a daemon restart would not resume it"); + + // Restarted again before the new conversation had a turn. + set_mode(&s, PermissionMode::Plan); + let third = s.runtime.process(3).await; + assert_eq!(arg_after(&third, "--resume"), None); +} diff --git a/crates/botloftd/tests/supervisor.rs b/crates/botloftd/tests/supervisor.rs index 413d75d7..d3beedb5 100644 --- a/crates/botloftd/tests/supervisor.rs +++ b/crates/botloftd/tests/supervisor.rs @@ -86,6 +86,7 @@ async fn a_crash_restarts_with_backoff_in_the_same_session_and_a_new_token() { let session = arg_after(&first, "--session-id").expect("session"); s.message("hi"); first.emit(stream::init(&session)).await; + first.emit(stream::began(&session)).await; let old_token = first.env("BOTLOFT_BOT_TOKEN").expect("token"); let (_, first_generation) = s.daemon.supervisor.status(&s.bot).expect("slot"); @@ -116,7 +117,7 @@ async fn a_resumed_session_that_dies_at_once_comes_back_fresh() { let s = setup().await; let first = s.runtime.process(1).await; s.until(BotState::Idle).await; - let session = arg_after(&first, "--session-id").expect("session"); + let session = s.turn(&first).await; first.exit(0).await; let resumed = s.runtime.process(2).await; diff --git a/docs/spec.md b/docs/spec.md index 7096697f..ac1c55c8 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -216,7 +216,8 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - Bots não pausados sempre rodam. O supervisor reconcilia no boot e a cada 5 s. - Backoff exponencial com jitter entre `restart_backoff_initial_ms` e `restart_backoff_max_ms`; zera após 10 min de sessão estável. -- **Sessão:** o daemon guarda em `bots.session_id` o id da conversa. O primeiro start usa `--session-id `; os seguintes, `--resume `. Se a sessão retomada morrer em menos de `fresh_start_if_dies_within_s`, o próximo start vem com um id novo. `bots.restart {fresh: true}` também começa conversa nova. O histórico do chat fica no banco do daemon (seção 8) e não depende do transcript do Claude Code. +- **Sessão:** o daemon guarda em `bots.session_id` o id da conversa que o Claude Code tem em disco. O primeiro start usa `--session-id `. O Claude Code só grava a conversa quando o primeiro turno dela começa (19): o id é guardado quando a mensagem desse turno volta no stdout (`user` com `isReplay`, 8.1), não no `system/init`. Daí em diante os starts usam `--resume `. Um processo que termina antes disso (um reinício pedido logo depois de criar o bot, por exemplo) não deixou conversa, e o próximo start usa `--session-id` com outro uuid novo. +- **Conversa nova:** o próximo start vem com um id novo se a sessão retomada morrer em menos de `fresh_start_if_dies_within_s`, se o Claude Code disser que não acha a conversa (um `result` com `subtype: "error_during_execution"` e `No conversation found` em `errors`, sem turno antes; o processo sai logo depois, 19) ou se o dono pedir `bots.restart {fresh: true}`. Esse `result` não é um turno: não vira item no chat nem fecha turno pendente. A conversa deixada para trás sai de `bots.session_id`, para o daemon não tentar retomá-la depois de reiniciar. O histórico do chat fica no banco do daemon (seção 8) e não depende do transcript do Claude Code. - Mudanças em nome, papel ou instruções regravam as regras na hora, mas o bot só as lê no próximo start; o daemon não reinicia o bot sozinho. - `auth_error` não entra em loop de restart: fica parado até o owner pedir `bots.restart` ou até o daemon ver o Claude Code conectado de novo. - **Login do Claude Code:** o daemon roda `claude auth status` (JSON com `loggedIn` e, conectado, `email`, `subscriptionType` e `orgName`; sai com 0 conectado e 1 desconectado, seção 19) no ambiente do usuário, sem janela: quando acha o Claude Code, a cada 30 s enquanto desconectado ou desconhecido, na hora quando um bot cai em `auth_error` e quando o app pede (`system.refresh`). Conectado, não confere de novo sozinho. Quando o resultado passa de desconectado para conectado, os bots em `auth_error` sobem de novo sem o dono pedir. Um erro de conta com o login válido (`billing_error`, `account_on_hold`, organização bloqueada) não muda o resultado e por isso não vira loop. O resultado sai em `system.status.claudeSignedIn`, e a conta (e-mail, plano, organização) em `system.status.account.claude`. O login em si é feito pelo app (15.2). @@ -247,7 +248,7 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - Qualquer outra ferramenta que peça permissão passa pela tool de aprovação (10.1) e vira um pedido no chat. - `--add-dir` põe a pasta de trabalho da crew (5) entre as pastas do bot. Sem ela, gravar na `shared\`, que fica fora do workspace, pedia aprovação até em "Aceitar edições" (19). - `--permission-mode` vem do modo do bot (`Bot.permissionMode`), que o dono escolhe no chat (15.1): `default` (Manual: pergunta antes de editar, rodar comandos e usar a rede), `accept_edits` (`acceptEdits`: edita arquivos sem perguntar), `plan` (planeja e pede para seguir, 10.1), `auto` (um classificador libera o que é seguro e o resto vira pedido) e `bypass_permissions` (`bypassPermissions`: faz tudo sem perguntar, ver 13). Bot novo começa em `default`. `dontAsk` não é oferecido: nega o que não estiver liberado, e o bot não teria como pedir. Regras `deny` valem em todos os modos. - - O Claude Code só lê a flag ao iniciar e não entra em `bypassPermissions` no meio da sessão. Por isso mudar o modo reinicia o processo com `--resume`, e a conversa continua: na hora se o bot está parado, ou quando o turno em andamento e as aprovações dele terminam, sem cortar o trabalho. + - O Claude Code só lê a flag ao iniciar e não entra em `bypassPermissions` no meio da sessão. Por isso mudar o modo reinicia o processo com `--resume`, e a conversa continua: na hora se o bot está parado, ou quando o turno em andamento e as aprovações dele terminam, sem cortar o trabalho. Um bot que ainda não teve turno não tem conversa a retomar e reinicia com `--session-id` (7.3). - `--model` vem do modelo do bot (`Bot.model`), que o dono escolhe no chat ou ao criar o bot (15.1): `fable`, `opus`, `sonnet` ou `haiku`, os apelidos do Claude Code, que apontam sempre para a versão mais nova de cada família. `default` deixa a flag de fora, e o Claude Code usa o padrão da conta, que depende do plano (19). Bot novo começa em `default`. O Claude Code não troca de modelo sozinho conforme a tarefa. Mudar o modelo reinicia o processo como a troca de modo, e `--resume` com `--model` passa a usar o modelo novo. O `system/init` de cada turno traz o modelo em uso (`claude-opus-5-5`); o daemon o guarda em `Bot.modelInUse` para o app dizer qual é o padrão do plano. Um modelo que a conta não pode usar falha o turno com o erro `model_not_found` (8.1), e o processo continua vivo. - O Claude Code sai do modo `plan` sozinho quando o dono aprova o plano. Cada turno começa com um `system/init` que traz `permissionMode` (19); se o processo começou em `plan`, o modo gravado ainda é `plan` e o init diz outro, o daemon grava o novo, sem reiniciar, para o app mostrar o que o bot faz e um reinício manter. Qualquer outra diferença vem de um processo que está para reiniciar no modo que o dono acabou de escolher, e é ignorada. 5. Ambiente: o bloco padrão do usuário (`CreateEnvironmentBlock`, o mesmo de um logon novo), **não** o ambiente do daemon. Um daemon iniciado de dentro de uma sessão do Claude Code herda `CLAUDECODE`, `CLAUDE_CODE_MESSAGING_SOCKET`, `ANTHROPIC_BASE_URL` e outras variáveis da sessão, que fariam o bot se achar filho dela. Por cima vão `BOTLOFT_BOT_ID`, `BOTLOFT_BOT_TOKEN`, `BOTLOFT_PORT` e `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1`, que faz o Claude Code carregar o `CLAUDE.md` da pasta de trabalho (5). @@ -277,9 +278,10 @@ Uma linha JSON por evento, em UTF-8. Linha que não é JSON, ou maior que 8 MiB, | Evento | O que vira | |---|---| -| `system/init` | guarda `session_id` em `bots.session_id` (vem no começo de cada turno); segue `permissionMode` e guarda `model` (7.4); um turno sem message por trás deixa o bot `busy` (7.2) | +| `system/init` | vem no começo de cada turno: segue `permissionMode` e guarda `model` (7.4); um turno sem message por trás deixa o bot `busy` (7.2) | | `system/background_tasks_changed` | quantos subagentes (`task_type: local_agent`) rodam em segundo plano (7.2) | -| `user` com `isReplay: true` | a message com aquele `uuid` começou a ser processada: a delivery ganha `read_at` (9.1) | +| `user` com `isReplay: true` | a message com aquele `uuid` começou a ser processada: a delivery ganha `read_at` (9.1). A conversa existe em disco a partir daqui: guarda `session_id` em `bots.session_id` (7.3) | +| `result` com `No conversation found` em `errors` | o Claude Code não achou a conversa a retomar e vai sair: não é um turno, não vira item nem fecha turno pendente; o próximo start começa conversa nova (7.3) | | `stream_event` com `text_delta` | texto ao vivo da resposta (`chat.delta`, 8.3); não é gravado | | `assistant`, bloco `text` | item `reply` com o texto (markdown) | | `assistant`, bloco `tool_use` | item `tool` em `running`, com resumo da entrada | @@ -853,6 +855,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Co | Subagentes em segundo plano e turnos do próprio Claude Code | 7.2 | **Testado com 2.1.284** (`-p`, `stream-json`, Haiku, dois subagentes no `Agent`): o `result` do turno chega enquanto os subagentes ainda rodam. Eles avisam com `system/background_tasks_changed` (`tasks: [{task_id, task_type: "local_agent", description}]`, a lista inteira de quem ainda roda; `[]` no fim), `task_started`, `task_progress`, `task_updated` e `task_notification` (`status`, `summary`). Ao terminarem, o Claude Code abre turnos por conta própria (`system/init` sem `user` com `isReplay` antes, `assistant` e `result`), sem message escrita. Um turno assim já apareceu num chat real: o bot ficou `idle` durante ele. Comandos `local_bash` de dentro de um subagente aparecem em `task_started` com `owned_by_subagent` | feito | | Regras `allow` do projeto em `-p` sem confiança | 7.4 | Confirmado (`permissions`): não são aplicadas numa pasta nunca confiada; `deny` vale sempre. Por isso `--allowedTools mcp__botloft` | M4.1 | | `--session-id`, `--resume` em `-p` | 7.3 | Confirmado (`cli-reference`, `sessions`): a sessão retoma histórico e modelo; flags como `--mcp-config` têm de ser passadas de novo. **Testado com 2.1.284**: depois de reiniciar o bot e depois de reiniciar o daemon, o bot lembrou arquivos, a imagem e a mensagem de outro bot | feito (M4.1) | +| Quando a conversa passa a existir em disco; `--resume` de uma que não existe | 7.3 | Não documentado. **Testado com 2.1.284** (`-p`, `stream-json`, Haiku, sem MCP). `--resume` com um id que o Claude Code não tem escreve `No conversation found with session ID: ` no stderr e, no stdout, sem `system/init` antes, um `result` com `subtype: "error_during_execution"`, `is_error: true`, `num_turns: 0`, `duration_ms: 0`, `total_cost_usd: 0`, `errors: ["No conversation found with session ID: "]` e sem `terminal_reason`; o processo sai sozinho com código 1, de 1,8 a 5,7 s depois de começar. A conversa só é gravada quando o primeiro turno começa: não foi retomada a de um processo com `--session-id` que ficou 6 s vivo sem mensagem, nem a de um morto ao imprimir o `system/init` do primeiro turno; mortos ao imprimir o `user` com `isReplay` (0,6 a 1,4 s depois do init), o primeiro `assistant` ou o `result`, os processos seguintes retomaram a conversa e ficaram calados à espera de mensagem. Pelo daemon de dev: `bots.create` e `bots.setPermissionMode` 20 ms depois subiram o segundo processo com `--session-id`, sem nada no chat; depois de um turno, a troca de modo retomou a conversa e o bot repetiu a palavra do turno anterior; com o transcript tirado do lugar e o daemon reiniciado, o `--resume` falhou, nada foi para o chat, o processo seguinte começou conversa nova e `bots.session_id` ficou vazio | feito | | Tools MCP adiadas | 10 | **Visto com 2.1.284**: as tools do `botloft` chegam adiadas; antes da primeira `send_message` o bot chama `ToolSearch` com `select:mcp__botloft__send_message`. O chat mostra isso como "load send_message" | feito (M4.1) | | Imagem inline na entrada | 9.5 | **Visto com 2.1.284**: o Claude Code guarda cada bloco `image` recebido em `%TEMP%\claude\\\images\.png`, e o bot pode abrir essa cópia com `Read` sem pedir aprovação. O anexo original continua na pasta do bot | feito (M4.1) | | Transcript `.jsonl` | 7.3 | Confirmado (`sessions`): formato interno, muda entre versões. O chat do Botloft não depende dele | não se aplica |