From 167a1ee0aeed87b6f29203fbf69697d36ac25d25 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:14:22 -0300 Subject: [PATCH] feat: the owner chooses how long a request for permission waits settings.update takes approvalWaitMinutes (1 to 1440) and saves it as bots.approval_timeout_minutes, keeping a comment after the old value. The wait is live: the next request waits the new time, and a longer one restarts each bot once nothing is in progress, since Claude Code reads the timeout in mcp.json when it starts. Settings, Chat offers 15 min to 8 hours, plus a value set by hand. --- app/src/features/account/ChatSettings.tsx | 26 ++++- app/src/features/account/prefs.test.tsx | 23 ++++ app/src/features/account/settings.test.tsx | 8 +- app/src/features/account/useDaemonSettings.ts | 1 + app/src/i18n/en/account.ts | 9 ++ app/src/i18n/es/account.ts | 8 ++ app/src/i18n/pt-BR/account.ts | 8 ++ app/src/lib/fake.ts | 3 +- app/src/lib/protocol.gen.ts | 13 ++- crates/botloft-core/src/protocol/settings.rs | 10 ++ crates/botloftd/src/approvals/mod.rs | 2 +- crates/botloftd/src/service/settings.rs | 21 +++- crates/botloftd/src/settings.rs | 109 +++++++++++++++--- crates/botloftd/src/state.rs | 10 +- crates/botloftd/src/supervisor/relaunch.rs | 10 ++ crates/botloftd/tests/approval_wait.rs | 69 +++++++++++ crates/botloftd/tests/common/mod.rs | 6 +- crates/botloftd/tests/settings.rs | 6 +- docs/spec.md | 10 +- 19 files changed, 309 insertions(+), 43 deletions(-) create mode 100644 crates/botloftd/tests/approval_wait.rs diff --git a/app/src/features/account/ChatSettings.tsx b/app/src/features/account/ChatSettings.tsx index 5c5ec162..42d1a226 100644 --- a/app/src/features/account/ChatSettings.tsx +++ b/app/src/features/account/ChatSettings.tsx @@ -1,14 +1,24 @@ -// Settings, "Chat": how a message is sent, and whether the panel beside -// the chat follows what the bot starts doing (spec 15.1). +// Settings, "Chat": how a message is sent, whether the panel beside the +// chat follows what the bot starts doing (spec 15.1), and how long a +// request for permission waits for the owner (spec 10.1). import { useT } from "../../i18n"; import { prefs, usePref } from "../../shell/prefs"; -import { Section, Toggle } from "./settingsParts"; +import { Choices } from "../../ui/Choices"; +import { Field, Section, Toggle } from "./settingsParts"; +import { useDaemonSettings } from "./useDaemonSettings"; + +/** The waits offered, in minutes; one set by hand in config.toml joins them. */ +const WAITS = [15, 30, 60, 120, 240, 480]; export function ChatSettings() { const s = useT().account.settings; const enterSends = usePref(prefs.enterSends); const followBot = usePref(prefs.followBot); + const { settings, change } = useDaemonSettings(); + const current = settings?.approvalWaitMinutes; + const waits = + current && !WAITS.includes(current) ? [...WAITS, current].sort((a, b) => a - b) : WAITS; return (
+ {settings && ( + + + label={s.approvalWait} + value={settings.approvalWaitMinutes} + options={waits.map((minutes) => ({ value: minutes, label: s.waitFor(minutes) }))} + onChange={(minutes) => change({ approvalWaitMinutes: minutes })} + /> + + )}
); } diff --git a/app/src/features/account/prefs.test.tsx b/app/src/features/account/prefs.test.tsx index 50535b70..cf239fb3 100644 --- a/app/src/features/account/prefs.test.tsx +++ b/app/src/features/account/prefs.test.tsx @@ -76,3 +76,26 @@ describe("the app's own choices", () => { expect(document.documentElement.dataset.motion).toBeUndefined(); }); }); + +describe("requests for permission", () => { + test("wait as long as the owner chooses, one set by hand included", async () => { + const fake = new FakeBotloft(); + fake.settings = { ...fake.settings, approvalWaitMinutes: 45 }; + renderApp(fake); + const dialog = await openSettings("Chat"); + const waits = await within(dialog).findByRole("radiogroup", { + name: "How long a request for permission waits for you", + }); + expect( + within(waits) + .getAllByRole("radio") + .map((radio) => radio.parentElement?.textContent), + ).toEqual(["15 min", "30 min", "45 min", "1 hour", "2 hours", "4 hours", "8 hours"]); + expect((within(waits).getByRole("radio", { name: "45 min" }) as HTMLInputElement).checked).toBe( + true, + ); + fireEvent.click(within(waits).getByRole("radio", { name: "4 hours" })); + await act(async () => {}); + expect(fake.settings.approvalWaitMinutes).toBe(240); + }); +}); diff --git a/app/src/features/account/settings.test.tsx b/app/src/features/account/settings.test.tsx index 58bd4ff4..5c61862c 100644 --- a/app/src/features/account/settings.test.tsx +++ b/app/src/features/account/settings.test.tsx @@ -52,7 +52,11 @@ describe("settings", () => { expect(dialog.textContent).toContain("the bots wait until you open Botloft"); fireEvent.click(awake); await waitFor(() => - expect(fake.settings).toEqual({ startWithWindows: false, keepAwake: false }), + expect(fake.settings).toEqual({ + startWithWindows: false, + keepAwake: false, + approvalWaitMinutes: 60, + }), ); expect( fake.calls.filter((call) => call.method === "settings.update").map((c) => c.params), @@ -123,7 +127,7 @@ describe("closing the window", () => { test("the welcome screen says Botloft waits for the owner after a restart", async () => { const fake = new FakeBotloft(); - fake.settings = { startWithWindows: false, keepAwake: true }; + fake.settings = { ...fake.settings, startWithWindows: false }; renderApp(fake); expect( await screen.findByText( diff --git a/app/src/features/account/useDaemonSettings.ts b/app/src/features/account/useDaemonSettings.ts index 4b10c454..d536c031 100644 --- a/app/src/features/account/useDaemonSettings.ts +++ b/app/src/features/account/useDaemonSettings.ts @@ -33,6 +33,7 @@ export function useDaemonSettings(): { setSettings({ startWithWindows: update.startWithWindows ?? before.startWithWindows, keepAwake: update.keepAwake ?? before.keepAwake, + approvalWaitMinutes: update.approvalWaitMinutes ?? before.approvalWaitMinutes, }); } attempt(failed, async () => setSettings(await api.call("settings.update", update))).then( diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 0d3b7d4c..102b191e 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -51,6 +51,15 @@ export const account = { followBot: "Open the browser and screens when a bot starts using them", followBotOn: "The panel opens beside the chat, so you see what the bot does.", followBotOff: "The panel's button gets a dot, and you open it when you want.", + approvalWait: "How long a request for permission waits for you", + approvalWaitHint: + "With no answer by then, the bot's request is denied and it goes on without it.", + waitFor: (minutes: number) => + minutes < 60 || minutes % 60 !== 0 + ? `${minutes} min` + : minutes === 60 + ? "1 hour" + : `${minutes / 60} hours`, lessMotion: "Less motion", lessMotionHint: "A still window, as when the Windows animation effects are off.", appearance: "Appearance", diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index 3ee3f349..6452efea 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -51,6 +51,14 @@ export const account: Messages["account"] = { followBot: "Abrir el navegador y las pantallas cuando un bot empiece a usarlos", followBotOn: "El panel se abre junto al chat, para que veas lo que hace el bot.", followBotOff: "El botón del panel muestra un punto, y lo abres cuando quieras.", + approvalWait: "Cuánto espera tu respuesta un pedido de permiso", + approvalWaitHint: "Sin respuesta en ese tiempo, el pedido del bot se deniega y sigue sin eso.", + waitFor: (minutes: number) => + minutes < 60 || minutes % 60 !== 0 + ? `${minutes} min` + : minutes === 60 + ? "1 hora" + : `${minutes / 60} horas`, lessMotion: "Menos animaciones", lessMotionHint: "La ventana queda quieta, como cuando los efectos de animación de Windows están desactivados.", diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index 66885f54..b9bac61d 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -52,6 +52,14 @@ export const account: Messages["account"] = { followBot: "Abrir o navegador e as telas quando um bot começar a usar", followBotOn: "O painel abre ao lado da conversa, para você ver o que o bot faz.", followBotOff: "O botão do painel ganha um ponto, e você abre quando quiser.", + approvalWait: "Quanto tempo um pedido de permissão espera por você", + approvalWaitHint: "Sem resposta nesse tempo, o pedido do bot é negado e ele segue sem isso.", + waitFor: (minutes: number) => + minutes < 60 || minutes % 60 !== 0 + ? `${minutes} min` + : minutes === 60 + ? "1 hora" + : `${minutes / 60} horas`, lessMotion: "Menos animações", lessMotionHint: "A janela fica parada, como quando os efeitos de animação do Windows estão desligados.", diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 46360cba..82e5f6ce 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -57,7 +57,7 @@ export class FakeBotloft implements BotloftApi { }; /** How many times the app asked for a new Claude Code check. */ refreshes = 0; - settings: Settings = { startWithWindows: true, keepAwake: true }; + settings: Settings = { startWithWindows: true, keepAwake: true, approvalWaitMinutes: 60 }; /** Every call, in order. */ readonly calls: { method: Method; params: unknown }[] = []; readonly chat = new FakeChat(this); @@ -217,6 +217,7 @@ export class FakeBotloft implements BotloftApi { this.settings = { startWithWindows: change.startWithWindows ?? this.settings.startWithWindows, keepAwake: change.keepAwake ?? this.settings.keepAwake, + approvalWaitMinutes: change.approvalWaitMinutes ?? this.settings.approvalWaitMinutes, }; return this.settings; }, diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 21eea539..1aa00419 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -721,12 +721,21 @@ startWithWindows: boolean, /** * The computer does not sleep while a bot works (spec 14). */ -keepAwake: boolean, }; +keepAwake: boolean, +/** + * How long a permission request waits for the owner before it is + * denied (spec 10.1). + */ +approvalWaitMinutes: number, }; /** * The settings to change; the ones left out stay as they are. */ -export type SettingsUpdateParams = { startWithWindows?: boolean, keepAwake?: boolean, }; +export type SettingsUpdateParams = { startWithWindows?: boolean, keepAwake?: boolean, +/** + * From 1 to [`APPROVAL_WAIT_MAX_MINUTES`]. + */ +approvalWaitMinutes?: number, }; /** * When a routine runs (spec 20.2), kept as structure rather than cron text diff --git a/crates/botloft-core/src/protocol/settings.rs b/crates/botloft-core/src/protocol/settings.rs index 6ac150b5..12eb99e0 100644 --- a/crates/botloft-core/src/protocol/settings.rs +++ b/crates/botloft-core/src/protocol/settings.rs @@ -13,6 +13,9 @@ pub struct Settings { pub start_with_windows: bool, /// The computer does not sleep while a bot works (spec 14). pub keep_awake: bool, + /// How long a permission request waits for the owner before it is + /// denied (spec 10.1). + pub approval_wait_minutes: u32, } /// The settings to change; the ones left out stay as they are. @@ -26,4 +29,11 @@ pub struct SettingsUpdateParams { #[serde(default)] #[cfg_attr(test, ts(optional))] pub keep_awake: Option, + /// From 1 to [`APPROVAL_WAIT_MAX_MINUTES`]. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub approval_wait_minutes: Option, } + +/// The longest a permission request may wait for the owner: a day. +pub const APPROVAL_WAIT_MAX_MINUTES: u32 = 24 * 60; diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs index 7e911eb5..bc0e4411 100644 --- a/crates/botloftd/src/approvals/mod.rs +++ b/crates/botloftd/src/approvals/mod.rs @@ -106,7 +106,7 @@ pub(crate) async fn ask_with( record: &pending.record, settled: false, }; - let decision = tokio::time::timeout(daemon.bots.approval_timeout, waiting).await; + let decision = tokio::time::timeout(daemon.settings.approval_wait(), waiting).await; guard.settled = true; daemon.approvals.lock().remove(&pending.record.approval.id); daemon.supervisor.approval_closed(bot, generation); diff --git a/crates/botloftd/src/service/settings.rs b/crates/botloftd/src/service/settings.rs index 9f2b9f04..1b300af2 100644 --- a/crates/botloftd/src/service/settings.rs +++ b/crates/botloftd/src/service/settings.rs @@ -1,7 +1,7 @@ //! `settings.get` and `settings.update` (spec 11.2): what the owner //! changes in the app's Settings. -use botloft_core::protocol::{Settings, SettingsUpdateParams}; +use botloft_core::protocol::{APPROVAL_WAIT_MAX_MINUTES, Settings, SettingsUpdateParams}; use tracing::info; use super::{ApiError, ApiResult}; @@ -13,9 +13,20 @@ pub fn get(daemon: &Daemon) -> ApiResult { } /// Saves the change in `config.toml` and applies it: the scheduled task -/// follows starting with Windows (spec 14), and keeping the computer -/// awake turns on or off at once. +/// follows starting with Windows (spec 14), keeping the computer awake +/// turns on or off at once, and so does a shorter wait for approvals. A +/// longer one reaches each bot when it restarts, as soon as nothing is in +/// progress: Claude Code reads the wait in `mcp.json` when it starts +/// (spec 10.1). pub fn update(daemon: &Daemon, params: SettingsUpdateParams) -> ApiResult { + if params + .approval_wait_minutes + .is_some_and(|minutes| !(1..=APPROVAL_WAIT_MAX_MINUTES).contains(&minutes)) + { + return Err(ApiError::validation(format!( + "approvalWaitMinutes must be between 1 and {APPROVAL_WAIT_MAX_MINUTES}" + ))); + } let before = daemon.settings.get(); if let Some(start) = params.start_with_windows && start != before.start_with_windows @@ -27,9 +38,13 @@ pub fn update(daemon: &Daemon, params: SettingsUpdateParams) -> ApiResult before.approval_wait_minutes { + daemon.supervisor.workspace_settings_changed(); + } info!( start_with_windows = after.start_with_windows, keep_awake = after.keep_awake, + approval_wait_minutes = after.approval_wait_minutes, "settings changed" ); Ok(after) diff --git a/crates/botloftd/src/settings.rs b/crates/botloftd/src/settings.rs index e08c1e93..66b4601b 100644 --- a/crates/botloftd/src/settings.rs +++ b/crates/botloftd/src/settings.rs @@ -5,6 +5,7 @@ use std::io; use std::path::{Path, PathBuf}; use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; use botloft_core::protocol::{Settings, SettingsUpdateParams}; use tokio::sync::watch; @@ -14,25 +15,53 @@ use crate::config::Config; pub struct LiveSettings { /// Where they are saved; `None` keeps them in memory (tests). file: Option, - current: Mutex, + current: Mutex, keep_awake: watch::Sender, } +struct Current { + settings: Settings, + /// `settings.approval_wait_minutes`, or shorter in tests. + approval_wait: Duration, +} + impl LiveSettings { pub fn new(file: Option, config: &Config) -> Self { - let current = Settings { + let minutes = u32::try_from(config.bots.approval_timeout_minutes).unwrap_or(u32::MAX); + let settings = Settings { start_with_windows: config.start_with_windows, keep_awake: config.keep_awake, + approval_wait_minutes: minutes, }; Self { file, - current: Mutex::new(current), - keep_awake: watch::Sender::new(current.keep_awake), + current: Mutex::new(Current { + settings, + approval_wait: minutes_to_wait(minutes), + }), + keep_awake: watch::Sender::new(settings.keep_awake), + } + } + + /// Requests wait `wait` for the owner, even under a minute (tests). + #[must_use] + pub fn with_approval_wait(self, wait: Duration) -> Self { + { + let mut current = self.lock(); + current.approval_wait = wait; + current.settings.approval_wait_minutes = + u32::try_from(wait.as_secs() / 60).unwrap_or(u32::MAX); } + self } pub fn get(&self) -> Settings { - *self.lock() + self.lock().settings + } + + /// How long a permission request waits for the owner (spec 10.1). + pub fn approval_wait(&self) -> Duration { + self.lock().approval_wait } /// Follows `keep_awake` (spec 14). @@ -47,22 +76,30 @@ impl LiveSettings { write(file, update)?; } if let Some(start) = update.start_with_windows { - current.start_with_windows = start; + current.settings.start_with_windows = start; } if let Some(keep_awake) = update.keep_awake { - current.keep_awake = keep_awake; + current.settings.keep_awake = keep_awake; self.keep_awake.send_replace(keep_awake); } - Ok(*current) + if let Some(minutes) = update.approval_wait_minutes { + current.settings.approval_wait_minutes = minutes; + current.approval_wait = minutes_to_wait(minutes); + } + Ok(current.settings) } - fn lock(&self) -> MutexGuard<'_, Settings> { + fn lock(&self) -> MutexGuard<'_, Current> { self.current .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } } +fn minutes_to_wait(minutes: u32) -> Duration { + Duration::from_secs(u64::from(minutes) * 60) +} + /// Sets the changed keys in `config.toml` and leaves the rest as it was. /// The new file replaces the old one whole, so a crash never leaves half. fn write(file: &Path, update: SettingsUpdateParams) -> io::Result<()> { @@ -75,16 +112,32 @@ fn write(file: &Path, update: SettingsUpdateParams) -> io::Result<()> { .parse() .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; if let Some(start) = update.start_with_windows { - config["start_with_windows"] = toml_edit::value(start); + set(config.as_table_mut(), "start_with_windows", start); } if let Some(keep_awake) = update.keep_awake { - config["keep_awake"] = toml_edit::value(keep_awake); + set(config.as_table_mut(), "keep_awake", keep_awake); + } + if let Some(minutes) = update.approval_wait_minutes { + let bots = config["bots"] + .or_insert(toml_edit::table()) + .as_table_like_mut() + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "bots is not a table"))?; + set(bots, "approval_timeout_minutes", i64::from(minutes)); } let staged = file.with_extension("toml.new"); std::fs::write(&staged, config.to_string())?; std::fs::rename(&staged, file) } +/// Sets `key` in `table`, keeping a comment after the old value. +fn set(table: &mut dyn toml_edit::TableLike, key: &str, value: impl Into) { + let mut value = value.into(); + if let Some(old) = table.get(key).and_then(toml_edit::Item::as_value) { + *value.decor_mut() = old.decor().clone(); + } + table.insert(key, toml_edit::Item::Value(value)); +} + #[cfg(test)] mod tests { use super::*; @@ -109,7 +162,8 @@ mod tests { saved, Settings { start_with_windows: true, - keep_awake: false + keep_awake: false, + approval_wait_minutes: 30, } ); assert!(awake.has_changed().expect("sender") && !*awake.borrow_and_update()); @@ -125,17 +179,44 @@ mod tests { .save(SettingsUpdateParams { start_with_windows: Some(false), keep_awake: Some(true), + approval_wait_minutes: Some(240), }) .expect("save"); - let config = Config::parse(&std::fs::read_to_string(&file).expect("read")).expect("parse"); + let text = std::fs::read_to_string(&file).expect("read"); + assert!(text.contains( + "keep_awake = true +" + )); + assert!(text.contains("approval_timeout_minutes = 240 # half an hour")); + let config = Config::parse(&text).expect("parse"); assert!(config.keep_awake && !config.start_with_windows); + assert_eq!(config.bots.approval_timeout_minutes, 240); assert_eq!( settings.get(), Settings { start_with_windows: false, - keep_awake: true + keep_awake: true, + approval_wait_minutes: 240, } ); + assert_eq!(settings.approval_wait(), Duration::from_secs(4 * 3600)); + } + + #[test] + fn a_new_wait_goes_in_the_bots_table() { + let dir = tempfile::tempdir().expect("dir"); + let file = dir.path().join("config.toml"); + let settings = LiveSettings::new(Some(file.clone()), &Config::default()); + settings + .save(SettingsUpdateParams { + approval_wait_minutes: Some(15), + ..SettingsUpdateParams::default() + }) + .expect("save"); + assert_eq!( + std::fs::read_to_string(&file).expect("read"), + "[bots]\napproval_timeout_minutes = 15\n" + ); } #[test] diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index 3fefc9b0..f947f00a 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -2,7 +2,7 @@ //! courier, the event bus and what the daemon knows about itself. use std::sync::{Arc, Mutex, MutexGuard}; -use std::time::{Duration, Instant}; +use std::time::Instant; use botloft_core::protocol::{ AccountUsage, Bot, BotStateChanged, BrowserAction, BrowserState, ChatDelta, ChatItemChanged, @@ -47,11 +47,10 @@ pub enum Event { /// Events buffered per connection before a slow client is dropped. const EVENT_BUFFER: usize = 1024; -/// Settings for what bots may ask of the owner (spec 6, `[bots]`). +/// Settings for what bots may ask of the owner (spec 6, `[bots]`). How +/// long a request waits for the owner is live: `LiveSettings`. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct BotSettings { - /// How long a permission request waits for the owner (spec 10.1). - pub approval_timeout: Duration, /// Largest attachment, per file (spec 9.5). pub attachment_max_bytes: u64, /// Most bots a chief's suggestions can bring a crew to (spec 10.2). @@ -61,7 +60,6 @@ pub struct BotSettings { impl BotSettings { pub fn from_config(config: &Config) -> Self { Self { - approval_timeout: Duration::from_secs(config.bots.approval_timeout_minutes * 60), attachment_max_bytes: config.bots.attachment_max_mb * 1024 * 1024, max_per_crew: config.bots.max_per_crew, } @@ -157,7 +155,7 @@ impl Daemon { WorkspaceEnv { paths: &self.paths, port: self.port, - approval_timeout: self.bots.approval_timeout, + approval_timeout: self.settings.approval_wait(), } } diff --git a/crates/botloftd/src/supervisor/relaunch.rs b/crates/botloftd/src/supervisor/relaunch.rs index fb6b2b21..f7bc29fe 100644 --- a/crates/botloftd/src/supervisor/relaunch.rs +++ b/crates/botloftd/src/supervisor/relaunch.rs @@ -28,6 +28,16 @@ impl Supervisor { } } + /// Every running bot picks up new settings of its workspace, as soon + /// as nothing is in progress: a longer wait for approvals (spec 10.1) + /// is in `mcp.json`, which Claude Code reads when it starts. + pub fn workspace_settings_changed(&self) { + let bots: Vec = self.lock().slots.keys().cloned().collect(); + for bot in bots { + self.launch_settings_changed(&bot); + } + } + /// Whether new settings wait for the running turn to end. pub fn relaunch_pending(&self, bot: &BotId) -> bool { self.lock() diff --git a/crates/botloftd/tests/approval_wait.rs b/crates/botloftd/tests/approval_wait.rs new file mode 100644 index 00000000..9b5b24be --- /dev/null +++ b/crates/botloftd/tests/approval_wait.rs @@ -0,0 +1,69 @@ +//! How long a permission request waits for the owner (spec 10.1), as the +//! owner sets it: a longer wait reaches each bot's `mcp.json` with a +//! restart once nothing is in progress; a shorter one needs no restart. + +mod common; + +use botloft_core::protocol::{BotState, SettingsUpdateParams}; +use botloftd::runtime::fake::FakeProcess; +use botloftd::service::{self, settings}; +use common::supervised::{Setup, setup}; + +fn wait(s: &Setup, minutes: u32) -> service::ApiResult { + settings::update( + &s.daemon, + SettingsUpdateParams { + approval_wait_minutes: Some(minutes), + ..SettingsUpdateParams::default() + }, + ) +} + +/// The `timeout` in the `mcp.json` the process was started with. +fn mcp_timeout(process: &FakeProcess) -> u64 { + let args = process.args(); + let at = args + .iter() + .position(|arg| arg == "--mcp-config") + .expect("mcp flag"); + let text = std::fs::read_to_string(&args[at + 1]).expect("mcp.json"); + let json: serde_json::Value = serde_json::from_str(&text).expect("json"); + json["mcpServers"]["botloft"]["timeout"] + .as_u64() + .expect("timeout") +} + +#[tokio::test(start_paused = true)] +async fn a_longer_wait_restarts_idle_bots_and_a_shorter_one_does_not() { + let s = setup().await; + let first = s.runtime.process(1).await; + s.until(BotState::Idle).await; + + let saved = wait(&s, 240).expect("longer"); + assert_eq!(saved.approval_wait_minutes, 240); + let second = s.runtime.process(2).await; + assert!(first.killed()); + assert_eq!(mcp_timeout(&second), (240 + 2) * 60 * 1000); + s.until(BotState::Idle).await; + + wait(&s, 30).expect("shorter"); + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + assert_eq!( + s.runtime.processes().len(), + 2, + "no restart for a shorter wait" + ); + assert_eq!( + s.daemon.settings.approval_wait(), + std::time::Duration::from_secs(30 * 60) + ); +} + +#[tokio::test(start_paused = true)] +async fn a_wait_outside_a_day_is_refused() { + let s = setup().await; + for minutes in [0, 24 * 60 + 1] { + let err = wait(&s, minutes).expect_err("refused"); + assert!(err.to_string().contains("between 1 and 1440"), "{err}"); + } +} diff --git a/crates/botloftd/tests/common/mod.rs b/crates/botloftd/tests/common/mod.rs index c6468bfc..8d14da67 100644 --- a/crates/botloftd/tests/common/mod.rs +++ b/crates/botloftd/tests/common/mod.rs @@ -65,10 +65,8 @@ pub fn test_settings() -> SupervisorSettings { } } -/// Approvals time out fast enough for a test to wait for it. pub fn bot_settings() -> BotSettings { BotSettings { - approval_timeout: Duration::from_secs(3), attachment_max_bytes: 1024 * 1024, max_per_crew: 3, } @@ -114,7 +112,9 @@ pub fn new_daemon(settings: SupervisorSettings) -> Parts { }, bots: bot_settings(), browser: BrowserSettings::default(), - settings: LiveSettings::new(None, &Config::default()), + // Approvals time out fast enough for a test to wait for it. + settings: LiveSettings::new(None, &Config::default()) + .with_approval_wait(Duration::from_secs(3)), }); Parts { daemon, diff --git a/crates/botloftd/tests/settings.rs b/crates/botloftd/tests/settings.rs index e1893fef..92559690 100644 --- a/crates/botloftd/tests/settings.rs +++ b/crates/botloftd/tests/settings.rs @@ -15,7 +15,7 @@ async fn settings_are_read_and_changed_one_at_a_time() { let settings = app.call("settings.get", json!(null)).await.expect("get"); assert_eq!( settings, - json!({ "startWithWindows": true, "keepAwake": true }) + json!({ "startWithWindows": true, "keepAwake": true, "approvalWaitMinutes": 0 }) ); let changed = app @@ -24,7 +24,7 @@ async fn settings_are_read_and_changed_one_at_a_time() { .expect("update"); assert_eq!( changed, - json!({ "startWithWindows": true, "keepAwake": false }) + json!({ "startWithWindows": true, "keepAwake": false, "approvalWaitMinutes": 0 }) ); assert!(!*t.daemon.settings.keep_awake().borrow()); @@ -34,7 +34,7 @@ async fn settings_are_read_and_changed_one_at_a_time() { .expect("update"); assert_eq!( changed, - json!({ "startWithWindows": false, "keepAwake": false }) + json!({ "startWithWindows": false, "keepAwake": false, "approvalWaitMinutes": 0 }) ); assert_eq!( app.call("settings.get", json!(null)).await.expect("get"), diff --git a/docs/spec.md b/docs/spec.md index 122494e3..8aef089f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -172,7 +172,7 @@ max_hops = 4 default_deadline_minutes = 120 ``` -`start_with_windows` e `keep_awake` também mudam pelas Configurações do app (`settings.update`, 11.2): o daemon grava no próprio arquivo com `toml_edit`, só a chave que mudou, e as linhas e comentários do dono ficam. O arquivo novo substitui o antigo inteiro (`config.toml.new` renomeado), para uma queda no meio não deixar metade. As duas valem na hora: `keep_awake` liga ou desliga o pedido de energia, e `start_with_windows` muda os gatilhos da tarefa (14). +`start_with_windows`, `keep_awake` e `bots.approval_timeout_minutes` também mudam pelas Configurações do app (`settings.update`, 11.2): o daemon grava no próprio arquivo com `toml_edit`, só a chave que mudou, e as linhas e comentários do dono ficam (o comentário depois de um valor trocado também). O arquivo novo substitui o antigo inteiro (`config.toml.new` renomeado), para uma queda no meio não deixar metade. Valem na hora: `keep_awake` liga ou desliga o pedido de energia, `start_with_windows` muda os gatilhos da tarefa (14) e a espera das aprovações vale para o próximo pedido; uma espera maior chega ao `mcp.json` de cada bot, que reinicia retomando a conversa quando nada estiver em andamento (10.1, 7.4). ## 7. Ciclo de vida do bot @@ -434,7 +434,7 @@ Endereçamento só dentro da crew. Bot não enxerga bots nem tasks de outras cre Com `--permission-prompt-tool mcp__botloft__permission_prompt`, toda ferramenta que precisaria de permissão chama a tool `permission_prompt`. A entrada é `{tool_name, input, tool_use_id}` (documentado; visto com 2.1.284). 1. O daemon grava uma `approval` (`pending`) e o item `approval` no chat, e passa o bot para `needs_approval`. -2. A chamada HTTP fica aberta até o dono responder (`approvals.answer {approvalId, allow, note?}`) ou até `approval_timeout_minutes`. +2. A chamada HTTP fica aberta até o dono responder (`approvals.answer {approvalId, allow, note?}`) ou até `approval_timeout_minutes`, que o dono escolhe nas Configurações (15 min a 8 h no app; o daemon aceita de 1 min a 1 dia). O `timeout` do `mcp.json` (10) só é lido quando o Claude Code inicia: aumentada a espera, cada bot reinicia quando nada estiver em andamento; diminuída, o daemon responde antes e nada reinicia. 3. Resposta ao Claude Code, em texto JSON: - Permitir: `{"behavior":"allow","updatedInput":}`. - Negar: `{"behavior":"deny","message":"The owner denied this."}`, com a nota do dono se houver. @@ -504,8 +504,8 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `tasks.list` | `crewId?, status?` | `Task[]` | | `browser.list`, `browser.watch`, `browser.unwatch`, `browser.take`, `browser.release`, `browser.input` | seção 21.7 | o navegador dos bots, a tela ao vivo e o dono no controle | | `screens.list` | seção 22.4 | as telas HTML do bot | -| `settings.get` | | `Settings`: `startWithWindows` e `keepAwake`, como estão no `config.toml` (6) | -| `settings.update` | `startWithWindows?, keepAwake?` | `Settings`; grava o que veio e aplica na hora (6, 14). Se a tarefa não pode mudar, nada é gravado e volta um erro | +| `settings.get` | | `Settings`: `startWithWindows`, `keepAwake` e `approvalWaitMinutes`, como estão no `config.toml` (6) | +| `settings.update` | `startWithWindows?, keepAwake?, approvalWaitMinutes?` (1 a 1440) | `Settings`; grava o que veio e aplica na hora (6, 14). Se a tarefa não pode mudar, nada é gravado e volta um erro | `Crew` traz `workFolder`, o caminho da pasta de trabalho (a escolhida ou a `shared\`), `workFolderChosen` e `leadBotId`, o chefe (10.2). `Bot` traz também `permissionMode`, `model` e `modelInUse` (7.4) e `lastActivity`: o último item do chat resumido em uma linha, para a lista de conversas: `kind` (`owner`, `message`, `reply`, `tool`, `approval`, `notice`), `text`, `tool` e `at`. O `text` não tem palavras do daemon: a mensagem do dono vem sem "You:", e uma ferramenta ou um pedido vêm só com o resumo, com a ferramenta à parte em `tool`; o app completa no idioma do dono ("Mandar uma mensagem · @writer", "Aguardando aprovação: rodar um comando"). @@ -620,7 +620,7 @@ Layout, como um app de mensagens: - **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. - **Área da conta**, no pé da barra lateral, como nos apps de chat: um círculo com a inicial, o nome do dono (do Windows) e o plano do Claude ("Plano Max"; sem plano, a organização ou o e-mail; desconectado, "Sem conta do Claude conectada"). Um clique abre um menu para cima com o e-mail e: **Uso** (as janelas de uso do plano, 8.1, com a parte usada e quando renovam; antes da primeira resposta de um bot, um aviso de que ainda não há dados), **Configurações** (abaixo), **Idioma** (submenu ao lado, com a escolha na hora), **Novidades** (a página de releases no navegador) e **Ajuda** (o README no navegador). Tema, tamanho e idioma saíram da barra de título; ela só mostra o idioma nas telas de preparo, que ainda não têm barra lateral. -- **Configurações**, um diálogo com as partes listadas à esquerda (abas verticais): **Geral**, **Conversa**, **Aparência** e **Sobre**. As escolhas que são só do app ficam no `localStorage` (`botloft.whenClosed`, `botloft.enterSends`, `botloft.followBot`, `botloft.lessMotion`), como o tema. Em Geral, **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app), "Iniciar com o Windows" (`startWithWindows`, 14) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. Embaixo, o **Idioma**. Em Conversa, "Enter envia a mensagem" (desligada, Enter quebra a linha e Ctrl+Enter envia, e a dica do compositor diz isso) e "Abrir o navegador e as telas quando um bot começar a usar" (desligada, só o ponto no botão avisa). Em Aparência, tema, tamanho e "Menos animações", que marca a raiz com `data-motion="less"` e para tudo como o Windows faria (15.3). Em **Sobre**: as versões do Botloft e do Claude Code, o e-mail e "Procurar atualizações", que consulta o feed na hora (15.5) e diz que esta é a versão mais recente ou mostra "Ver atualização", que abre o diálogo de atualizar. A linha do Botloft na tela de boas-vindas segue as mesmas escolhas. +- **Configurações**, um diálogo com as partes listadas à esquerda (abas verticais): **Geral**, **Conversa**, **Aparência** e **Sobre**. As escolhas que são só do app ficam no `localStorage` (`botloft.whenClosed`, `botloft.enterSends`, `botloft.followBot`, `botloft.lessMotion`), como o tema. Em Geral, **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app), "Iniciar com o Windows" (`startWithWindows`, 14) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. Embaixo, o **Idioma**. Em Conversa, "Enter envia a mensagem" (desligada, Enter quebra a linha e Ctrl+Enter envia, e a dica do compositor diz isso) e "Abrir o navegador e as telas quando um bot começar a usar" (desligada, só o ponto no botão avisa), e quanto tempo um pedido de permissão espera pelo dono (15 min, 30 min, 1 h, 2 h, 4 h ou 8 h; um valor posto à mão no `config.toml` entra na lista), com a linha de que sem resposta o pedido é negado (10.1). Em Aparência, tema, tamanho e "Menos animações", que marca a raiz com `data-motion="less"` e para tudo como o Windows faria (15.3). Em **Sobre**: as versões do Botloft e do Claude Code, o e-mail e "Procurar atualizações", que consulta o feed na hora (15.5) e diz que esta é a versão mais recente ou mostra "Ver atualização", que abre o diálogo de atualizar. A linha do Botloft na tela de boas-vindas segue as mesmas escolhas. - **Área principal com um bot:** cabeçalho com nome, estado e ações; o chat; o compositor embaixo. O compositor aceita texto, colar imagem e arrastar ou escolher arquivos. Enter envia e Shift+Enter quebra linha, ou, se o dono escolheu nas Configurações, Ctrl+Enter envia e Enter quebra linha (a dica aparece enquanto o dono escreve). - **Modo do bot**, no compositor, ao lado do clipe, como no Claude Code: um botão com o modo atual abre um menu para cima, "Modo", com Automático, Manual, Aceitar edições e Plano, cada um com uma linha que fala do bot pelo nome ("Scout decide o que precisa do seu OK") e a marca no atual. Separado, "Ignorar permissões" com o botão Ativar, que abre uma confirmação dizendo que o bot não fica preso à pasta dele (13). Nesse modo o botão fica vermelho e o cabeçalho mostra "Não pergunta nada" em vermelho. Com o bot ocupado, uma linha acima do compositor avisa que ele muda de modo quando terminar o que está fazendo. - **Modelo do bot**, no compositor, ao lado do botão de enviar: mostra o modelo em uso ("Opus 5.5"; antes do primeiro turno, o nome escolhido ou "Padrão") e abre um menu para cima, "Modelo", com Padrão do plano, Fable, Opus, Sonnet e Haiku. Cada um tem uma linha que fala do bot pelo nome ("Scout fica rápido e capaz, bom para quase tudo"; o padrão diz qual modelo é hoje), e o pé do menu lembra que modelos mais capazes gastam o limite do plano mais rápido. A janela de criar e editar bot tem a mesma escolha. Com o bot ocupado, a mesma linha acima do compositor avisa que ele troca quando terminar.