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.