From d6ee8904d1eab415c1192c2ed601f188e9eb430d Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:20:01 -0300 Subject: [PATCH 1/4] docs(spec): record the edit diffs checked with the real Claude Code --- docs/spec.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/spec.md b/docs/spec.md index 0c84bbb..62e475d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1197,6 +1197,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Conta e cópia na nuvem, com o servidor de verdade | 27 | **Visto** (2026-10-07, `botloft-cloud` 0.11.0 no Coolify de uma VPS, atrás da Cloudflare em `botloft.comitium.com.br`, cópias num bucket R2, e-mail pelo SMTP do Resend na região sa-east-1; daemon de teste em pasta própria): (1) o pedido de entrada mandou o e-mail ao Gmail do dono e, ao apertar o botão da página do link, o daemon entrou em 45 s; (2) a cópia leve (12 714 bytes: uma equipe, um bot com `CLAUDE.md`, uma rotina) subiu em 3,2 s, apareceu no bucket (`1/cpy_…`, 12,71 KB, acesso público desligado), na lista e no uso (12 714 de 200 MiB) e voltou com os mesmos bytes; (3) um segundo daemon, vazio, recusou a senha errada (`wrong_passphrase`), abriu a cópia (`scope: light`) e, ao reiniciar, restaurou a equipe, o bot, a rotina e a memória, sem o arquivo da pasta `shared`; (4) apagar a cópia esvaziou o bucket, e `cloud.signout` apagou `secrets\cloud.json`. Achados: o firewall da VPS só aceita a Cloudflare nas portas 80 e 443 (um acesso direto ao IP dá timeout), então o registro do servidor precisa estar na nuvem laranja, sem a qual nem o HTTPS sai; com o proxy o servidor vê o endereço da Cloudflare, e o limite por endereço (27.3) passa a valer por ponto de entrada até `client_ip_header` ser ligado (27.2). O e-mail caiu na caixa principal do Gmail do dono, fora do spam. Ainda não visto: outros provedores (Outlook, por exemplo) e o celular. | | Cofre de credenciais do Windows, para a senha do envio automático | 27.10 | **Testado** (2026-10-07, Windows 11, teste da crate rodando no cofre de verdade, com um nome só dele): `CredWriteW`, `CredReadW` e `CredDeleteW` com `CRED_TYPE_GENERIC` e `CRED_PERSIST_LOCAL_MACHINE` guardam, devolvem (inclusive acentos), sobrescrevem e apagam o segredo; ler o que não existe dá `ERROR_NOT_FOUND`, que o código lê como "não há", e apagar o que não existe não é erro. **Envio automático com a VPS de verdade, testado em 2026-10-07** (daemon de teste em pasta própria, `botloft.comitium.com.br`, conta de teste do dono): o padrão de `[cloud] url` é o servidor real; ligar guardou a senha no Gerenciador de Credenciais (`cmdkey /list` mostra `Botloft backup passphrase `, persistência de máquina local) e a primeira cópia leve (19,7 KB) subiu cerca de 4 s depois; olhar de novo sem mudança não mandou nada; editar o `CLAUDE.md` de um bot fez subir a segunda cópia; a cópia abre com a senha guardada (`scope: light`) e não com outra (`wrong_passphrase`); a senha não está em log nem em `autobackup.json`; desligar a apagou do cofre. Achados, já corrigidos e com teste: o estado saía com `null` no lugar de campo ausente (a tela mostraria uma data inválida em "Última cópia enviada"), e uma mudança de frequência feita durante um envio era desfeita quando o envio acabava | feito | | Celular (aprovar e responder de longe) | 28 | **Visto** (2026-10-08), com a VPS atualizada e um **Android de verdade** (Chrome, instalado como app); **não visto** no iPhone (sem aparelho). **Servidor:** a imagem do `deploy/cloud/Dockerfile` inteiro foi levada à VPS do Coolify (`docker load`), com `phone_dir`, `[push]` e a chave em `BOTLOFT_CLOUD_VAPID_KEY` (variável só de execução); subiu sem erro, `/health` 200, `/m` com a política atrás da Cloudflare, o JavaScript igual ao do build local, e o **WebSocket do relay abre atravessando a Cloudflare** (`101`). **Computador:** `pnpm local` trocou o app e o daemon (hashes iguais aos do build). **No aparelho:** (1) a **câmera do Android preservou o `#`** do QR; os seis dígitos eram iguais nos dois lados; instalou como app; (2) **Permitir** um pedido de escrever um arquivo (o cartão chegou sozinho, o bot seguiu); (3) **Negar com recado** (o cartão sumiu, o bot citou o recado, o arquivo ficou); (4) uma pergunta de `ask_owner` com duas opções, respondida pelo celular (o bot recebeu "verde"); (5) **aviso com a tela bloqueada e o app fechado**: chegou "O Botloft precisa de você" e tocar abriu o cartão; (6) desconectar **pelo computador** (o app aberto mostrou "foi desconectado" e voltou à conexão) e **pelo celular** (o computador deixou de listá-lo); (7) conectar de novo com um código novo no mesmo celular; (8) um pedido de rotina (que só se resolve no computador) chegou com o **Permitir apagado** e o aviso "precisa de você no computador", e **sumiu sozinho** do celular quando foi aprovado no computador. **Achados:** uma primeira instalação do app que travou pela metade deixou os avisos sem ligar até reinstalar (nada a corrigir no código; vale uma linha na ajuda); e o painel de navegador do app não registra service workers, então o aviso só se vê em aparelho de verdade. **Falta ver:** iPhone com a página na Tela de Início (Safari: câmera e `#`, Web Crypto, avisos); a conexão do relay aberta por horas atrás da Cloudflare; vários avisos seguidos virando dois (só testado automaticamente); sair da conta no computador levando os celulares (só testado automaticamente) | em parte | +| Entrada do `Write` e do `Edit` para as contagens e o diff | 8.2, 15.3 | **Testado com 2.1.294** (2026-10-22, daemon de dev em pasta própria, app de dev com `?live`, Haiku, modo Manual): pedido para criar `notas.md` com 5 linhas pelo `Write` e trocar uma linha por duas pelo `Edit`. O `Write` chegou com `file_path` e `content`, e o `Edit` com `file_path`, `old_string` e `new_string` (sem contexto em volta: só a linha trocada). A linha mostrou "+5" no `Write` e "+2 −1" no `Edit` já no pedido de permissão, e abertas, os diffs certos: cinco linhas adicionadas; "- pão" removida, "- pão integral" e "- manteiga" adicionadas. O arquivo ficou como pedido. O texto da conversa não foi para o log | feito | Itens do runtime anterior (ConPTY, hooks em exec form, `crossSessionInbound`, linha de auth do inbox, diálogo de confiança, `ESC[6n` do ConPTY, consultas do terminal no replay) foram verificados no M2–M4 e deixaram de se aplicar com a ADR 0001; o histórico está no git e na ADR. From 91ff69c233b575d52008d3dc2a50ff98e6960a41 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:06:43 -0300 Subject: [PATCH 2/4] feat(tools): show_plan, the plan a bot shows before long work - With "show the plan first" on (bots.plan_first, migration 0031, bots.update { planFirst }), the bot's rules ask it to call show_plan before long work: a summary, 2 to 8 steps and an optional smaller way. - The card goes through the approvals path but waits only 20 s and does not put the bot in needs_approval: start now, do only the smaller way, change (with a note) or cancel; with no answer the bot starts. - Off, or in plan mode, the tool answers at once without a card. The phone reads the plan as one text and may answer it. - The chief's and the plan rule sections move to workspace/sections.rs. - Spec 10.5 describes it. --- app/src/lib/protocol.gen.ts | 13 +- crates/botloft-core/src/chat.rs | 5 +- crates/botloft-core/src/protocol/methods.rs | 4 + crates/botloft-core/src/protocol/model.rs | 3 + .../migrations/0031_bot_plan_first.sql | 3 + crates/botloft-store/src/bots.rs | 15 +- crates/botloft-store/src/bots/tests.rs | 1 + crates/botloft-store/src/lib.rs | 1 + crates/botloft-store/src/migrate.rs | 1 + crates/botloftd/src/approvals/mod.rs | 68 +++++- crates/botloftd/src/mobile/cards.rs | 59 ++++- crates/botloftd/src/service/bots.rs | 13 +- crates/botloftd/src/tools/bot_change.rs | 1 + crates/botloftd/src/tools/catalog.rs | 1 + crates/botloftd/src/tools/mod.rs | 8 +- crates/botloftd/src/tools/plan.rs | 212 ++++++++++++++++++ crates/botloftd/src/workspace/files.rs | 41 +--- crates/botloftd/src/workspace/mod.rs | 2 + crates/botloftd/src/workspace/sections.rs | 52 +++++ crates/botloftd/tests/agy_bot.rs | 2 + crates/botloftd/tests/mcp.rs | 3 +- crates/botloftd/tests/plan_first.rs | 211 +++++++++++++++++ docs/spec.md | 17 +- 23 files changed, 676 insertions(+), 60 deletions(-) create mode 100644 crates/botloft-store/migrations/0031_bot_plan_first.sql create mode 100644 crates/botloftd/src/tools/plan.rs create mode 100644 crates/botloftd/src/workspace/sections.rs create mode 100644 crates/botloftd/tests/plan_first.rs diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index f1c481d..022c8c0 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -257,7 +257,12 @@ agentModel: string | null, * Command prefixes a bot not on Claude Code may run without asking: * its agent cannot ask in headless mode (spec 30). `*` is every command. */ -allowedCommands: Array, model: BotModel, +allowedCommands: Array, +/** + * Shows its plan before a long task and starts by itself after a few + * seconds (spec 10.5). + */ +planFirst: boolean, model: BotModel, /** * The model id Claude Code reported when the bot last started a turn * (`claude-opus-5-5`); `null` before its first turn. @@ -509,7 +514,11 @@ export type BotsUpdateParams = { botId: BotId, name?: string, role?: string, ins /** * For a bot not on Claude Code; replaces the whole list. */ -allowedCommands?: Array, }; +allowedCommands?: Array, +/** + * Show the plan before a long task (spec 10.5). + */ +planFirst?: boolean, }; export type BotsSetPausedParams = { botId: BotId, paused: boolean, }; diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs index 67625da..09fb732 100644 --- a/crates/botloft-core/src/chat.rs +++ b/crates/botloft-core/src/chat.rs @@ -23,6 +23,8 @@ pub const DELETE_ROUTINE_TOOL: &str = "mcp__botloft__delete_routine"; /// A bot asking to rename or change itself, or the chief another bot /// (spec 10.3). pub const CHANGE_BOT_TOOL: &str = "mcp__botloft__change_bot"; +/// A bot showing its plan before a long task (spec 10.5). +pub const SHOW_PLAN_TOOL: &str = "mcp__botloft__show_plan"; /// A bot asking to reach another crew (spec 10.4). pub const CREW_ACCESS_TOOL: &str = "mcp__botloft__ask_crew_access"; /// A bot asking to use its browser on a site (spec 21.5). Not a tool the @@ -71,7 +73,7 @@ pub fn one_line(text: &str, max: usize) -> String { /// Longest input kept for `tool`, in bytes. pub fn tool_input_max(tool: &str) -> usize { match tool { - PLAN_TOOL => PLAN_INPUT_MAX, + PLAN_TOOL | SHOW_PLAN_TOOL => PLAN_INPUT_MAX, SUGGEST_TOOL | ROUTINE_TOOL | CHANGE_ROUTINE_TOOL | CHANGE_BOT_TOOL => SUGGESTION_INPUT_MAX, tool if is_command(tool) => COMMAND_INPUT_MAX, _ => TOOL_INPUT_MAX, @@ -119,6 +121,7 @@ pub fn tool_summary(name: &str, input: &Value) -> String { | CHANGE_BOT_TOOL => field(input, "name").map(str::to_owned), BROWSER_SITE_TOOL => field(input, "site").map(str::to_owned), CREW_ACCESS_TOOL => field(input, "crew").map(str::to_owned), + SHOW_PLAN_TOOL => field(input, "summary").map(str::to_owned), DESKTOP_TOOL => field(input, "app").map(str::to_owned), "mcp__botloft__desktop_look" | "mcp__botloft__desktop_screenshot" => { field(input, "why").map(str::to_owned) diff --git a/crates/botloft-core/src/protocol/methods.rs b/crates/botloft-core/src/protocol/methods.rs index e6690e8..db89ba9 100644 --- a/crates/botloft-core/src/protocol/methods.rs +++ b/crates/botloft-core/src/protocol/methods.rs @@ -318,6 +318,10 @@ pub struct BotsUpdateParams { #[serde(default)] #[cfg_attr(test, ts(optional))] pub allowed_commands: Option>, + /// Show the plan before a long task (spec 10.5). + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub plan_first: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index 92b6d47..5761bc6 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -158,6 +158,9 @@ pub struct Bot { /// Command prefixes a bot not on Claude Code may run without asking: /// its agent cannot ask in headless mode (spec 30). `*` is every command. pub allowed_commands: Vec, + /// Shows its plan before a long task and starts by itself after a few + /// seconds (spec 10.5). + pub plan_first: bool, pub model: BotModel, /// The model id Claude Code reported when the bot last started a turn /// (`claude-opus-5-5`); `null` before its first turn. diff --git a/crates/botloft-store/migrations/0031_bot_plan_first.sql b/crates/botloft-store/migrations/0031_bot_plan_first.sql new file mode 100644 index 0000000..5352184 --- /dev/null +++ b/crates/botloft-store/migrations/0031_bot_plan_first.sql @@ -0,0 +1,3 @@ +-- A bot can show its plan before a long task and start by itself after a few +-- seconds (spec 10.5). +ALTER TABLE bots ADD COLUMN plan_first INTEGER NOT NULL DEFAULT 0; diff --git a/crates/botloft-store/src/bots.rs b/crates/botloft-store/src/bots.rs index 6b82825..98eb1c6 100644 --- a/crates/botloft-store/src/bots.rs +++ b/crates/botloft-store/src/bots.rs @@ -25,6 +25,8 @@ pub struct BotRecord { pub agent_model: Option, /// Command prefixes the bot may run without asking (spec 30). pub allowed_commands: Vec, + /// Shows its plan before a long task (spec 10.5). + pub plan_first: bool, pub model: BotModel, /// What Claude Code last reported; written by [`Store::set_model_in_use`] /// and left alone by [`Store::update_bot`]. @@ -38,7 +40,7 @@ pub struct BotRecord { } const COLUMNS: &str = "id, crew_id, name, handle, slug, role, instructions, color, paused, created_at, archived_at, \ - permission_mode, model, model_in_use, effort, effort_default, agent, agent_model, allowed_commands"; + permission_mode, model, model_in_use, effort, effort_default, agent, agent_model, allowed_commands, plan_first"; fn from_row(row: &Row<'_>) -> rusqlite::Result { Ok(BotRecord { @@ -64,6 +66,7 @@ fn from_row(row: &Row<'_>) -> rusqlite::Result { agent: parse_column(row, 16)?, agent_model: row.get(17)?, allowed_commands: serde_json::from_str(&row.get::<_, String>(18)?).unwrap_or_default(), + plan_first: row.get(19)?, }) } @@ -77,7 +80,7 @@ impl Store { .execute( &format!( "INSERT INTO bots ({COLUMNS}) \ - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19)" + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20)" ), params![ bot.id.as_str(), @@ -98,7 +101,8 @@ impl Store { bot.effort_default.map(ModelEffort::as_str), bot.agent.as_str(), bot.agent_model, - commands_json(&bot.allowed_commands) + commands_json(&bot.allowed_commands), + bot.plan_first ], ) .map_err(|err| unique_as_duplicate(err, "bot handle or slug"))?; @@ -167,7 +171,7 @@ impl Store { .execute( "UPDATE bots SET name = ?2, handle = ?3, role = ?4, instructions = ?5, \ color = ?6, paused = ?7, archived_at = ?8, permission_mode = ?9, model = ?10, \ - effort = ?11, agent_model = ?12, allowed_commands = ?13 WHERE id = ?1", + effort = ?11, agent_model = ?12, allowed_commands = ?13, \n plan_first = ?14 WHERE id = ?1", params![ bot.id.as_str(), bot.name, @@ -181,7 +185,8 @@ impl Store { bot.model.as_str(), bot.effort.as_str(), bot.agent_model, - commands_json(&bot.allowed_commands) + commands_json(&bot.allowed_commands), + bot.plan_first ], ) .map_err(|err| unique_as_duplicate(err, "bot handle"))?; diff --git a/crates/botloft-store/src/bots/tests.rs b/crates/botloft-store/src/bots/tests.rs index 7bbc8f0..8223083 100644 --- a/crates/botloft-store/src/bots/tests.rs +++ b/crates/botloft-store/src/bots/tests.rs @@ -37,6 +37,7 @@ fn bot(crew: &CrewId, handle: &str, created_at: i64) -> BotRecord { agent: AgentKind::Claude, agent_model: None, allowed_commands: Vec::new(), + plan_first: false, model: BotModel::Default, model_in_use: None, effort: BotEffort::Default, diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index e843876..9f61516 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -199,6 +199,7 @@ pub(crate) mod tests { agent: botloft_core::protocol::AgentKind::Claude, agent_model: None, allowed_commands: Vec::new(), + plan_first: false, model: botloft_core::protocol::BotModel::Default, model_in_use: None, effort: botloft_core::protocol::BotEffort::Default, diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index f302db6..ef713dd 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -40,6 +40,7 @@ const MIGRATIONS: &[(u32, &str)] = &[ ), (29, include_str!("../migrations/0029_read_through.sql")), (30, include_str!("../migrations/0030_crew_auto_mode.sql")), + (31, include_str!("../migrations/0031_bot_plan_first.sql")), ]; /// Schema version after every migration has run. diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs index eb297eb..64cf1d0 100644 --- a/crates/botloftd/src/approvals/mod.rs +++ b/crates/botloftd/src/approvals/mod.rs @@ -8,10 +8,11 @@ mod prompt; use std::collections::HashMap; use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; use botloft_core::chat::{ - CHANGE_ROUTINE_TOOL, CREW_ACCESS_TOOL, ROUTINE_TOOL, SUGGEST_TOOL, clip, tool_input_max, - tool_summary, + CHANGE_ROUTINE_TOOL, CREW_ACCESS_TOOL, ROUTINE_TOOL, SHOW_PLAN_TOOL, SUGGEST_TOOL, clip, + tool_input_max, tool_summary, }; use botloft_core::command::tool_explanation; use botloft_core::ids::{ApprovalId, BotId, ChatItemId}; @@ -133,18 +134,63 @@ pub(crate) async fn ask_with( ) -> Option { let (pending, waiting) = open(daemon, bot, tool_name, input, tool_use_id)?; opened(&pending.record.approval.id); - daemon.supervisor.approval_opened(bot, generation); + let wait = Wait { + within: daemon.settings.approval_wait(), + needs_owner: true, + }; + settle_after(daemon, bot, generation, &pending, waiting, wait).await +} + +/// Puts a request in the bot's chat that settles by itself after `within`: +/// the bot keeps working meanwhile, so it does not wait for the owner in +/// the app's eyes (spec 10.5). +pub(crate) async fn ask_briefly( + daemon: &Daemon, + bot: &BotId, + generation: u64, + tool_name: &str, + input: &Value, + within: Duration, +) -> Option { + let (pending, waiting) = open(daemon, bot, tool_name, input, "")?; + let wait = Wait { + within, + needs_owner: false, + }; + settle_after(daemon, bot, generation, &pending, waiting, wait).await +} + +/// How long a request waits, and whether its bot waits for the owner then. +struct Wait { + within: Duration, + needs_owner: bool, +} + +async fn settle_after( + daemon: &Daemon, + bot: &BotId, + generation: u64, + pending: &Pending, + waiting: oneshot::Receiver, + wait: Wait, +) -> Option { + if wait.needs_owner { + daemon.supervisor.approval_opened(bot, generation); + } let mut guard = Guard { daemon, bot, generation, record: &pending.record, + needs_owner: wait.needs_owner, settled: false, }; - let decision = tokio::time::timeout(daemon.settings.approval_wait(), waiting).await; + let decision = tokio::time::timeout(wait.within, waiting).await; guard.settled = true; daemon.approvals.lock().remove(&pending.record.approval.id); - daemon.supervisor.approval_closed(bot, generation); + if wait.needs_owner { + daemon.supervisor.approval_closed(bot, generation); + } Some(match decision { Ok(Ok(Decision { allow: true, input, .. @@ -191,6 +237,9 @@ pub fn answer(daemon: &Daemon, params: ApprovalsAnswerParams) -> ApiResult Some( crate::tools::check_crew_access_answer(&existing.approval.input, &input)?, ), + Some(input) if params.allow && existing.approval.tool_name == SHOW_PLAN_TOOL => Some( + crate::tools::check_plan_answer(&existing.approval.input, &input)?, + ), _ => None, }; let Some(record) = settle(daemon, &existing, status, note.as_deref(), input.as_deref()) else { @@ -367,6 +416,7 @@ struct Guard<'a> { bot: &'a BotId, generation: u64, record: &'a ApprovalRecord, + needs_owner: bool, settled: bool, } @@ -386,8 +436,10 @@ impl Drop for Guard<'_> { None, None, ); - self.daemon - .supervisor - .approval_closed(self.bot, self.generation); + if self.needs_owner { + self.daemon + .supervisor + .approval_closed(self.bot, self.generation); + } } } diff --git a/crates/botloftd/src/mobile/cards.rs b/crates/botloftd/src/mobile/cards.rs index 397476f..372df5d 100644 --- a/crates/botloftd/src/mobile/cards.rs +++ b/crates/botloftd/src/mobile/cards.rs @@ -1,7 +1,7 @@ //! What the phone is shown (spec 28.5): a card for each request and question //! that waits, with only what the owner needs to decide. -use botloft_core::chat::PLAN_TOOL; +use botloft_core::chat::{PLAN_TOOL, SHOW_PLAN_TOOL}; use botloft_core::ids::ApprovalId; use botloft_core::protocol::{ ApprovalCard, ApprovalItem, ApprovalStatus, CARD_TEXT_MAX, CardBot, ChatBody, ChatItem, @@ -30,6 +30,7 @@ pub fn may_allow(tool: &str) -> bool { | "MultiEdit" | "NotebookEdit" ) || tool == PLAN_TOOL + || tool == SHOW_PLAN_TOOL } /// The text to read before allowing, and whether it is not all of the @@ -46,6 +47,7 @@ fn text_of(tool: &str, input: &str) -> (String, bool) { let (text, whole) = match tool { "Bash" | "PowerShell" => (field("command"), parsed.is_some()), PLAN_TOOL => (field("plan"), parsed.is_some()), + SHOW_PLAN_TOOL => (parsed.as_ref().map(plan_text), parsed.is_some()), _ => (None, parsed.is_some()), }; // A request the daemon cut short (it does not parse) is shown as it is @@ -61,6 +63,31 @@ fn text_of(tool: &str, input: &str) -> (String, bool) { (text[..end].to_owned(), true) } +/// A plan shown before a long task (spec 10.5) as one text: the summary, +/// the numbered steps and the smaller way. +fn plan_text(plan: &Value) -> String { + let mut text = plan["summary"].as_str().unwrap_or_default().to_owned(); + let steps = plan["steps"] + .as_array() + .map(Vec::as_slice) + .unwrap_or_default(); + for (number, step) in steps.iter().filter_map(Value::as_str).enumerate() { + text.push_str(&format!( + " +{}. {step}", + number + 1 + )); + } + if let Some(alternative) = plan["alternative"].as_str() { + text.push_str(&format!( + " + +{alternative}" + )); + } + text +} + fn bot_and_crew(daemon: &Daemon, bot: &botloft_core::ids::BotId) -> Option<(CardBot, String)> { let store = daemon.store(); let record = match store.bot(bot) { @@ -162,9 +189,37 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn a_plan_before_long_work_reads_as_one_text_on_the_phone() { + let plan = json!({ + "summary": "Fix the login", + "steps": ["Read the form", "Fix the check"], + "alternative": "Only list the problems", + "starts_at": 1, + }); + let (text, cut) = text_of(SHOW_PLAN_TOOL, &plan.to_string()); + assert_eq!( + text, + "Fix the login +1. Read the form +2. Fix the check + +Only list the problems" + ); + assert!(!cut); + } + #[test] fn the_phone_allows_plain_tools_and_leaves_the_rest_to_the_computer() { - for tool in ["Bash", "PowerShell", "WebFetch", "Read", "Edit", PLAN_TOOL] { + for tool in [ + "Bash", + "PowerShell", + "WebFetch", + "Read", + "Edit", + PLAN_TOOL, + SHOW_PLAN_TOOL, + ] { assert!(may_allow(tool), "{tool}"); } for tool in [ diff --git a/crates/botloftd/src/service/bots.rs b/crates/botloftd/src/service/bots.rs index 5ddcaf3..c26680d 100644 --- a/crates/botloftd/src/service/bots.rs +++ b/crates/botloftd/src/service/bots.rs @@ -137,6 +137,7 @@ pub(crate) fn insert( agent: new.agent, agent_model: None, allowed_commands: Vec::new(), + plan_first: false, model: new.model, model_in_use: None, effort: new.effort, @@ -168,7 +169,7 @@ pub fn update(daemon: &Daemon, params: BotsUpdateParams) -> ApiResult { if let Some(color) = params.color { record.color = parse_color(&color)?; } - let mut commands_changed = false; + let mut relaunch = false; if let Some(commands) = params.allowed_commands { if record.agent == AgentKind::Claude { return Err(ApiError::validation( @@ -176,9 +177,14 @@ pub fn update(daemon: &Daemon, params: BotsUpdateParams) -> ApiResult { )); } let commands = clean_commands(commands)?; - commands_changed = commands != record.allowed_commands; + relaunch = commands != record.allowed_commands; record.allowed_commands = commands; } + if let Some(plan_first) = params.plan_first { + // The rules say when to show the plan; the agent reads them at start. + relaunch |= plan_first != record.plan_first; + record.plan_first = plan_first; + } // Rules first: if the disk write fails, nothing is saved. A running bot // reads them at its next start. super::mcp::write_rules(daemon, &store, &crew, &record).map_err(ApiError::Workspace)?; @@ -186,7 +192,7 @@ pub fn update(daemon: &Daemon, params: BotsUpdateParams) -> ApiResult { let id = record.id.clone(); let bot = changed(daemon, &store, &crew, record); drop(store); - if commands_changed { + if relaunch { // The agent reads its rules when it starts. daemon.supervisor.launch_settings_changed(&id); } @@ -307,6 +313,7 @@ pub(crate) fn to_protocol(daemon: &Daemon, store: &Store, crew: &Crew, record: B agent: record.agent, agent_model: record.agent_model, allowed_commands: record.allowed_commands, + plan_first: record.plan_first, model: record.model, model_in_use: record.model_in_use, effort: record.effort, diff --git a/crates/botloftd/src/tools/bot_change.rs b/crates/botloftd/src/tools/bot_change.rs index 6aa5722..dd047b7 100644 --- a/crates/botloftd/src/tools/bot_change.rs +++ b/crates/botloftd/src/tools/bot_change.rs @@ -198,6 +198,7 @@ fn apply(daemon: &Daemon, id: &BotId, before: &Profile, next: &Profile) -> ApiRe instructions: Some(next.instructions.clone()), color: None, allowed_commands: None, + plan_first: None, }, )?; if next.model != before.model { diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs index 36c6bc2..9fe0928 100644 --- a/crates/botloftd/src/tools/catalog.rs +++ b/crates/botloftd/src/tools/catalog.rs @@ -27,6 +27,7 @@ pub fn tools() -> Value { let more = [ super::share::tool(), super::question::tool(), + super::plan::tool(), super::signal::tool(), ] .into_iter() diff --git a/crates/botloftd/src/tools/mod.rs b/crates/botloftd/src/tools/mod.rs index c2f350e..d91f20d 100644 --- a/crates/botloftd/src/tools/mod.rs +++ b/crates/botloftd/src/tools/mod.rs @@ -26,6 +26,7 @@ mod desktop_list; mod desktop_real; mod desktop_screen; mod era; +mod plan; mod question; mod routine; mod routine_catalog; @@ -48,6 +49,7 @@ use tracing::debug; pub(crate) use self::crew_access::check_answer as check_crew_access_answer; use self::era::{Era, LEGACY, era, supported}; +pub(crate) use self::plan::check_answer as check_plan_answer; pub(crate) use self::routine::check_changed as check_changed_routine; pub(crate) use self::routine_change::check_changed as check_changed_routine_change; use crate::approvals; @@ -59,7 +61,7 @@ const CACHE_TTL_MS: u64 = 60 * 60 * 1000; const INSTRUCTIONS: &str = "Tools to work with your Botloft crew: see who is in it, send notes \ or tasks to other bots, report the result of tasks assigned to you and, for the crew's chief, \ - suggest new bots (list_bot_templates and get_bot_template show the catalog of ready-made roles) and, to write the owner a summary, read what the crew's bots did with crew_activity. schedule_routine asks the owner for work at set times. ask_owner asks the owner a question without waiting; the answer arrives later as a message. send_signal tells the crew something happened, and the routines waiting for it run. share_file shows the \ + suggest new bots (list_bot_templates and get_bot_template show the catalog of ready-made roles) and, to write the owner a summary, read what the crew's bots did with crew_activity. schedule_routine asks the owner for work at set times. ask_owner asks the owner a question without waiting; the answer arrives later as a message. show_plan shows the owner your plan before long work, when your rules ask for it. send_signal tells the crew something happened, and the routines waiting for it run. share_file shows the \ owner files you made, as cards in the chat they can open and save. The browser_ tools drive your own web browser, which the owner can watch \ live and take over when you ask with browser_ask_owner. The desktop_ tools read the apps open on \ the owner's own computer, app by app as the owner allows. The owner writes to you directly; messages from other bots and from Botloft start with \ @@ -168,6 +170,10 @@ pub async fn handle( let result = crew_access::ask(&daemon, &bot, generation, args).await; Ok(decorate(era, &request, result)) } + Ok(era) if called(&request) == Some(plan::SHOW_PLAN) => { + let result = plan::show(&daemon, &bot, generation, arguments(&request)).await; + Ok(decorate(era, &request, result)) + } Ok(era) if called(&request) == Some(bot_change::CHANGE_BOT) => { let args = arguments(&request); let result = bot_change::change(&daemon, &bot, generation, args).await; diff --git a/crates/botloftd/src/tools/plan.rs b/crates/botloftd/src/tools/plan.rs new file mode 100644 index 0000000..e667fd7 --- /dev/null +++ b/crates/botloftd/src/tools/plan.rs @@ -0,0 +1,212 @@ +//! `show_plan` (spec 10.5): before a long task, the bot shows the owner what +//! it understood and how it will do it. The owner has a few seconds to start +//! it now, pick the smaller way, change it or cancel it; with no answer the +//! bot starts by itself. + +use std::time::Duration; + +use botloft_core::chat::SHOW_PLAN_TOOL; +use botloft_core::ids::BotId; +use botloft_core::protocol::PermissionMode; +use serde::Deserialize; +use serde_json::{Value, json}; + +use super::calls::{explain, parse, tool_result}; +use crate::approvals::{self, Answer}; +use crate::service::{ApiError, ApiResult, bots}; +use crate::state::Daemon; + +pub const SHOW_PLAN: &str = "show_plan"; +/// How long the owner has before the bot starts by itself. +pub const PLAN_WAIT: Duration = Duration::from_secs(20); +const SUMMARY_MAX: usize = 300; +const STEP_MAX: usize = 200; +const STEPS_MIN: usize = 2; +const STEPS_MAX: usize = 8; +const ALTERNATIVE: &str = "alternative"; + +pub(super) fn tool() -> Value { + json!({ + "name": SHOW_PLAN, + "title": "Show your plan", + "description": "Shows the owner your plan before long work, when your rules ask for \ + it: what you understood, the steps and, if there is one, a smaller way. The owner \ + has a few seconds to change it; the call waits that long and, with no answer, \ + tells you to start. Do what the result says.", + "inputSchema": { + "type": "object", + "properties": { + "summary": { + "type": "string", + "maxLength": SUMMARY_MAX, + "description": "One line: what you understood the owner wants, in their \ + language.", + }, + "steps": { + "type": "array", + "items": { "type": "string", "maxLength": STEP_MAX }, + "minItems": STEPS_MIN, + "maxItems": STEPS_MAX, + "description": "What you will do, in order, in plain words.", + }, + "alternative": { + "type": "string", + "maxLength": SUMMARY_MAX, + "description": "A smaller way to do it, if there is one, such as only \ + listing the problems without changing anything.", + }, + }, + "required": ["summary", "steps"], + "additionalProperties": false, + }, + "annotations": { "readOnlyHint": true }, + }) +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Args { + summary: String, + steps: Vec, + alternative: Option, +} + +/// The fields trimmed and checked; nothing invalid reaches the owner. +fn checked(args: Args) -> Result { + let line = |name: &str, text: &str, max: usize| -> Result { + let text = text.trim(); + if text.is_empty() { + return Err(format!("{name} cannot be empty")); + } + if text.chars().count() > max { + return Err(format!("{name} must be at most {max} characters")); + } + Ok(text.to_owned()) + }; + if !(STEPS_MIN..=STEPS_MAX).contains(&args.steps.len()) { + return Err(format!("steps takes {STEPS_MIN} to {STEPS_MAX} steps")); + } + Ok(Args { + summary: line("summary", &args.summary, SUMMARY_MAX)?, + steps: args + .steps + .iter() + .map(|step| line("a step", step, STEP_MAX)) + .collect::>()?, + alternative: args + .alternative + .filter(|text| !text.trim().is_empty()) + .map(|text| line("alternative", &text, SUMMARY_MAX)) + .transpose()?, + }) +} + +pub(super) async fn show(daemon: &Daemon, bot: &BotId, generation: u64, args: Value) -> Value { + tool_result(run(daemon, bot, generation, args).await) +} + +async fn run(daemon: &Daemon, bot: &BotId, generation: u64, args: Value) -> Result { + let args = checked(parse(args)?)?; + let record = bots::find(&daemon.store(), bot).map_err(explain)?; + if !record.plan_first { + return Ok(go("The owner did not ask to see plans first: go ahead.")); + } + if record.permission_mode == PermissionMode::Plan { + return Ok(go( + "You are in plan mode: ExitPlanMode shows the owner your plan. Go on planning.", + )); + } + // Tests make every wait for the owner short; the plan's too. + let within = PLAN_WAIT.min(daemon.settings.approval_wait()); + let starts_at = daemon.clock.now_ms() + i64::try_from(within.as_millis()).unwrap_or(0); + let input = json!({ + "summary": args.summary, + "steps": args.steps, + "alternative": args.alternative, + "starts_at": starts_at, + }); + let answer = approvals::ask_briefly(daemon, bot, generation, SHOW_PLAN_TOOL, &input, within); + Ok(match answer.await { + Some(Answer::Allowed { input: Some(_) }) => json!({ + "start": ALTERNATIVE, + "note": "The owner picked your alternative: do only that, not the whole plan.", + }), + Some(Answer::Allowed { input: None }) => go("The owner said start: go ahead."), + Some(Answer::Denied { note: Some(note) }) => json!({ + "start": false, + "change": note, + "note": "The owner wants something different: do not start the plan. Do what they \ + said; if the work is still long, show the new plan first.", + }), + Some(Answer::Denied { note: None }) => json!({ + "start": false, + "note": "The owner cancelled: do not start. Ask in one sentence what they want, then \ + end your turn.", + }), + Some(Answer::Expired) => go("The owner did not change the plan: go ahead."), + None => go("Botloft could not show the plan: go ahead."), + }) +} + +fn go(note: &str) -> Value { + json!({ "start": "plan", "note": note }) +} + +/// The owner's answer when they pick the alternative: `{"choice": +/// "alternative"}`, and only for a plan that has one. It is kept with the +/// plan, so the answered card still shows it. +pub(crate) fn check_answer(saved: &str, answer: &str) -> ApiResult { + let wrong = || ApiError::validation("the answer to a plan is {\"choice\":\"alternative\"}"); + let answer: Value = serde_json::from_str(answer).map_err(|_| wrong())?; + if answer != json!({ "choice": ALTERNATIVE }) { + return Err(wrong()); + } + let mut plan: Value = serde_json::from_str(saved).unwrap_or_default(); + if !plan["alternative"].is_string() { + return Err(ApiError::validation("this plan has no alternative")); + } + plan["choice"] = json!(ALTERNATIVE); + Ok(plan.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn args(steps: &[&str]) -> Args { + Args { + summary: " Fix the login ".into(), + steps: steps.iter().map(|step| (*step).to_owned()).collect(), + alternative: Some(" ".into()), + } + } + + #[test] + fn plans_are_trimmed_and_an_empty_alternative_is_none() { + let plan = checked(args(&["Read the form", " Fix the check "])).unwrap(); + assert_eq!(plan.summary, "Fix the login"); + assert_eq!(plan.steps, ["Read the form", "Fix the check"]); + assert_eq!(plan.alternative, None); + } + + #[test] + fn plans_need_two_to_eight_short_steps() { + assert!(checked(args(&["Only one"])).is_err()); + assert!(checked(args(&["a"; 9])).is_err()); + assert!(checked(args(&["a", ""])).is_err()); + let long = "x".repeat(STEP_MAX + 1); + assert!(checked(args(&["a", &long])).is_err()); + } + + #[test] + fn only_a_plan_with_an_alternative_takes_the_alternative() { + let with = r#"{"summary":"s","steps":["a","b"],"alternative":"smaller"}"#; + let without = r#"{"summary":"s","steps":["a","b"],"alternative":null}"#; + let pick = r#"{"choice":"alternative"}"#; + let kept: Value = serde_json::from_str(&check_answer(with, pick).unwrap()).unwrap(); + assert_eq!(kept["choice"], ALTERNATIVE); + assert_eq!(kept["steps"][1], "b"); + assert!(check_answer(without, pick).is_err()); + assert!(check_answer(with, r#"{"choice":"other"}"#).is_err()); + } +} diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index 54fcf31..e7d3663 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -8,6 +8,7 @@ use botloft_store::BotRecord; use serde_json::{Value, json}; use super::memory; +use super::sections::{LEAD_MD, PLAN_FIRST_MD}; use crate::paths::permission_rule_path; /// Longer than the approval timeout, so Claude Code never gives up on a @@ -62,41 +63,6 @@ session, including after restarts. Keep decisions, context about ongoing work and lessons learned here, and remove what is no longer true. "; -/// Added to the rules of the crew's chief (spec 10.2). -const LEAD_MD: &str = " -## You lead this crew - -The owner made you the chief of this crew: its goal is in your instructions. - -- Plan the work, split it into pieces that can run at the same time, and hand - them out with `send_message` and `kind: \"task\"`. Each bot works in - parallel; results come back as messages. Check them before you report. -- When a piece needs a specialist the crew lacks, look at the bot catalog first: - `list_bot_templates` shows ready-made roles and `get_bot_template` reads one. - If a role fits, call `suggest_bot` with its id in `template`, a name and why - the crew needs it; add `instructions` only for what is specific to this - crew, and do not invent a role the catalog already has. If none fits, call - `suggest_bot` with a name, a one-line role, instructions written to that - bot, a model and why it helps. The owner approves each new bot in your chat - and may change it (unless you bypass permissions). Then give it work with a - task. -- Pick the model by the work: `haiku` for simple, repetitive tasks, `sonnet` - for most work, `opus` or `fable` only for the hardest reasoning. Leave it out - to use the owner's plan default. Stronger models use up the owner's plan - faster. -- Keep the crew small: do small jobs yourself and prefer a bot that already - exists (`crew_roster`). -- When the owner asks you to rename or change another bot of the crew, call - `change_bot` with its handle in `bot`; the owner approves that too. -- Keep an eye on the cost: `crew_roster` shows each bot's model, effort and - `week_plan_percent`, about how much of the owner's weekly plan it used. - When a bot's work turns out simpler than its model or effort (or harder), - propose a change with `change_bot` (`model`, `effort`) and say why; the - owner approves it. Simple, repetitive work: `haiku` and `low`. -- Tell the owner what each bot is doing, so they can follow and talk to each - one in its own chat. -"; - /// `.claude/rules/botloft.md`: who the bot is and how to work with its crew. /// Regenerated whenever the bot or its crew changes. pub fn rules_md(crew: &Crew, bot: &BotRecord, work_folder: &Path) -> String { @@ -115,6 +81,7 @@ pub fn rules_md(crew: &Crew, bot: &BotRecord, work_folder: &Path) -> String { } else { "" }; + let plan = if bot.plan_first { PLAN_FIRST_MD } else { "" }; format!( " @@ -220,7 +187,8 @@ app; the other bots of your crew send you messages too. file, self-contained (inline CSS, or files next to it). For a screen that is not for a computer, put `` (or `tablet`) in its head. -{lead}", +{plan}{lead}", + plan = plan, name = bot.name, handle = bot.handle, crew = crew.name, @@ -314,6 +282,7 @@ mod tests { agent: botloft_core::protocol::AgentKind::Claude, agent_model: None, allowed_commands: Vec::new(), + plan_first: false, model: botloft_core::protocol::BotModel::Default, model_in_use: None, effort: botloft_core::protocol::BotEffort::Default, diff --git a/crates/botloftd/src/workspace/mod.rs b/crates/botloftd/src/workspace/mod.rs index c03bfc1..d43aad0 100644 --- a/crates/botloftd/src/workspace/mod.rs +++ b/crates/botloftd/src/workspace/mod.rs @@ -6,6 +6,7 @@ pub mod connected; mod files; pub mod folder; mod memory; +mod sections; use std::io; use std::path::{Path, PathBuf}; @@ -164,6 +165,7 @@ mod tests { agent: botloft_core::protocol::AgentKind::Claude, agent_model: None, allowed_commands: Vec::new(), + plan_first: false, model: botloft_core::protocol::BotModel::Default, model_in_use: None, effort: botloft_core::protocol::BotEffort::Default, diff --git a/crates/botloftd/src/workspace/sections.rs b/crates/botloftd/src/workspace/sections.rs new file mode 100644 index 0000000..a9a24b8 --- /dev/null +++ b/crates/botloftd/src/workspace/sections.rs @@ -0,0 +1,52 @@ +//! Parts of the bot's rules that only some bots get (spec 5.1). + +/// Added to the rules of the crew's chief (spec 10.2). +pub(super) const LEAD_MD: &str = " +## You lead this crew + +The owner made you the chief of this crew: its goal is in your instructions. + +- Plan the work, split it into pieces that can run at the same time, and hand + them out with `send_message` and `kind: \"task\"`. Each bot works in + parallel; results come back as messages. Check them before you report. +- When a piece needs a specialist the crew lacks, look at the bot catalog first: + `list_bot_templates` shows ready-made roles and `get_bot_template` reads one. + If a role fits, call `suggest_bot` with its id in `template`, a name and why + the crew needs it; add `instructions` only for what is specific to this + crew, and do not invent a role the catalog already has. If none fits, call + `suggest_bot` with a name, a one-line role, instructions written to that + bot, a model and why it helps. The owner approves each new bot in your chat + and may change it (unless you bypass permissions). Then give it work with a + task. +- Pick the model by the work: `haiku` for simple, repetitive tasks, `sonnet` + for most work, `opus` or `fable` only for the hardest reasoning. Leave it out + to use the owner's plan default. Stronger models use up the owner's plan + faster. +- Keep the crew small: do small jobs yourself and prefer a bot that already + exists (`crew_roster`). +- When the owner asks you to rename or change another bot of the crew, call + `change_bot` with its handle in `bot`; the owner approves that too. +- Keep an eye on the cost: `crew_roster` shows each bot's model, effort and + `week_plan_percent`, about how much of the owner's weekly plan it used. + When a bot's work turns out simpler than its model or effort (or harder), + propose a change with `change_bot` (`model`, `effort`) and say why; the + owner approves it. Simple, repetitive work: `haiku` and `low`. +- Tell the owner what each bot is doing, so they can follow and talk to each + one in its own chat. +"; + +/// Added when the owner wants the plan before a long task (spec 10.5). +pub(super) const PLAN_FIRST_MD: &str = " +## Show your plan first + +The owner wants to see what you will do before long work. + +- Before work that takes more than a minute or two (a build, research, + changes in many files), call `show_plan` before any other tool: a line + with what you understood, 2 to 8 short steps and, if there is a smaller + way, `alternative`. Write it in the owner's language, in plain words. +- The owner has a few seconds to change it; with no answer, start. Do what + the result says. +- Skip it for a quick answer, a question, a small change, a routine run, a + task from another bot, and work the owner already approved in this chat. +"; diff --git a/crates/botloftd/tests/agy_bot.rs b/crates/botloftd/tests/agy_bot.rs index 97a4663..f08752f 100644 --- a/crates/botloftd/tests/agy_bot.rs +++ b/crates/botloftd/tests/agy_bot.rs @@ -496,6 +496,7 @@ async fn the_commands_the_owner_allows_reach_the_rules_of_the_next_process() { "npm test".into(), "git status".into(), ]), + plan_first: None, }, ) .expect("update"); @@ -519,6 +520,7 @@ async fn the_commands_the_owner_allows_reach_the_rules_of_the_next_process() { instructions: None, color: None, allowed_commands: Some(vec!["a) b".into()]), + plan_first: None, }, ); assert!(refused.is_err()); diff --git a/crates/botloftd/tests/mcp.rs b/crates/botloftd/tests/mcp.rs index 7fac01a..ae06e0f 100644 --- a/crates/botloftd/tests/mcp.rs +++ b/crates/botloftd/tests/mcp.rs @@ -75,6 +75,7 @@ async fn claude_code_discovers_the_server_and_lists_the_tools() { "schedule_routine", "share_file", "ask_owner", + "show_plan", "send_signal", "my_routines", "change_routine", @@ -151,7 +152,7 @@ async fn earlier_revisions_start_with_initialize() { let listed = mcp::post(t.addr, &legacy, &list).await; assert_eq!( listed.body["result"]["tools"].as_array().map(Vec::len), - Some(41) + Some(42) ); let unknown = json!({ "jsonrpc": "2.0", "id": 2, "method": "prompts/list" }).to_string(); let unknown = mcp::post(t.addr, &legacy, &unknown).await; diff --git a/crates/botloftd/tests/plan_first.rs b/crates/botloftd/tests/plan_first.rs new file mode 100644 index 0000000..30b100e --- /dev/null +++ b/crates/botloftd/tests/plan_first.rs @@ -0,0 +1,211 @@ +//! `show_plan` (spec 10.5): with the option on, a bot shows its plan before +//! long work and the owner has a few seconds to start it now, pick the +//! smaller way, change it or cancel it. With no answer the bot starts. + +mod common; + +use botloft_core::protocol::BotState; +use common::bots::ready_bot; +use common::mcp::Mcp; +use common::{Client, TestDaemon}; +use serde_json::{Value, json}; +use tokio::task::JoinHandle; + +const TOOL: &str = "mcp__botloft__show_plan"; + +struct Planner { + t: TestDaemon, + app: Client, + bot: Value, + mcp: Mcp, +} + +/// "Ops" with @writer, who shows plans first when `on`. +async fn planner(on: bool) -> Planner { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let (mut bot, first, mut mcp) = ready_bot(&t, &mut app, &crew, "Writer").await; + if on { + bot = app + .call( + "bots.update", + json!({ "botId": bot["id"], "planFirst": true }), + ) + .await + .expect("update"); + // It starts again to read its new rules. + let old = first.env("BOTLOFT_BOT_TOKEN"); + let process = loop { + let process = t.process_of(&bot).await; + if process.env("BOTLOFT_BOT_TOKEN") != old { + break process; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + }; + t.until_state(&bot, BotState::Idle).await; + mcp = Mcp::new(t.addr, process.env("BOTLOFT_BOT_TOKEN").expect("token")); + } + Planner { t, app, bot, mcp } +} + +fn plan(alternative: Option<&str>) -> Value { + json!({ + "summary": "Fix the sign-in page", + "steps": ["Read the form", "Fix the check", "Run the tests"], + "alternative": alternative, + }) +} + +fn show(mcp: &Mcp, arguments: Value) -> JoinHandle> { + let mut mcp = Mcp::new(mcp.addr, mcp.token.clone()); + tokio::spawn(async move { mcp.tool("show_plan", arguments).await }) +} + +async fn pending(app: &mut Client) -> Value { + loop { + let changed = app.notification("chat.item").await; + let body = changed["item"]["body"].clone(); + if body["kind"] == "approval" && body["status"] == "pending" { + assert_eq!(body["toolName"], TOOL); + return body; + } + } +} + +async fn answer(app: &mut Client, asked: &Value, mut how: Value) { + how["approvalId"] = asked["approvalId"].clone(); + app.call("approvals.answer", how).await.expect("answer"); +} + +#[tokio::test] +async fn the_rules_ask_for_the_plan_only_when_the_owner_wants_it() { + let p = planner(true).await; + assert_eq!(p.bot["planFirst"], true); + let rules = std::fs::read_to_string( + std::path::Path::new(p.bot["workspace"].as_str().expect("workspace")) + .join(".claude/rules/botloft.md"), + ) + .expect("rules"); + assert!(rules.contains("## Show your plan first")); + drop(p.t); + + let p = planner(false).await; + assert_eq!(p.bot["planFirst"], false); + let rules = std::fs::read_to_string( + std::path::Path::new(p.bot["workspace"].as_str().expect("workspace")) + .join(".claude/rules/botloft.md"), + ) + .expect("rules"); + assert!(!rules.contains("show_plan")); +} + +#[tokio::test] +async fn with_the_option_off_the_bot_starts_at_once_without_a_card() { + let mut p = planner(false).await; + let result = show(&p.mcp, plan(None)).await.expect("task").expect("tool"); + assert_eq!(result["start"], "plan"); + let chat = p + .app + .call("chat.history", json!({ "botId": p.bot["id"] })) + .await + .expect("chat"); + let cards = chat + .as_array() + .expect("items") + .iter() + .filter(|item| item["body"]["kind"] == "approval") + .count(); + assert_eq!(cards, 0); +} + +#[tokio::test] +async fn start_now_goes_ahead_and_the_card_holds_the_plan() { + let mut p = planner(true).await; + let waiting = show(&p.mcp, plan(None)); + let asked = pending(&mut p.app).await; + assert_eq!(asked["summary"], "Fix the sign-in page"); + let input: Value = serde_json::from_str(asked["input"].as_str().expect("input")).expect("json"); + assert_eq!(input["steps"][2], "Run the tests"); + assert!(input["starts_at"].as_i64().is_some()); + // A short wait that starts by itself: nothing waits for the owner. + let id = p.bot["id"].as_str().expect("id").parse().expect("id"); + let state = p.t.daemon.supervisor.status(&id).map(|(state, _)| state); + assert_ne!(state, Some(BotState::NeedsApproval)); + + answer(&mut p.app, &asked, json!({ "allow": true })).await; + let result = waiting.await.expect("task").expect("tool"); + assert_eq!(result["start"], "plan"); +} + +#[tokio::test] +async fn the_owner_picks_the_alternative_changes_or_cancels_the_plan() { + let mut p = planner(true).await; + + let waiting = show(&p.mcp, plan(Some("Only list the problems"))); + let asked = pending(&mut p.app).await; + let pick = json!({ "allow": true, "input": r#"{"choice":"alternative"}"# }); + answer(&mut p.app, &asked, pick).await; + let result = waiting.await.expect("task").expect("tool"); + assert_eq!(result["start"], "alternative"); + + let waiting = show(&p.mcp, plan(None)); + let asked = pending(&mut p.app).await; + let wrong = p + .app + .call( + "approvals.answer", + json!({ + "approvalId": asked["approvalId"], + "allow": true, + "input": r#"{"choice":"alternative"}"#, + }), + ) + .await; + assert!(wrong.is_err(), "no alternative to pick"); + let change = json!({ "allow": false, "note": "Start with the tests" }); + answer(&mut p.app, &asked, change).await; + let result = waiting.await.expect("task").expect("tool"); + assert_eq!(result["start"], false); + assert_eq!(result["change"], "Start with the tests"); + + let waiting = show(&p.mcp, plan(None)); + let asked = pending(&mut p.app).await; + answer(&mut p.app, &asked, json!({ "allow": false })).await; + let result = waiting.await.expect("task").expect("tool"); + assert_eq!(result["start"], false); + assert!(result.get("change").is_none()); +} + +#[tokio::test] +async fn with_no_answer_the_bot_starts_by_itself() { + let mut p = planner(true).await; + let waiting = show(&p.mcp, plan(None)); + let asked = pending(&mut p.app).await; + let result = waiting.await.expect("task").expect("tool"); + assert_eq!(result["start"], "plan"); + loop { + let changed = p.app.notification("chat.item").await; + let body = &changed["item"]["body"]; + if body["approvalId"] == asked["approvalId"] && body["status"] != "pending" { + assert_eq!(body["status"], "expired"); + break; + } + } +} + +#[tokio::test] +async fn a_plan_without_steps_is_refused_before_the_owner_sees_it() { + let p = planner(true).await; + let err = show( + &p.mcp, + json!({ "summary": "Fix it", "steps": ["Only one"] }), + ) + .await + .expect("task") + .expect_err("refused"); + assert!(err.contains("steps"), "{err}"); +} diff --git a/docs/spec.md b/docs/spec.md index 62e475d..4c87c15 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -588,6 +588,7 @@ Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão | `share_file` | `files` (1 a 10 caminhos, absolutos ou relativos à pasta do bot) | `shown` (um `BotFile` por arquivo, 8.4) e um lembrete de que o dono vê cada um como cartão no chat; um arquivo fora das pastas do bot e não escrito por ele, ou que não existe, recusa a chamada inteira e diz qual e por quê | | `send_signal` | `name`, `note?` (até 2 000 caracteres) | o aviso normalizado e, para cada rotina da crew que o espera, se rodou e por que não (20.13) | | `ask_owner` | `question` (até 2 000 caracteres), `options?` (2 a 5, até 100 caracteres cada) | `question_id` e o lembrete de que a resposta chega depois, como mensagem; a tool não espera (23.2) | +| `show_plan` | `summary` (até 300 caracteres), `steps` (2 a 8, até 200 caracteres cada), `alternative?` (até 300) | `start` (`plan`, `alternative` ou `false`) e o que fazer; espera até 20 s pelo dono (10.5) | | `permission_prompt` | `tool_name`, `input`, `tool_use_id` | decisão do dono (10.1). Chamada pelo Claude Code, não pelo modelo | | `browser_*` | seção 21.4 | o navegador do bot: abrir, ler, clicar, digitar, rolar, ver a tela, pedir a mão do dono | @@ -665,6 +666,20 @@ Crews não se enxergam (7.5, 10). Quando o dono pede, um bot pode pedir para alc - **No app:** o cartão diz " quer acessar a equipe " ou " quer acessar , da equipe ", o que o bot quer lá ("Conversar com ...: ver quem está lá e mandar mensagens e tarefas", "Ler os arquivos de ... e da pasta de trabalho", "Mudar e criar arquivos de ...") e o porquê, com Só agora, Sempre (só num pedido de um bot), Sempre a equipe e Negar, e o campo para dizer por que não. A resposta guarda, no lugar da entrada, o pedido com `scope`, e o cartão respondido diz por quanto tempo. Nos detalhes do bot, "Outras equipes" lista o que ele alcança sempre (`crewAccess.list`, com `talk`, `read` e `edit`) e o que pode fazer lá, com um botão para tirar cada um (`crewAccess.revoke`); a lista é lida de novo quando um pedido desses é respondido. IDs com o prefixo `cxa_`. - **Onde fica:** a mensagem e a task entre crews são da crew de quem recebe, e o resultado e o aviso de prazo, da crew de quem pediu. Assim cada crew só guarda o que é para os bots dela, e apagar uma crew não deixa nada pendurado. `my_tasks` mostra um bot de outra crew como "writer of crew Blog". +### 10.5 Plano antes de tarefa longa + +Um bot que entendeu errado o pedido gasta minutos e plano antes de o dono notar. Com "Mostrar o plano antes de tarefas longas" ligado nos detalhes do bot, ele diz o que vai fazer antes de começar, e o dono tem alguns segundos para mudar ou cancelar. Não é o modo Plano (10.1): lá o bot só planeja e espera o OK, sem prazo; aqui ele já trabalha no modo dele e começa sozinho se ninguém mexer. + +- **A tool:** `show_plan {summary, steps, alternative?}`. `summary` é uma linha com o que o bot entendeu, `steps` são de 2 a 8 passos curtos, e `alternative` é, se houver, um jeito menor de fazer ("só listar o que está errado, sem mudar nada"). Tudo para o dono, na língua dele. Campo inválido volta como erro, sem cartão. +- **Quando não espera:** com a opção desligada, ou com o bot no modo `plan`, a tool volta na hora com `start: "plan"`, sem cartão, e diz por quê. Isso cobre um bot que chame a tool sem a regra. +- **Espera:** o cartão vai pelo caminho das aprovações (10.1), com `toolName: "mcp__botloft__show_plan"` e, na entrada, os campos e `starts_at` (Unix ms), a hora em que começa sozinho, 20 s depois. A chamada espera até lá. Como a espera é curta e começa sozinha, o bot **não** passa para `needs_approval`: nada pisca nem avisa no Windows. +- **Respostas** (`approvals.answer`): permitir é "Começar agora"; permitir com `input` `{"choice":"alternative"}` é "Fazer só isto" (só com `alternative`); negar com nota é "Mudar"; negar sem nota é "Cancelar". Sem resposta até `starts_at`, a aprovação vira `expired`, que neste cartão quer dizer "começou sozinho". +- **O que o bot lê:** `start: "plan"` para seguir (também quando ninguém respondeu); `start: "alternative"` para fazer só a alternativa; `start: false` com `change`, a nota do dono, para não começar o plano e fazer o que ele disse (mostrando um plano novo se o trabalho ainda for longo); `start: false` sem nota para não começar e perguntar numa frase o que o dono quer. +- **Regras do bot** (5.1), só com a opção ligada, numa seção "Show your plan first": antes de um trabalho de mais de um ou dois minutos (um build, uma pesquisa, mudanças em muitos arquivos), chamar `show_plan` antes das outras ferramentas. Não chamar para resposta rápida, pergunta, mudança pequena, rotina, task de outro bot ou trabalho que o dono já aprovou nesta conversa. Ligar ou desligar regrava as regras e reinicia o bot quando nada estiver em andamento (7.4). +- **Dados:** `bots.plan_first` (0 ou 1, desligado por padrão), em `Bot.planFirst` e em `bots.update {planFirst}`. +- **No app:** o cartão diz " vai começar assim", com o resumo, os passos numerados e a alternativa, se houver. Embaixo, "Começa sozinho em N s", que conta até zero, e Começar agora, Fazer só isto, Mudar (abre o campo da nota) e Cancelar. Respondido, vira uma linha: "Começou", "Começou sozinho", "Fez só a alternativa", "Pediu mudanças" (com a nota) ou "Cancelado", com o plano a um clique. O celular (28) mostra o resumo, os passos e a alternativa num texto só, e pode começar agora, mudar ou cancelar. +- **Fora desta etapa:** pausar a contagem para ler com calma e escolher o tempo de espera. + ## 11. Protocolo do app (JSON-RPC 2.0 sobre WebSocket) Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests com `id`, respostas com `result` ou `error`, notificações do servidor sem `id`. O maior frame aceito do app é de 32 MiB, por causa dos anexos. @@ -2446,7 +2461,7 @@ Notificações: `mobile.changed {status}` e `mobile.pair_request {pairId, name, 1. **Só permite o que mostra inteiro.** Um pedido cujo texto passa de 8 KiB, ou que o daemon guardou cortado (a entrada não é um JSON inteiro), vai ao celular **cortado e marcado** (`cut`), com só "Negar" e "Abra no computador". Ninguém aprova no escuro. O texto de um comando é o `command`, o de um plano é o `plan`, o das outras ferramentas é a entrada como o daemon a guardou. 2. **Só "uma vez".** O celular não grava "permitir sempre" (`always`) e não edita a entrada (`input`). A regra durável é decisão de quem está no computador. -3. **O que muda o computador de forma durável fica no computador.** O celular só permite `Bash`, `PowerShell`, `WebFetch`, `WebSearch`, `Read`, `Write`, `Edit`, `MultiEdit`, `NotebookEdit` e o plano (`ExitPlanMode`); tudo o mais (sugestão de bot, 10.2; rotina pedida por bot, 20.12; acesso a outra crew, 10.4; permissão de desktop, 24.2; ferramentas conectadas, 25.4; o navegador em mãos, 21.10; e o que vier a existir) aparece como "Precisa de você no computador" (`atComputer`), com "Negar" quando couber. O daemon confere de novo quando chega a resposta: um "Permitir" para o que é só do computador, ou cortado, não faz nada e o celular recebe o cartão de novo. O celular nunca recebe `browser_ask_owner` nem nada que peça senha ou código (23.2). +3. **O que muda o computador de forma durável fica no computador.** O celular só permite `Bash`, `PowerShell`, `WebFetch`, `WebSearch`, `Read`, `Write`, `Edit`, `MultiEdit`, `NotebookEdit`, o plano (`ExitPlanMode`) e o plano antes de tarefa longa (`show_plan`, 10.5); tudo o mais (sugestão de bot, 10.2; rotina pedida por bot, 20.12; acesso a outra crew, 10.4; permissão de desktop, 24.2; ferramentas conectadas, 25.4; o navegador em mãos, 21.10; e o que vier a existir) aparece como "Precisa de você no computador" (`atComputer`), com "Negar" quando couber. O daemon confere de novo quando chega a resposta: um "Permitir" para o que é só do computador, ou cortado, não faz nada e o celular recebe o cartão de novo. O celular nunca recebe `browser_ask_owner` nem nada que peça senha ou código (23.2). 4. **O mesmo caminho do app.** `approval.answer` chama a mesma lógica de `approvals.answer` (10.1), e `question.answer` e `question.dismiss` as de `questions.*` (23.3): `conflict` se já foi respondido, `expired` se venceu. Responder no celular fecha o cartão no computador e vice-versa, nos dois sentidos, na hora (`approval.closed`, `question.closed`). 5. **Rate limit** no daemon: 30 respostas por minuto por celular. 6. **Nunca se loga** o selo, o `card` nem a resposta em `info` (13). From 7d2c6919ad836a06d1db0c649ac8bdce1512d35e Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:06:50 -0300 Subject: [PATCH 3/4] feat(chat): the plan card that starts by itself, and its switch - StartPlanCard shows the summary, the numbered steps and the smaller way, counts down to the start, and offers Start now, Do only this, Change and Cancel. Answered, it is one line with the plan a click away. - The show_plan tool line is hidden like ask_owner's: the card says it. - The sidebar reads "Plan: ..." instead of "Waiting for approval". - The agent's edit dialog has "Show the plan before long tasks". - Texts in English, Portuguese and Spanish; a plan seeded for pnpm dev. --- app/src/dev/preview.ts | 2 + app/src/dev/seedPlan.ts | 26 +++ app/src/features/bots/BotDialog.tsx | 10 + app/src/features/chat/ApprovalCard.tsx | 3 + app/src/features/chat/StartPlanCard.tsx | 197 ++++++++++++++++++ app/src/features/chat/ToolLines.tsx | 1 + app/src/features/chat/rows.ts | 13 +- app/src/features/chat/startPlan.test.tsx | 109 ++++++++++ .../features/crews/SidebarConversation.tsx | 5 + app/src/i18n/en/bots.ts | 5 + app/src/i18n/en/chat.ts | 3 + app/src/i18n/en/crews.ts | 1 + app/src/i18n/en/startPlan.ts | 21 ++ app/src/i18n/en/tools.ts | 1 + app/src/i18n/es/bots.ts | 5 + app/src/i18n/es/chat.ts | 2 + app/src/i18n/es/crews.ts | 1 + app/src/i18n/es/startPlan.ts | 23 ++ app/src/i18n/es/tools.ts | 1 + app/src/i18n/pt-BR/bots.ts | 5 + app/src/i18n/pt-BR/chat.ts | 2 + app/src/i18n/pt-BR/crews.ts | 1 + app/src/i18n/pt-BR/startPlan.ts | 23 ++ app/src/i18n/pt-BR/tools.ts | 1 + app/src/lib/fakeBots.ts | 4 +- app/src/lib/fakeChat.ts | 4 + 26 files changed, 464 insertions(+), 5 deletions(-) create mode 100644 app/src/dev/seedPlan.ts create mode 100644 app/src/features/chat/StartPlanCard.tsx create mode 100644 app/src/features/chat/startPlan.test.tsx create mode 100644 app/src/i18n/en/startPlan.ts create mode 100644 app/src/i18n/es/startPlan.ts create mode 100644 app/src/i18n/pt-BR/startPlan.ts diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts index 322df0a..e2ddc50 100644 --- a/app/src/dev/preview.ts +++ b/app/src/dev/preview.ts @@ -18,6 +18,7 @@ import { seedContext } from "./seedContext"; import { seedDesktop } from "./seedDesktop"; import { seedHands } from "./seedHands"; import { activeTab, drawPage } from "./seedPage"; +import { seedPlan } from "./seedPlan"; import { seedQuestions } from "./seedQuestions"; import { seedRoutines } from "./seedRoutines"; import { seedScreens } from "./seedScreens"; @@ -127,6 +128,7 @@ function seed(fake: FakeBotloft): void { }); seedRoutines(fake, { scout: scout.id, analyst: analyst.id }); seedQuestions(fake, { scout: scout.id, analyst: analyst.id }); + seedPlan(fake, writer.id); seedBrowser(fake, scout.id); // Analyst hit the limit in the middle of a search: its browser rests. fake.browser.open(analyst.id, "https://stats.example/flour-prices", "Flour prices · Stats"); diff --git a/app/src/dev/seedPlan.ts b/app/src/dev/seedPlan.ts new file mode 100644 index 0000000..6ff7d2f --- /dev/null +++ b/app/src/dev/seedPlan.ts @@ -0,0 +1,26 @@ +// Dev only: a bot that shows its plan before long work (spec 10.5). + +import type { FakeBotloft } from "../lib/fake"; +import type { BotId } from "../lib/protocol.gen"; + +export function seedPlan(fake: FakeBotloft, bot: BotId): void { + fake.bot(bot).planFirst = true; + void fake.call("messages.send", { + botId: bot, + body: "Rewrite the whole sourdough guide for beginners, with the new photos.", + }); + fake.chat.tool(bot, "mcp__botloft__show_plan", { status: "done" }); + const plan = { + summary: "A beginner's sourdough guide, rewritten from the start, with the new photos", + steps: [ + "Read the current guide and the notes in the work folder", + "Write a shorter guide in five parts, from starter to first loaf", + "Put the new photos next to the steps they show", + "Ask Revisão to check facts and tone", + ], + alternative: "Only fix the hardest part, feeding the starter, and leave the rest", + // Long enough to look at it in the preview. + starts_at: Date.now() + 90_000, + }; + fake.chat.ask(bot, "mcp__botloft__show_plan", plan.summary, JSON.stringify(plan)); +} diff --git a/app/src/features/bots/BotDialog.tsx b/app/src/features/bots/BotDialog.tsx index 6c9f3bd..e4f02fb 100644 --- a/app/src/features/bots/BotDialog.tsx +++ b/app/src/features/bots/BotDialog.tsx @@ -40,6 +40,7 @@ export function BotDialog(props: Props) { const [model, setModel] = useState(editing?.model ?? "default"); // One command prefix per line, for a bot whose agent cannot ask (spec 30). const [commands, setCommands] = useState((editing?.allowedCommands ?? []).join("\n")); + const [planFirst, setPlanFirst] = useState(editing?.planFirst ?? false); // Which agent runs the bot is chosen once, when it is made (spec 30). const agents = useApp((state) => state.system?.enabledAgents ?? CLAUDE_ONLY); const preferred = useApp((state) => state.settings?.defaultAgent ?? "claude"); @@ -65,6 +66,7 @@ export function BotDialog(props: Props) { ...(role !== editing.role && { role }), ...(instructions !== editing.instructions && { instructions }), ...(color !== undefined && color !== editing.color && { color }), + ...(planFirst !== editing.planFirst && { planFirst }), ...(editing.agent !== "claude" && commands !== editing.allowedCommands.join("\n") && { allowedCommands: commands.split("\n"), @@ -184,6 +186,14 @@ export function BotDialog(props: Props) { hint={t.chat.model.cost} /> )} + {editing && ( + + )}
{t.bots.dialog.color} diff --git a/app/src/features/chat/ApprovalCard.tsx b/app/src/features/chat/ApprovalCard.tsx index 47f7158..ea2c296 100644 --- a/app/src/features/chat/ApprovalCard.tsx +++ b/app/src/features/chat/ApprovalCard.tsx @@ -21,6 +21,7 @@ import { BotChangeCard, CHANGE_BOT_TOOL } from "./BotChangeCard"; import { CREW_ACCESS_TOOL, CrewAccessCard } from "./CrewAccessCard"; import { commandOf, isCommand } from "./command"; import { PLAN_TOOL, PlanCard } from "./PlanCard"; +import { START_PLAN_TOOL, StartPlanCard } from "./StartPlanCard"; import { SUGGEST_TOOL, SuggestionCard } from "./SuggestionCard"; import { pretty } from "./ToolLines"; import { toolAction } from "./toolNames"; @@ -67,6 +68,8 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B switch (approval.toolName) { case PLAN_TOOL: return ; + case START_PLAN_TOOL: + return ; case SUGGEST_TOOL: return ; case ROUTINE_TOOL: diff --git a/app/src/features/chat/StartPlanCard.tsx b/app/src/features/chat/StartPlanCard.tsx new file mode 100644 index 0000000..5defe1e --- /dev/null +++ b/app/src/features/chat/StartPlanCard.tsx @@ -0,0 +1,197 @@ +// The plan a bot shows before long work (spec 10.5): it starts by itself +// after a few seconds unless the owner starts it now, picks the smaller +// way, changes it or cancels it. + +import { Ban, Check, ListChecks, PencilLine, Play, Shrink, TimerReset } from "lucide-react"; +import { useEffect, useState } from "react"; +import { useT } from "../../i18n"; +import type { ApprovalItem, Bot } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { NoteArea, SectionCard, SettledLine } from "../../ui/ChatCard"; +import { attempt } from "../../ui/toast"; + +export const START_PLAN_TOOL = "mcp__botloft__show_plan"; + +export interface StartPlan { + summary: string; + steps: string[]; + alternative: string | null; + /** When it starts by itself, in Unix ms. */ + startsAt: number; + /** Set once the owner picked the smaller way. */ + choice: "alternative" | null; +} + +/** The plan in the request's input; `null` if it was cut or is not one. */ +export function startPlanOf(input: string): StartPlan | null { + try { + const parsed = JSON.parse(input) as Record; + const steps = parsed.steps; + if (typeof parsed.summary !== "string" || !Array.isArray(steps)) { + return null; + } + return { + summary: parsed.summary, + steps: steps.filter((step): step is string => typeof step === "string"), + alternative: typeof parsed.alternative === "string" ? parsed.alternative : null, + startsAt: typeof parsed.starts_at === "number" ? parsed.starts_at : 0, + choice: parsed.choice === "alternative" ? "alternative" : null, + }; + } catch { + return null; + } +} + +/** Whole seconds until `at`, counted down every second. */ +function useSecondsUntil(at: number): number { + const left = () => Math.max(0, Math.ceil((at - Date.now()) / 1000)); + const [seconds, setSeconds] = useState(left); + useEffect(() => { + if (seconds === 0) { + return; + } + const timer = window.setTimeout(() => setSeconds(left()), 1000); + return () => window.clearTimeout(timer); + }); + return seconds; +} + +function Plan({ plan }: { plan: StartPlan }) { + const p = useT().chat.startPlan; + return ( +
+

{plan.summary}

+
    + {plan.steps.map((step, index) => ( + // Steps can repeat; their place is what tells them apart. + // biome-ignore lint/suspicious/noArrayIndexKey: fixed list +
  1. {step}
  2. + ))} +
+ {plan.alternative && ( +

+ {p.alternative}: + {plan.alternative} +

+ )} +
+ ); +} + +export function StartPlanCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { + const p = useT().chat.startPlan; + const api = useApi(); + const plan = startPlanOf(approval.input); + const seconds = useSecondsUntil(plan?.startsAt ?? 0); + const [changing, setChanging] = useState(false); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + if (!plan) { + return null; + } + if (approval.status !== "pending") { + return ; + } + + const answer = async (allow: boolean, extra: { note?: string; input?: string } = {}) => { + setBusy(true); + await attempt(p.failed, () => + api.call("approvals.answer", { approvalId: approval.approvalId, allow, ...extra }), + ); + setBusy(false); + }; + const change = () => { + const trimmed = note.trim(); + if (trimmed) { + void answer(false, { note: trimmed }); + } + }; + + return ( + + {changing && ( +
+ +
+ )} +
+ {changing ? ( + + ) : ( + <> + + {plan.alternative && ( + + )} + + + )} + + {!changing && ( + + {seconds > 0 ? p.startsIn(seconds) : p.starting} + + )} +
+ + } + > + +
+ ); +} + +/** One line once settled; the plan stays a click away. */ +function Answered({ approval, plan }: { approval: ApprovalItem; plan: StartPlan }) { + const p = useT().chat.startPlan; + const [icon, text, tone] = + approval.status === "allowed" + ? plan.choice === "alternative" + ? ([Shrink, p.didAlternative, "ok"] as const) + : ([Check, p.started, "ok"] as const) + : approval.status === "denied" + ? approval.note + ? ([PencilLine, p.changed, "work"] as const) + : ([Ban, p.cancelled, "quiet"] as const) + : ([TimerReset, p.startedAlone, "ok"] as const); + return ( + } + /> + ); +} diff --git a/app/src/features/chat/ToolLines.tsx b/app/src/features/chat/ToolLines.tsx index 90fbc5c..890dd90 100644 --- a/app/src/features/chat/ToolLines.tsx +++ b/app/src/features/chat/ToolLines.tsx @@ -56,6 +56,7 @@ const ICONS: Record = { WebSearch: Globe, TodoWrite: ListChecks, ExitPlanMode: ListTodo, + show_plan: ListChecks, Task: Users, Agent: Users, send_message: Send, diff --git a/app/src/features/chat/rows.ts b/app/src/features/chat/rows.ts index cff2373..322088e 100644 --- a/app/src/features/chat/rows.ts +++ b/app/src/features/chat/rows.ts @@ -13,12 +13,17 @@ export type Row = | { kind: "run"; key: string; items: ChatItem[] } | { kind: "notice"; key: string; item: ChatItem; notice: NoticeItem }; -/** The bot asking the owner (spec 23.2): its question card says it all. */ -const ASK_TOOL = "mcp__botloft__ask_owner"; +/** + * Tools whose card says it all: the bot asking the owner (spec 23.2) and + * the plan it shows before long work (spec 10.5). + */ +const CARD_TOOLS = new Set(["mcp__botloft__ask_owner", "mcp__botloft__show_plan"]); -/** A call to ask the owner that worked; the card stands for it. */ +/** A call that worked and has its own card, which stands for it. */ function isAskLine(item: ChatItem): boolean { - return item.body.kind === "tool" && item.body.name === ASK_TOOL && item.body.status !== "failed"; + return ( + item.body.kind === "tool" && CARD_TOOLS.has(item.body.name) && item.body.status !== "failed" + ); } function dayOf(ms: number): string { diff --git a/app/src/features/chat/startPlan.test.tsx b/app/src/features/chat/startPlan.test.tsx new file mode 100644 index 0000000..a003d68 --- /dev/null +++ b/app/src/features/chat/startPlan.test.tsx @@ -0,0 +1,109 @@ +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp, sidebar } from "../../test/app"; +import { START_PLAN_TOOL } from "./StartPlanCard"; + +afterEach(cleanup); + +const PLAN = { + summary: "Rewrite the sourdough guide", + steps: ["Read the guide", "Write five parts", "Add the photos"], + alternative: "Only fix the starter part", +}; + +async function openWriter() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const writer = fake.addBot(ops.id, "Writer", "Writes posts"); + fake.setBotState(writer.id, "busy"); + renderApp(fake); + await crewOpened("Ops"); + openBot("Writer"); + await screen.findByRole("list", { name: "Messages" }); + return { fake, writer }; +} + +function show(fake: FakeBotloft, bot: string, plan: object, startsIn = 20_000) { + act(() => { + fake.chat.ask( + bot, + START_PLAN_TOOL, + PLAN.summary, + JSON.stringify({ ...plan, starts_at: Date.now() + startsIn }), + ); + }); + return screen.getByRole("region", { name: "Writer will start like this" }); +} + +const answered = (fake: FakeBotloft) => + fake.calls.find((call) => call.method === "approvals.answer")?.params as + | { allow: boolean; note?: string; input?: string } + | undefined; + +describe("the plan an agent shows before long work", () => { + test("shows the steps, the smaller way and when it starts, and starts now", async () => { + const { fake, writer } = await openWriter(); + const card = show(fake, writer.id, PLAN); + expect(within(card).getByText("Rewrite the sourdough guide")).toBeDefined(); + expect(within(card).getAllByRole("listitem")).toHaveLength(3); + expect(within(card).getByText("Only fix the starter part")).toBeDefined(); + expect(within(card).getByText(/Starts by itself in (19|20) s/)).toBeDefined(); + fireEvent.click(within(card).getByRole("button", { name: "Start now" })); + await waitFor(() => expect(answered(fake)).toMatchObject({ allow: true })); + expect(answered(fake)?.input).toBeUndefined(); + expect(await screen.findByText("You said start")).toBeDefined(); + }); + + test("the smaller way is picked and kept with the plan", async () => { + const { fake, writer } = await openWriter(); + const card = show(fake, writer.id, PLAN); + fireEvent.click(within(card).getByRole("button", { name: "Do only this" })); + await waitFor(() => + expect(answered(fake)).toMatchObject({ allow: true, input: '{"choice":"alternative"}' }), + ); + expect(await screen.findByText("Doing only the smaller way")).toBeDefined(); + }); + + test("a change goes with the owner's words, and a plan without a smaller way has no such button", async () => { + const { fake, writer } = await openWriter(); + const card = show(fake, writer.id, { ...PLAN, alternative: null }); + expect(within(card).queryByRole("button", { name: "Do only this" })).toBeNull(); + fireEvent.click(within(card).getByRole("button", { name: "Change" })); + const send = within(card).getByRole("button", { name: "Send" }); + expect(send).toHaveProperty("disabled", true); + fireEvent.change(within(card).getByLabelText("What Writer should do instead"), { + target: { value: "Start with the photos" }, + }); + fireEvent.click(send); + await waitFor(() => + expect(answered(fake)).toMatchObject({ allow: false, note: "Start with the photos" }), + ); + expect(await screen.findByText("You asked for changes")).toBeDefined(); + }); + + test("cancel sends no note, and a plan past its time says it is starting", async () => { + const { fake, writer } = await openWriter(); + const card = show(fake, writer.id, PLAN, -1000); + expect(within(card).getByText("Starting…")).toBeDefined(); + fireEvent.click(within(card).getByRole("button", { name: "Cancel" })); + await waitFor(() => expect(answered(fake)).toMatchObject({ allow: false })); + expect(answered(fake)?.note).toBeUndefined(); + expect(await screen.findByText("You cancelled it")).toBeDefined(); + }); + + test("the option is a switch in the agent's edit dialog", async () => { + const { fake, writer } = await openWriter(); + expect(fake.bot(writer.id).planFirst).toBe(false); + fireEvent.contextMenu(within(sidebar()).getByRole("button", { name: /^Writer,/ }), { + clientX: 120, + clientY: 200, + }); + const menu = await screen.findByRole("menu", { name: "Actions for Writer" }); + fireEvent.click(within(menu).getByRole("menuitem", { name: "Edit" })); + const dialog = within(await screen.findByRole("dialog", { name: /Edit Writer/ })); + fireEvent.click(dialog.getByRole("switch", { name: "Show the plan before long tasks" })); + fireEvent.click(dialog.getByRole("button", { name: "Save" })); + await waitFor(() => expect(fake.bot(writer.id).planFirst).toBe(true)); + }); +}); diff --git a/app/src/features/crews/SidebarConversation.tsx b/app/src/features/crews/SidebarConversation.tsx index 9bf084f..89a9511 100644 --- a/app/src/features/crews/SidebarConversation.tsx +++ b/app/src/features/crews/SidebarConversation.tsx @@ -13,6 +13,7 @@ import { BotStateBadge, stateView } from "../bots/BotStateBadge"; import { useBotActions } from "../bots/botActions"; import { ChiefBadge, isChief } from "../bots/ChiefBadge"; import { ListAvatar } from "../bots/ListAvatar"; +import { START_PLAN_TOOL } from "../chat/StartPlanCard"; import { toolAction, toolTitle } from "../chat/toolNames"; import { row } from "./SidebarPages"; @@ -95,6 +96,10 @@ function activityText(activity: Activity, t: Messages): string { case "owner": return t.crews.sidebar.fromOwner(plainText(text)); case "approval": + // A plan before long work starts by itself: nothing waits for the owner. + if (tool === START_PLAN_TOOL) { + return t.crews.sidebar.showedPlan(plainText(text)); + } return t.crews.sidebar.awaitingApproval(tool ? toolAction(tool, t.tools) : text); case "question": return t.questions.activity(plainText(text)); diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts index 9d352ce..a6360f9 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -98,6 +98,11 @@ export const bots = { instructionsPlaceholder: "How this agent works, what it may do on its own and when to ask.", instructionsHint: "Saved to the agent's rules now; the agent reads them the next time it starts.", + planFirst: { + title: "Show the plan before long tasks", + hint: (bot: string) => + `${bot} says what it will do before long work and waits 20 seconds, so you can change or cancel it.`, + }, fullAccess: { title: "Run any command without asking", off: "Off: it may run only the commands listed below.", diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index d223f85..268fabb 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -2,6 +2,8 @@ // files and the composer. Bot names, tool names and what the bots write // are data and never pass through here. +import { startPlan } from "./startPlan"; + /** A turn's tokens, already written as numbers. */ export interface TurnTokens { read: string; @@ -378,6 +380,7 @@ export const chat = { declined: (name: string) => `You kept ${name} as it was`, expired: (name: string) => `The change to ${name} was not answered in time`, }, + startPlan, plan: { ready: (bot: string) => `${bot} made a plan and wants to go ahead`, noteLabel: (bot: string) => `What ${bot} should change in the plan`, diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts index e580edb..be0f5c1 100644 --- a/app/src/i18n/en/crews.ts +++ b/app/src/i18n/en/crews.ts @@ -21,6 +21,7 @@ export const crews = { fromOwner: (text: string) => `You: ${text}`, /** The conversation-list line for an approval the bot is waiting on. */ awaitingApproval: (text: string) => `Waiting for approval: ${text}`, + showedPlan: (text: string) => `Plan: ${text}`, unread: "unread", /** Beside a crew's name: how many of its bots are unread. */ unreadCount: (count: number) => `${count} unread`, diff --git a/app/src/i18n/en/startPlan.ts b/app/src/i18n/en/startPlan.ts new file mode 100644 index 0000000..55faab6 --- /dev/null +++ b/app/src/i18n/en/startPlan.ts @@ -0,0 +1,21 @@ +// The plan a bot shows before long work (spec 10.5). + +export const startPlan = { + ready: (bot: string) => `${bot} will start like this`, + alternative: "Or a smaller way", + startsIn: (seconds: number) => `Starts by itself in ${seconds} s`, + starting: "Starting…", + startNow: "Start now", + onlyThis: "Do only this", + change: "Change", + sendChange: "Send", + noteLabel: (bot: string) => `What ${bot} should do instead`, + notePlaceholder: "What should change?", + cancel: "Cancel", + failed: "Could not answer the plan", + started: "You said start", + startedAlone: "Started by itself", + didAlternative: "Doing only the smaller way", + changed: "You asked for changes", + cancelled: "You cancelled it", +}; diff --git a/app/src/i18n/en/tools.ts b/app/src/i18n/en/tools.ts index e5c6847..c0fee28 100644 --- a/app/src/i18n/en/tools.ts +++ b/app/src/i18n/en/tools.ts @@ -40,6 +40,7 @@ export const tools = { ask_crew_access: "Reach another crew", share_file: "Share a file", ask_owner: "Ask you a question", + show_plan: "Show you the plan", browser: "Use a site", browser_help: "Ask for your hand", browser_open: "Open a page", diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts index b65e55d..8168598 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -95,6 +95,11 @@ export const bots: Messages["bots"] = { "Cómo trabaja este agente, qué puede hacer por su cuenta y cuándo debe preguntar.", instructionsHint: "Se guardan ahora en las reglas del agente; el agente las lee la próxima vez que se inicie.", + planFirst: { + title: "Mostrar el plan antes de tareas largas", + hint: (bot: string) => + `${bot} dice lo que va a hacer antes de un trabajo largo y espera 20 segundos, para que lo cambies o lo canceles.`, + }, fullAccess: { title: "Ejecutar cualquier comando sin preguntar", off: "Desactivado: solo ejecuta los comandos de la lista de abajo.", diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index 38a3340..870279e 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -1,5 +1,6 @@ import type { Messages } from "../en"; import type { TurnTokens } from "../en/chat"; +import { startPlan } from "./startPlan"; export const chat: Messages["chat"] = { view: { @@ -364,6 +365,7 @@ export const chat: Messages["chat"] = { declined: (name) => `Dejaste a ${name} como estaba`, expired: (name) => `El cambio de ${name} quedó sin respuesta`, }, + startPlan, plan: { ready: (bot: string) => `${bot} hizo un plan y quiere seguir con él`, noteLabel: (bot: string) => `Qué debe cambiar ${bot} en el plan`, diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts index dbda7ea..775f89f 100644 --- a/app/src/i18n/es/crews.ts +++ b/app/src/i18n/es/crews.ts @@ -17,6 +17,7 @@ export const crews: Messages["crews"] = { noMessages: "Aún no hay mensajes", fromOwner: (text: string) => `Tú: ${text}`, awaitingApproval: (text: string) => `Esperando aprobación: ${text}`, + showedPlan: (text: string) => `Plan: ${text}`, unread: "sin leer", unreadCount: (count: number) => `${count} sin leer`, moveUp: "Mover arriba", diff --git a/app/src/i18n/es/startPlan.ts b/app/src/i18n/es/startPlan.ts new file mode 100644 index 0000000..2b5f5f3 --- /dev/null +++ b/app/src/i18n/es/startPlan.ts @@ -0,0 +1,23 @@ +// El plan que un agente muestra antes de un trabajo largo (spec 10.5). + +import type { Messages } from "../en"; + +export const startPlan: Messages["chat"]["startPlan"] = { + ready: (bot: string) => `${bot} va a empezar así`, + alternative: "O una forma más pequeña", + startsIn: (seconds: number) => `Empieza solo en ${seconds} s`, + starting: "Empezando…", + startNow: "Empezar ahora", + onlyThis: "Hacer solo esto", + change: "Cambiar", + sendChange: "Enviar", + noteLabel: (bot: string) => `Qué debe hacer ${bot} en su lugar`, + notePlaceholder: "¿Qué debe cambiar?", + cancel: "Cancelar", + failed: "No se pudo responder al plan", + started: "Dijiste que empiece", + startedAlone: "Empezó solo", + didAlternative: "Haciendo solo la forma más pequeña", + changed: "Pediste cambios", + cancelled: "Lo cancelaste", +}; diff --git a/app/src/i18n/es/tools.ts b/app/src/i18n/es/tools.ts index 6945cfe..81e5bf6 100644 --- a/app/src/i18n/es/tools.ts +++ b/app/src/i18n/es/tools.ts @@ -41,6 +41,7 @@ export const tools: Messages["tools"] = { ask_crew_access: "Acceder a otro equipo", share_file: "Compartir un archivo", ask_owner: "Hacerte una pregunta", + show_plan: "Mostrarte el plan", browser: "Usar un sitio", browser_help: "Pedir tu ayuda", browser_open: "Abrir una página", diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts index 968a591..3ee3491 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -94,6 +94,11 @@ export const bots: Messages["bots"] = { instructionsPlaceholder: "Como este agente trabalha, o que ele pode fazer sozinho e quando deve perguntar.", instructionsHint: "Salvas agora nas regras do agente; ele as lê na próxima vez que iniciar.", + planFirst: { + title: "Mostrar o plano antes de tarefas longas", + hint: (bot: string) => + `${bot} diz o que vai fazer antes de um trabalho longo e espera 20 segundos, para você mudar ou cancelar.`, + }, fullAccess: { title: "Rodar qualquer comando sem perguntar", off: "Desligado: ele só roda os comandos da lista abaixo.", diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index 8842c42..2bf456d 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -1,5 +1,6 @@ import type { Messages } from "../en"; import type { TurnTokens } from "../en/chat"; +import { startPlan } from "./startPlan"; export const chat: Messages["chat"] = { view: { @@ -360,6 +361,7 @@ export const chat: Messages["chat"] = { declined: (name) => `Você manteve ${name} como estava`, expired: (name) => `A mudança em ${name} ficou sem resposta`, }, + startPlan, plan: { ready: (bot: string) => `${bot} fez um plano e quer seguir com ele`, noteLabel: (bot: string) => `O que ${bot} deve mudar no plano`, diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts index 98d9393..fc77413 100644 --- a/app/src/i18n/pt-BR/crews.ts +++ b/app/src/i18n/pt-BR/crews.ts @@ -17,6 +17,7 @@ export const crews: Messages["crews"] = { noMessages: "Nenhuma mensagem ainda", fromOwner: (text: string) => `Você: ${text}`, awaitingApproval: (text: string) => `Aguardando aprovação: ${text}`, + showedPlan: (text: string) => `Plano: ${text}`, unread: "não lida", unreadCount: (count: number) => (count === 1 ? "1 não lida" : `${count} não lidas`), moveUp: "Mover para cima", diff --git a/app/src/i18n/pt-BR/startPlan.ts b/app/src/i18n/pt-BR/startPlan.ts new file mode 100644 index 0000000..badeb48 --- /dev/null +++ b/app/src/i18n/pt-BR/startPlan.ts @@ -0,0 +1,23 @@ +// O plano que um agente mostra antes de um trabalho longo (spec 10.5). + +import type { Messages } from "../en"; + +export const startPlan: Messages["chat"]["startPlan"] = { + ready: (bot: string) => `${bot} vai começar assim`, + alternative: "Ou um jeito menor", + startsIn: (seconds: number) => `Começa sozinho em ${seconds} s`, + starting: "Começando…", + startNow: "Começar agora", + onlyThis: "Fazer só isto", + change: "Mudar", + sendChange: "Enviar", + noteLabel: (bot: string) => `O que ${bot} deve fazer em vez disso`, + notePlaceholder: "O que deve mudar?", + cancel: "Cancelar", + failed: "Não foi possível responder ao plano", + started: "Você mandou começar", + startedAlone: "Começou sozinho", + didAlternative: "Fazendo só o jeito menor", + changed: "Você pediu mudanças", + cancelled: "Você cancelou", +}; diff --git a/app/src/i18n/pt-BR/tools.ts b/app/src/i18n/pt-BR/tools.ts index 9f37b20..9a2f616 100644 --- a/app/src/i18n/pt-BR/tools.ts +++ b/app/src/i18n/pt-BR/tools.ts @@ -40,6 +40,7 @@ export const tools: Messages["tools"] = { ask_crew_access: "Acessar outra equipe", share_file: "Compartilhar um arquivo", ask_owner: "Fazer uma pergunta a você", + show_plan: "Mostrar o plano a você", browser: "Usar um site", browser_help: "Pedir sua ajuda", browser_open: "Abrir uma página", diff --git a/app/src/lib/fakeBots.ts b/app/src/lib/fakeBots.ts index 1ea2ef2..a6b059a 100644 --- a/app/src/lib/fakeBots.ts +++ b/app/src/lib/fakeBots.ts @@ -36,6 +36,7 @@ export function botHandlers(fake: FakeBotloft): Pick { agent: agent ?? "claude", agentModel: null, allowedCommands: [], + planFirst: false, model: model ?? "default", modelInUse: null, effort: "default", @@ -53,7 +54,7 @@ export function botHandlers(fake: FakeBotloft): Pick { fake.bots.set(bot.id, bot); return fake.changedBot(bot); }, - "bots.update": ({ botId, name, role, instructions, color, allowedCommands }) => { + "bots.update": ({ botId, name, role, instructions, color, allowedCommands, planFirst }) => { const bot = fake.bot(botId); if (name !== undefined) { bot.name = checkName(name); @@ -62,6 +63,7 @@ export function botHandlers(fake: FakeBotloft): Pick { bot.role = role?.trim() ?? bot.role; bot.instructions = instructions ?? bot.instructions; bot.color = color ?? bot.color; + bot.planFirst = planFirst ?? bot.planFirst; if (allowedCommands !== undefined) { bot.allowedCommands = [ ...new Set(allowedCommands.map((line) => line.trim()).filter(Boolean)), diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts index d7a0a82..5a6ac2a 100644 --- a/app/src/lib/fakeChat.ts +++ b/app/src/lib/fakeChat.ts @@ -210,6 +210,10 @@ export class FakeChat { approval.status = allow ? "allowed" : "denied"; approval.note = allow ? null : (note ?? null); approval.answeredAt = this.fake.now; + // The smaller way is kept with the plan, as the daemon does (spec 10.5). + if (allow && approval.toolName === "mcp__botloft__show_plan" && input !== undefined) { + approval.input = JSON.stringify({ ...JSON.parse(approval.input), ...JSON.parse(input) }); + } // Claude Code leaves plan mode once the plan is approved. const bot = this.fake.bots.get(approval.botId); if (allow && approval.toolName === "ExitPlanMode" && bot?.permissionMode === "plan") { From 08a4961f8705acaaf15fd10229842108206387f5 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:06:04 -0300 Subject: [PATCH 4/4] docs(spec): record show_plan checked with the real Claude Code --- docs/spec.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/spec.md b/docs/spec.md index 4c87c15..5679138 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1213,6 +1213,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Cofre de credenciais do Windows, para a senha do envio automático | 27.10 | **Testado** (2026-10-07, Windows 11, teste da crate rodando no cofre de verdade, com um nome só dele): `CredWriteW`, `CredReadW` e `CredDeleteW` com `CRED_TYPE_GENERIC` e `CRED_PERSIST_LOCAL_MACHINE` guardam, devolvem (inclusive acentos), sobrescrevem e apagam o segredo; ler o que não existe dá `ERROR_NOT_FOUND`, que o código lê como "não há", e apagar o que não existe não é erro. **Envio automático com a VPS de verdade, testado em 2026-10-07** (daemon de teste em pasta própria, `botloft.comitium.com.br`, conta de teste do dono): o padrão de `[cloud] url` é o servidor real; ligar guardou a senha no Gerenciador de Credenciais (`cmdkey /list` mostra `Botloft backup passphrase `, persistência de máquina local) e a primeira cópia leve (19,7 KB) subiu cerca de 4 s depois; olhar de novo sem mudança não mandou nada; editar o `CLAUDE.md` de um bot fez subir a segunda cópia; a cópia abre com a senha guardada (`scope: light`) e não com outra (`wrong_passphrase`); a senha não está em log nem em `autobackup.json`; desligar a apagou do cofre. Achados, já corrigidos e com teste: o estado saía com `null` no lugar de campo ausente (a tela mostraria uma data inválida em "Última cópia enviada"), e uma mudança de frequência feita durante um envio era desfeita quando o envio acabava | feito | | Celular (aprovar e responder de longe) | 28 | **Visto** (2026-10-08), com a VPS atualizada e um **Android de verdade** (Chrome, instalado como app); **não visto** no iPhone (sem aparelho). **Servidor:** a imagem do `deploy/cloud/Dockerfile` inteiro foi levada à VPS do Coolify (`docker load`), com `phone_dir`, `[push]` e a chave em `BOTLOFT_CLOUD_VAPID_KEY` (variável só de execução); subiu sem erro, `/health` 200, `/m` com a política atrás da Cloudflare, o JavaScript igual ao do build local, e o **WebSocket do relay abre atravessando a Cloudflare** (`101`). **Computador:** `pnpm local` trocou o app e o daemon (hashes iguais aos do build). **No aparelho:** (1) a **câmera do Android preservou o `#`** do QR; os seis dígitos eram iguais nos dois lados; instalou como app; (2) **Permitir** um pedido de escrever um arquivo (o cartão chegou sozinho, o bot seguiu); (3) **Negar com recado** (o cartão sumiu, o bot citou o recado, o arquivo ficou); (4) uma pergunta de `ask_owner` com duas opções, respondida pelo celular (o bot recebeu "verde"); (5) **aviso com a tela bloqueada e o app fechado**: chegou "O Botloft precisa de você" e tocar abriu o cartão; (6) desconectar **pelo computador** (o app aberto mostrou "foi desconectado" e voltou à conexão) e **pelo celular** (o computador deixou de listá-lo); (7) conectar de novo com um código novo no mesmo celular; (8) um pedido de rotina (que só se resolve no computador) chegou com o **Permitir apagado** e o aviso "precisa de você no computador", e **sumiu sozinho** do celular quando foi aprovado no computador. **Achados:** uma primeira instalação do app que travou pela metade deixou os avisos sem ligar até reinstalar (nada a corrigir no código; vale uma linha na ajuda); e o painel de navegador do app não registra service workers, então o aviso só se vê em aparelho de verdade. **Falta ver:** iPhone com a página na Tela de Início (Safari: câmera e `#`, Web Crypto, avisos); a conexão do relay aberta por horas atrás da Cloudflare; vários avisos seguidos virando dois (só testado automaticamente); sair da conta no computador levando os celulares (só testado automaticamente) | em parte | | Entrada do `Write` e do `Edit` para as contagens e o diff | 8.2, 15.3 | **Testado com 2.1.294** (2026-10-22, daemon de dev em pasta própria, app de dev com `?live`, Haiku, modo Manual): pedido para criar `notas.md` com 5 linhas pelo `Write` e trocar uma linha por duas pelo `Edit`. O `Write` chegou com `file_path` e `content`, e o `Edit` com `file_path`, `old_string` e `new_string` (sem contexto em volta: só a linha trocada). A linha mostrou "+5" no `Write` e "+2 −1" no `Edit` já no pedido de permissão, e abertas, os diffs certos: cinco linhas adicionadas; "- pão" removida, "- pão integral" e "- manteiga" adicionadas. O arquivo ficou como pedido. O texto da conversa não foi para o log | feito | +| Plano antes de tarefa longa (`show_plan`) | 10.5 | **Testado com 2.1.309** (2026-10-25, daemon de dev em pasta própria, comandado por script pelo WebSocket, Sonnet, modo Manual, opção ligada): (1) um pedido de três arquivos e revisão: depois do `ToolSearch` que carrega as tools do Botloft, a primeira ferramenta foi `show_plan`, com resumo e cinco passos em português e sem `alternative`; sem resposta, o cartão virou `expired` em 20 s e o bot seguiu o plano. (2) um pedido de tradução respondido com Mudar e uma nota: o bot não traduziu e fez o que a nota pedia. (3) uma pergunta rápida: nenhum cartão. O log do daemon não guardou texto das mensagens nem do plano. Ainda não visto: o bot oferecer uma `alternative` sozinho (nenhum dos dois planos trouxe) e o cartão no app instalado. | Itens do runtime anterior (ConPTY, hooks em exec form, `crossSessionInbound`, linha de auth do inbox, diálogo de confiança, `ESC[6n` do ConPTY, consultas do terminal no replay) foram verificados no M2–M4 e deixaram de se aplicar com a ADR 0001; o histórico está no git e na ADR.