diff --git a/app/src/features/crews/AutoModeSwitch.tsx b/app/src/features/crews/AutoModeSwitch.tsx new file mode 100644 index 00000000..fcceacf0 --- /dev/null +++ b/app/src/features/crews/AutoModeSwitch.tsx @@ -0,0 +1,71 @@ +import { useId } from "react"; +import { useT } from "../../i18n"; +import { Switch } from "../../ui/Switch"; + +/** + * The crew's automatic mode (spec 7.4): on, every agent of the crew works + * without asking at each step, the ones it has and the ones that join later. + * `card` is the form version, with its explanation; the other is the compact + * one for the crew's header. + */ +export function AutoModeSwitch({ + checked, + onChange, + card = false, + disabled, +}: { + checked: boolean; + onChange(checked: boolean): void; + card?: boolean; + disabled?: boolean | undefined; +}) { + const t = useT().crews.autoMode; + const labelId = useId(); + const hintId = useId(); + if (!card) { + return ( +
+ + {t.label} + + + + {t.hint} + +
+ ); + } + return ( +
+
+

+ {t.label} +

+

+ {t.hint} +

+
+ +
+ ); +} diff --git a/app/src/features/crews/CrewDialog.tsx b/app/src/features/crews/CrewDialog.tsx index bed8205a..94c2e365 100644 --- a/app/src/features/crews/CrewDialog.tsx +++ b/app/src/features/crews/CrewDialog.tsx @@ -8,6 +8,7 @@ import { Dialog } from "../../ui/Dialog"; import { SelectField, TextArea, TextField } from "../../ui/Field"; import { notifyError } from "../../ui/toast"; import { useCatalog } from "../catalog/useCatalog"; +import { AutoModeSwitch } from "./AutoModeSwitch"; import { addTemplateBots } from "./addTemplateBots"; import { CrewColorField } from "./CrewColorField"; import type { CrewTemplate } from "./crewTemplates"; @@ -39,6 +40,7 @@ export function CrewDialog({ ); const [color, setColor] = useState(crew?.color ?? null); const [folder, setFolder] = useState(null); + const [autoMode, setAutoMode] = useState(false); const [goal, setGoal] = useState(start ? t.crewTemplates.items[start.id].goal : ""); const [chiefModel, setChiefModel] = useState("default"); const selectBot = useApp((state) => state.selectBot); @@ -70,6 +72,7 @@ export function CrewDialog({ : await api.call("crews.create", { name, ...(folder && { workFolder: folder }), + ...(autoMode && { autoMode: true }), // Every new crew starts with its chief (spec 10.2). lead: { name: d.chiefName, @@ -165,6 +168,7 @@ export function CrewDialog({ placeholder={t.crews.dialog.goalPlaceholder} hint={t.crews.dialog.goalHint} /> + + diff --git a/app/src/features/crews/autoMode.test.tsx b/app/src/features/crews/autoMode.test.tsx new file mode 100644 index 00000000..41274cd7 --- /dev/null +++ b/app/src/features/crews/autoMode.test.tsx @@ -0,0 +1,74 @@ +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openNewCrewForm, renderApp } from "../../test/app"; + +afterEach(cleanup); + +describe("the crew's automatic mode", () => { + test("the new-crew form has the switch, and the crew is created in automatic mode", async () => { + const fake = new FakeBotloft(); + fake.addCrew("Ops"); + renderApp(fake); + await crewOpened("Ops"); + + const dialog = await openNewCrewForm(); + fireEvent.change(within(dialog).getByLabelText("Name"), { target: { value: "Sales" } }); + const toggle = within(dialog).getByRole("switch", { name: "Automatic mode" }); + expect(toggle.getAttribute("aria-checked")).toBe("false"); + fireEvent.click(toggle); + fireEvent.click(within(dialog).getByRole("button", { name: "Create crew" })); + + await crewOpened("Chief"); + const create = fake.calls.find((call) => call.method === "crews.create"); + expect(create?.params).toMatchObject({ name: "Sales", autoMode: true }); + const crew = [...fake.crews.values()].find((item) => item.name === "Sales"); + expect(crew?.autoMode).toBe(true); + const chief = [...fake.bots.values()].find((bot) => bot.crewId === crew?.id); + expect(chief?.permissionMode).toBe("auto"); + }); + + test("a crew made without it stays manual", async () => { + const fake = new FakeBotloft(); + fake.addCrew("Ops"); + renderApp(fake); + await crewOpened("Ops"); + + const dialog = await openNewCrewForm(); + fireEvent.change(within(dialog).getByLabelText("Name"), { target: { value: "Plain" } }); + fireEvent.click(within(dialog).getByRole("button", { name: "Create crew" })); + + await crewOpened("Chief"); + const create = fake.calls.find((call) => call.method === "crews.create"); + expect(create?.params).not.toHaveProperty("autoMode"); + }); + + test("the header switch turns the whole crew to automatic, and keeps it for new agents", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + fake.addBot(crew.id, "Scout"); + renderApp(fake); + await crewOpened("Ops"); + + fireEvent.click(screen.getByRole("switch", { name: "Automatic mode" })); + await waitFor(() => + expect(fake.calls.some((call) => call.method === "crews.setAutoMode")).toBe(true), + ); + await waitFor(() => + expect( + screen.getByRole("switch", { name: "Automatic mode" }).getAttribute("aria-checked"), + ).toBe("true"), + ); + for (const bot of fake.activeBots(crew.id)) { + expect(bot.permissionMode).toBe("auto"); + } + + const added = await fake.call("bots.create", { + crewId: crew.id, + name: "Later", + role: "Helps", + instructions: "", + }); + expect(added.permissionMode).toBe("auto"); + }); +}); diff --git a/app/src/features/crews/crewActions.tsx b/app/src/features/crews/crewActions.tsx index 707ba76a..e64cccae 100644 --- a/app/src/features/crews/crewActions.tsx +++ b/app/src/features/crews/crewActions.tsx @@ -34,6 +34,8 @@ export interface CrewActions { pause: MenuItem & { icon: LucideIcon }; /** Opens the work folder in the system's file manager. */ openFolder(): void; + /** Turns the crew's automatic mode on or off (spec 7.4). */ + setAutoMode(enabled: boolean): void; /** Rename, the folder, archive and delete. */ items: MenuItem[]; /** The dialog an action opened, `null` while none is. */ @@ -68,6 +70,11 @@ export function useCrewActions(crew: Crew): CrewActions { putCrew(await api.call("crews.setPaused", { crewId: crew.id, paused })), ); + const setAutoMode = (enabled: boolean) => + attempt(t.crews.autoMode.failed, async () => + putCrew(await api.call("crews.setAutoMode", { crewId: crew.id, enabled })), + ); + let dialogs: ReactNode | null = null; if (open === "bot") { dialogs = ; @@ -114,6 +121,7 @@ export function useCrewActions(crew: Crew): CrewActions { newBot: { label: t.crews.newBot, icon: Plus, onSelect: () => setOpen("bot") }, agency: { label: t.catalog.open, onSelect: () => setOpen("agency") }, openFolder, + setAutoMode, pause: crew.paused ? { label: words.resume, icon: Play, onSelect: () => setPaused(false) } : { label: words.pause, icon: Pause, onSelect: () => setPaused(true) }, diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts index 95b7629b..e580edbe 100644 --- a/app/src/i18n/en/crews.ts +++ b/app/src/i18n/en/crews.ts @@ -5,6 +5,12 @@ export const crews = { newCrew: "New crew", newBot: "New agent", rename: "Rename", + /** The crew's automatic mode (spec 7.4): a switch in the new-crew form and in the crew's header. */ + autoMode: { + label: "Automatic mode", + hint: "Every agent in this crew works without asking at each step, including the ones you add later. Anything risky still comes to you.", + failed: "Could not change the automatic mode", + }, paused: "Paused", sidebar: { /** The right-click menu of a crew in the list. */ diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts index 50fb9ed1..dbda7ea8 100644 --- a/app/src/i18n/es/crews.ts +++ b/app/src/i18n/es/crews.ts @@ -4,6 +4,12 @@ export const crews: Messages["crews"] = { newCrew: "Nuevo equipo", newBot: "Nuevo agente", rename: "Renombrar", + /** The crew's automatic mode (spec 7.4): a switch in the new-crew form and in the crew's header. */ + autoMode: { + label: "Modo automático", + hint: "Todos los agentes de este equipo trabajan sin preguntar en cada paso, incluidos los que añadas después. Lo que sea arriesgado sigue llegando a ti.", + failed: "No se pudo cambiar el modo automático", + }, paused: "En pausa", sidebar: { menuOf: (crew) => `Acciones de ${crew}`, diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts index 5a2fe6de..98d93938 100644 --- a/app/src/i18n/pt-BR/crews.ts +++ b/app/src/i18n/pt-BR/crews.ts @@ -4,6 +4,12 @@ export const crews: Messages["crews"] = { newCrew: "Nova equipe", newBot: "Novo agente", rename: "Renomear", + /** The crew's automatic mode (spec 7.4): a switch in the new-crew form and in the crew's header. */ + autoMode: { + label: "Modo automático", + hint: "Todos os agentes desta equipe trabalham sem perguntar a cada passo, inclusive os que você adicionar depois. O que for arriscado ainda chega até você.", + failed: "Não foi possível mudar o modo automático", + }, paused: "Pausada", sidebar: { menuOf: (crew) => `Ações de ${crew}`, diff --git a/app/src/lib/fakeBots.ts b/app/src/lib/fakeBots.ts index 7c803c8d..1ea2ef20 100644 --- a/app/src/lib/fakeBots.ts +++ b/app/src/lib/fakeBots.ts @@ -32,7 +32,7 @@ export function botHandlers(fake: FakeBotloft): Pick { instructions, color: color ?? AVATAR_PALETTE[index % AVATAR_PALETTE.length] ?? "#FF7A59", paused: false, - permissionMode: "default", + permissionMode: crew.autoMode ? "auto" : "default", agent: agent ?? "claude", agentModel: null, allowedCommands: [], diff --git a/app/src/lib/fakeCrews.ts b/app/src/lib/fakeCrews.ts index dafb5877..984908c1 100644 --- a/app/src/lib/fakeCrews.ts +++ b/app/src/lib/fakeCrews.ts @@ -22,7 +22,7 @@ function checkFolder(folder: string): string { export function crewHandlers(fake: FakeBotloft): Pick { return { "crews.list": () => [...fake.crews.values()].filter((crew) => crew.archivedAt === null), - "crews.create": ({ name, workFolder, lead }) => { + "crews.create": ({ name, workFolder, lead, autoMode }) => { const checked = checkName(name); const slug = slugify(checked, "crew"); const crew: Crew = { @@ -34,6 +34,7 @@ export function crewHandlers(fake: FakeBotloft): Pick { leadBotId: null, color: null, paused: false, + autoMode: autoMode === true, createdAt: fake.now, archivedAt: null, }; @@ -69,6 +70,19 @@ export function crewHandlers(fake: FakeBotloft): Pick { crew.paused = paused; return fake.changedCrew(crew); }, + "crews.setAutoMode": ({ crewId, enabled }) => { + const crew = fake.crew(crewId); + crew.autoMode = enabled; + if (enabled) { + for (const bot of fake.activeBots(crewId)) { + if (bot.permissionMode !== "auto") { + bot.permissionMode = "auto"; + fake.changedBot(bot); + } + } + } + return fake.changedCrew(crew); + }, "crews.setWorkFolder": ({ crewId, workFolder }) => { const crew = fake.crew(crewId); crew.workFolder = workFolder === null ? sharedFolder(crew.slug) : checkFolder(workFolder); diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 2e1350ae..ae599039 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -113,6 +113,11 @@ workFolderChosen: boolean, * `null` when the crew has no chief. */ leadBotId: BotId | null, paused: boolean, +/** + * Whether every bot of the crew runs in automatic mode, the ones it has + * and the ones that join later (spec 7.4). + */ +autoMode: boolean, /** * Color `#RRGGBB` the owner gave the crew; `null` for none. */ @@ -439,7 +444,11 @@ workFolder?: string, /** * The chief, created with the crew (spec 10.2). */ -lead?: NewLead, }; +lead?: NewLead, +/** + * Starts the crew with every bot in automatic mode; off when absent. + */ +autoMode?: boolean, }; export type CrewsRenameParams = { crewId: CrewId, name: string, }; @@ -451,6 +460,8 @@ color: string | null, }; export type CrewsSetPausedParams = { crewId: CrewId, paused: boolean, }; +export type CrewsSetAutoModeParams = { crewId: CrewId, enabled: boolean, }; + export type CrewsSetWorkFolderParams = { crewId: CrewId, /** * `null` goes back to the crew's `shared` folder. @@ -2269,6 +2280,7 @@ export interface RpcMethods { "crews.rename": { params: CrewsRenameParams; result: Crew }; "crews.setColor": { params: CrewsSetColorParams; result: Crew }; "crews.setPaused": { params: CrewsSetPausedParams; result: Crew }; + "crews.setAutoMode": { params: CrewsSetAutoModeParams; result: Crew }; "crews.setWorkFolder": { params: CrewsSetWorkFolderParams; result: Crew }; "crews.setLead": { params: CrewsSetLeadParams; result: Crew }; "crews.archive": { params: CrewIdParams; result: Crew }; diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 299a6c9b..2e4a839e 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -147,6 +147,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -287,6 +288,11 @@ fn export_bindings() { &out.name::(), &crew, ); + out.method( + method::CREWS_SET_AUTO_MODE, + &out.name::(), + &crew, + ); out.method( method::CREWS_SET_WORK_FOLDER, &out.name::(), diff --git a/crates/botloft-core/src/protocol/methods.rs b/crates/botloft-core/src/protocol/methods.rs index 49329036..e6690e8f 100644 --- a/crates/botloft-core/src/protocol/methods.rs +++ b/crates/botloft-core/src/protocol/methods.rs @@ -186,6 +186,10 @@ pub struct CrewsCreateParams { #[serde(default)] #[cfg_attr(test, ts(optional))] pub lead: Option, + /// Starts the crew with every bot in automatic mode; off when absent. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub auto_mode: Option, } /// The bot that leads a new crew. The app writes its name and role in the @@ -245,6 +249,14 @@ pub struct CrewsSetPausedParams { pub paused: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct CrewsSetAutoModeParams { + pub crew_id: CrewId, + pub enabled: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(test, derive(ts_rs::TS))] diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index b8c4ed2c..478ec584 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -116,6 +116,7 @@ pub mod method { pub const CREWS_RENAME: &str = "crews.rename"; pub const CREWS_SET_COLOR: &str = "crews.setColor"; pub const CREWS_SET_PAUSED: &str = "crews.setPaused"; + pub const CREWS_SET_AUTO_MODE: &str = "crews.setAutoMode"; pub const CREWS_SET_WORK_FOLDER: &str = "crews.setWorkFolder"; pub const CREWS_SET_LEAD: &str = "crews.setLead"; pub const CREWS_ARCHIVE: &str = "crews.archive"; diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index d08b7526..92b6d472 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -23,6 +23,9 @@ pub struct Crew { /// `null` when the crew has no chief. pub lead_bot_id: Option, pub paused: bool, + /// Whether every bot of the crew runs in automatic mode, the ones it has + /// and the ones that join later (spec 7.4). + pub auto_mode: bool, /// Color `#RRGGBB` the owner gave the crew; `null` for none. pub color: Option, /// Unix time in milliseconds. diff --git a/crates/botloft-store/migrations/0030_crew_auto_mode.sql b/crates/botloft-store/migrations/0030_crew_auto_mode.sql new file mode 100644 index 00000000..ae313417 --- /dev/null +++ b/crates/botloft-store/migrations/0030_crew_auto_mode.sql @@ -0,0 +1,3 @@ +-- A crew can keep every bot on automatic mode: the bots it has and the ones +-- that join later (spec 7.4). +ALTER TABLE crews ADD COLUMN auto_mode INTEGER NOT NULL DEFAULT 0; diff --git a/crates/botloft-store/src/bots/tests.rs b/crates/botloft-store/src/bots/tests.rs index 95a74163..7bbc8f0f 100644 --- a/crates/botloft-store/src/bots/tests.rs +++ b/crates/botloft-store/src/bots/tests.rs @@ -14,6 +14,7 @@ fn setup() -> (Store, CrewId) { lead_bot_id: None, paused: false, color: None, + auto_mode: false, created_at: 1, archived_at: None, }; diff --git a/crates/botloft-store/src/crews.rs b/crates/botloft-store/src/crews.rs index 7a1dab04..574c145b 100644 --- a/crates/botloft-store/src/crews.rs +++ b/crates/botloft-store/src/crews.rs @@ -7,7 +7,7 @@ use rusqlite::{OptionalExtension, Row, params}; use crate::{Result, Store, parse_column, unique_as_duplicate}; const COLUMNS: &str = - "id, name, slug, paused, created_at, archived_at, work_dir, lead_bot_id, color"; + "id, name, slug, paused, created_at, archived_at, work_dir, lead_bot_id, color, auto_mode"; /// A crew without a chosen folder comes back with an empty `work_folder`; /// the daemon fills in the `shared` folder, which depends on its paths. @@ -24,6 +24,7 @@ fn from_row(row: &Row<'_>) -> rusqlite::Result { .and_then(|id| id.parse().ok()), paused: row.get(3)?, color: row.get(8)?, + auto_mode: row.get(9)?, created_at: row.get(4)?, archived_at: row.get(5)?, }) @@ -38,7 +39,7 @@ impl Store { self.conn .execute( &format!( - "INSERT INTO crews ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)" + "INSERT INTO crews ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)" ), params![ crew.id.as_str(), @@ -49,7 +50,8 @@ impl Store { crew.archived_at, work_dir(crew), crew.lead_bot_id.as_ref().map(|id| id.as_str()), - crew.color + crew.color, + crew.auto_mode ], ) .map_err(|err| unique_as_duplicate(err, "crew slug"))?; @@ -86,11 +88,11 @@ impl Store { } /// Saves the mutable fields: name, paused, archived_at, the chosen work - /// folder, the chief and the color. + /// folder, the chief, the color and the automatic mode. pub fn update_crew(&self, crew: &Crew) -> Result<()> { self.conn.execute( "UPDATE crews SET name = ?2, paused = ?3, archived_at = ?4, work_dir = ?5, \ - lead_bot_id = ?6, color = ?7 WHERE id = ?1", + lead_bot_id = ?6, color = ?7, auto_mode = ?8 WHERE id = ?1", params![ crew.id.as_str(), crew.name, @@ -98,7 +100,8 @@ impl Store { crew.archived_at, work_dir(crew), crew.lead_bot_id.as_ref().map(|id| id.as_str()), - crew.color + crew.color, + crew.auto_mode ], )?; Ok(()) @@ -135,6 +138,7 @@ mod tests { lead_bot_id: None, paused: false, color: None, + auto_mode: false, created_at, archived_at: None, } diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index aadaf6cb..e8438768 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -177,6 +177,7 @@ pub(crate) mod tests { lead_bot_id: None, paused: false, color: None, + auto_mode: false, created_at: 0, archived_at: None, }; diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index 3baf080f..f302db6c 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -39,6 +39,7 @@ const MIGRATIONS: &[(u32, &str)] = &[ include_str!("../migrations/0028_bot_allowed_commands.sql"), ), (29, include_str!("../migrations/0029_read_through.sql")), + (30, include_str!("../migrations/0030_crew_auto_mode.sql")), ]; /// Schema version after every migration has run. diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index e9831647..7f74784e 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -32,6 +32,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(crews::rename(daemon, parse(params)?)), method::CREWS_SET_COLOR => reply(crews::set_color(daemon, parse(params)?)), method::CREWS_SET_PAUSED => reply(crews::set_paused(daemon, parse(params)?)), + method::CREWS_SET_AUTO_MODE => reply(crews::set_auto_mode(daemon, parse(params)?)), method::CREWS_SET_WORK_FOLDER => reply(crews::set_work_folder(daemon, parse(params)?)), method::CREWS_SET_LEAD => reply(lead::set_lead(daemon, parse(params)?)), method::CREWS_ARCHIVE => reply(crews::archive(daemon, parse(params)?)), diff --git a/crates/botloftd/src/service/bots.rs b/crates/botloftd/src/service/bots.rs index 0098779c..5ddcaf3c 100644 --- a/crates/botloftd/src/service/bots.rs +++ b/crates/botloftd/src/service/bots.rs @@ -129,7 +129,11 @@ pub(crate) fn insert( instructions: new.instructions, color, paused: false, - permission_mode: PermissionMode::Default, + permission_mode: if crew.auto_mode { + PermissionMode::Auto + } else { + PermissionMode::Default + }, agent: new.agent, agent_model: None, allowed_commands: Vec::new(), diff --git a/crates/botloftd/src/service/crews.rs b/crates/botloftd/src/service/crews.rs index 2a6d0205..01f9f44e 100644 --- a/crates/botloftd/src/service/crews.rs +++ b/crates/botloftd/src/service/crews.rs @@ -3,8 +3,8 @@ use botloft_core::avatar::normalize_color; use botloft_core::ids::CrewId; use botloft_core::protocol::{ - Crew, CrewIdParams, CrewsCreateParams, CrewsRenameParams, CrewsSetColorParams, - CrewsSetPausedParams, CrewsSetWorkFolderParams, + Crew, CrewIdParams, CrewsCreateParams, CrewsRenameParams, CrewsSetAutoModeParams, + CrewsSetColorParams, CrewsSetPausedParams, CrewsSetWorkFolderParams, PermissionMode, }; use botloft_core::{now_ms, slug, validate}; use botloft_store::Store; @@ -58,6 +58,7 @@ pub fn create(daemon: &Daemon, params: CrewsCreateParams) -> ApiResult { work_folder: chosen.unwrap_or_default(), lead_bot_id: None, paused: false, + auto_mode: params.auto_mode.unwrap_or(false), color: None, created_at: now_ms(), archived_at: None, @@ -161,6 +162,38 @@ pub fn set_paused(daemon: &Daemon, params: CrewsSetPausedParams) -> ApiResult ApiResult { + let store = daemon.store(); + let mut crew = active(&store, ¶ms.crew_id)?; + if crew.auto_mode == params.enabled && !params.enabled { + return Ok(present(daemon, crew)); + } + crew.auto_mode = params.enabled; + store.update_crew(&crew)?; + let mut moved = Vec::new(); + if params.enabled { + for mut record in store.bots(Some(&crew.id), false)? { + if record.permission_mode == PermissionMode::Auto { + continue; + } + record.permission_mode = PermissionMode::Auto; + store.update_bot(&record)?; + moved.push(bots::changed(daemon, &store, &crew, record).id); + } + } + let crew = changed(daemon, crew); + drop(store); + for id in &moved { + daemon.supervisor.launch_settings_changed(id); + } + tracing::info!(crew = %crew.id, enabled = params.enabled, "automatic mode changed"); + Ok(crew) +} + /// Archives the crew and its bots. Archiving twice is not an error. pub fn archive(daemon: &Daemon, params: CrewIdParams) -> ApiResult { let store = daemon.store(); diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index c7d67bd9..54fcf314 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -296,6 +296,7 @@ mod tests { lead_bot_id: None, paused: false, color: None, + auto_mode: false, created_at: 0, archived_at: None, }; diff --git a/crates/botloftd/src/workspace/folder.rs b/crates/botloftd/src/workspace/folder.rs index a6dd6355..b2cb434c 100644 --- a/crates/botloftd/src/workspace/folder.rs +++ b/crates/botloftd/src/workspace/folder.rs @@ -147,6 +147,7 @@ mod tests { lead_bot_id: None, paused: false, color: None, + auto_mode: false, created_at: 0, archived_at: None, } diff --git a/crates/botloftd/src/workspace/mod.rs b/crates/botloftd/src/workspace/mod.rs index ebdb35f5..c03bfc13 100644 --- a/crates/botloftd/src/workspace/mod.rs +++ b/crates/botloftd/src/workspace/mod.rs @@ -146,6 +146,7 @@ mod tests { lead_bot_id: None, paused: false, color: None, + auto_mode: false, created_at: 0, archived_at: None, }; @@ -248,6 +249,7 @@ mod tests { lead_bot_id: None, paused: false, color: None, + auto_mode: false, created_at: 0, archived_at: None, }; diff --git a/crates/botloftd/tests/agy_bot.rs b/crates/botloftd/tests/agy_bot.rs index cd35a53b..97a4663c 100644 --- a/crates/botloftd/tests/agy_bot.rs +++ b/crates/botloftd/tests/agy_bot.rs @@ -34,6 +34,7 @@ async fn agy_bot() -> Agy { let crew = crews::create( &parts.daemon, CrewsCreateParams { + auto_mode: None, name: "Ops".into(), work_folder: None, lead: None, @@ -421,6 +422,7 @@ async fn an_agy_bot_starts_without_claude_code_and_a_claude_bot_stays_offline() let crew = crews::create( &parts.daemon, CrewsCreateParams { + auto_mode: None, name: "Ops".into(), work_folder: None, lead: None, @@ -559,6 +561,7 @@ async fn new_bots_and_the_chief_follow_the_default_agent() { let crew = crews::create( &agy.daemon, CrewsCreateParams { + auto_mode: None, name: "Docs".into(), work_folder: None, lead: Some(botloft_core::protocol::NewLead { diff --git a/crates/botloftd/tests/codex_bot.rs b/crates/botloftd/tests/codex_bot.rs index ee788dd2..d9e511ea 100644 --- a/crates/botloftd/tests/codex_bot.rs +++ b/crates/botloftd/tests/codex_bot.rs @@ -34,6 +34,7 @@ async fn codex_bot() -> Codex { let crew = crews::create( &parts.daemon, CrewsCreateParams { + auto_mode: None, name: "Ops".into(), work_folder: None, lead: None, @@ -471,6 +472,7 @@ async fn a_folder_botloft_fences_is_refused_without_asking() { let other = crews::create( &codex.daemon, CrewsCreateParams { + auto_mode: None, name: "Other".into(), work_folder: None, lead: None, diff --git a/crates/botloftd/tests/common/supervised.rs b/crates/botloftd/tests/common/supervised.rs index 14d24c1a..14718eb1 100644 --- a/crates/botloftd/tests/common/supervised.rs +++ b/crates/botloftd/tests/common/supervised.rs @@ -29,6 +29,7 @@ pub async fn setup() -> Setup { let crew = crews::create( &daemon, CrewsCreateParams { + auto_mode: None, name: "Ops".into(), work_folder: None, lead: None, diff --git a/crates/botloftd/tests/crew_folders.rs b/crates/botloftd/tests/crew_folders.rs index fd87afa1..47ad666d 100644 --- a/crates/botloftd/tests/crew_folders.rs +++ b/crates/botloftd/tests/crew_folders.rs @@ -18,6 +18,7 @@ fn crew(parts: &Parts, work_folder: Option<&Path>) -> Result { crews::create( &parts.daemon, CrewsCreateParams { + auto_mode: None, name: "Site".into(), work_folder: work_folder.map(|path| path.display().to_string()), lead: None, diff --git a/crates/botloftd/tests/routine_signals.rs b/crates/botloftd/tests/routine_signals.rs index 589ab2d3..f8f1ccb0 100644 --- a/crates/botloftd/tests/routine_signals.rs +++ b/crates/botloftd/tests/routine_signals.rs @@ -121,6 +121,7 @@ async fn only_enabled_routines_of_the_crew_hear_a_signal() { let elsewhere = crews::create( &s.daemon, CrewsCreateParams { + auto_mode: None, name: "Home".into(), work_folder: None, lead: None, diff --git a/crates/botloftd/tests/rpc_crews_bots.rs b/crates/botloftd/tests/rpc_crews_bots.rs index 0169440f..3a3a47ab 100644 --- a/crates/botloftd/tests/rpc_crews_bots.rs +++ b/crates/botloftd/tests/rpc_crews_bots.rs @@ -319,3 +319,75 @@ async fn invalid_input_is_reported_with_the_validation_code() { NOT_FOUND ); } + +#[tokio::test] +async fn automatic_mode_is_a_rule_of_the_crew() { + let daemon = TestDaemon::start().await; + let mut app = daemon.session().await; + let create = |name: &'static str, crew: Value| json!({ "crewId": crew, "name": name, "role": "Helps", "instructions": "Be useful." }); + + // A crew that starts in automatic mode: its bots start in it too. + let auto = app + .call("crews.create", json!({ "name": "Auto", "autoMode": true })) + .await + .expect("crew"); + assert_eq!(auto["autoMode"], true); + let bot = app + .call("bots.create", create("Scout", auto["id"].clone())) + .await + .expect("bot"); + assert_eq!(bot["permissionMode"], "auto"); + + // An ordinary crew starts in Manual; turning the rule on moves the bots + // it has and the ones that join later. + let manual = app + .call("crews.create", json!({ "name": "Manual" })) + .await + .expect("crew"); + assert_eq!(manual["autoMode"], false); + let first = app + .call("bots.create", create("Writer", manual["id"].clone())) + .await + .expect("bot"); + assert_eq!(first["permissionMode"], "default"); + + let on = app + .call( + "crews.setAutoMode", + json!({ "crewId": manual["id"], "enabled": true }), + ) + .await + .expect("on"); + assert_eq!(on["autoMode"], true); + let bots = app + .call("bots.list", json!({ "crewId": manual["id"] })) + .await + .expect("list"); + assert_eq!(bots[0]["permissionMode"], "auto"); + let later = app + .call("bots.create", create("Editor", manual["id"].clone())) + .await + .expect("bot"); + assert_eq!(later["permissionMode"], "auto"); + + // Off: bots keep their mode, new ones go back to Manual. + let off = app + .call( + "crews.setAutoMode", + json!({ "crewId": manual["id"], "enabled": false }), + ) + .await + .expect("off"); + assert_eq!(off["autoMode"], false); + let newest = app + .call("bots.create", create("Tester", manual["id"].clone())) + .await + .expect("bot"); + assert_eq!(newest["permissionMode"], "default"); + let all = app + .call("bots.list", json!({ "crewId": manual["id"] })) + .await + .expect("list"); + assert_eq!(all[0]["permissionMode"], "auto"); + assert_eq!(all[0]["id"], first["id"]); +} diff --git a/crates/botloftd/tests/supervisor.rs b/crates/botloftd/tests/supervisor.rs index c74c04d7..d0ceee20 100644 --- a/crates/botloftd/tests/supervisor.rs +++ b/crates/botloftd/tests/supervisor.rs @@ -198,6 +198,7 @@ async fn a_failed_spawn_backs_off_and_retries() { let crew = crews::create( &daemon, CrewsCreateParams { + auto_mode: None, name: "Ops".into(), work_folder: None, lead: None, diff --git a/crates/botloftd/tests/supervisor_launch.rs b/crates/botloftd/tests/supervisor_launch.rs index 71816400..3fd0c23f 100644 --- a/crates/botloftd/tests/supervisor_launch.rs +++ b/crates/botloftd/tests/supervisor_launch.rs @@ -19,6 +19,7 @@ fn add_bot(daemon: &Daemon) -> BotId { let crew = crews::create( daemon, CrewsCreateParams { + auto_mode: None, name: "Ops".into(), work_folder: None, lead: None, diff --git a/docs/spec.md b/docs/spec.md index d36ad54d..9da1f0c3 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -261,6 +261,7 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - Qualquer outra ferramenta que peça permissão passa pela tool de aprovação (10.1) e vira um pedido no chat. - `--add-dir` põe a pasta de trabalho da crew (5) entre as pastas do bot. Sem ela, gravar na `shared\`, que fica fora do workspace, pedia aprovação até em "Aceitar edições" (19). - `--permission-mode` vem do modo do bot (`Bot.permissionMode`), que o dono escolhe no chat (15.1): `default` (Manual: pergunta antes de editar, rodar comandos e usar a rede), `accept_edits` (`acceptEdits`: edita arquivos sem perguntar), `plan` (planeja e pede para seguir, 10.1), `auto` (um classificador libera o que é seguro e o resto vira pedido) e `bypass_permissions` (`bypassPermissions`: faz tudo sem perguntar, ver 13). Bot novo começa em `default`. `dontAsk` não é oferecido: nega o que não estiver liberado, e o bot não teria como pedir. Regras `deny` valem em todos os modos. + - **Modo automático da crew.** `Crew.autoMode` (`crews.create` com `autoMode`, ou `crews.setAutoMode`) é uma regra da equipe: ligada, os bots que ela tem passam ao modo `auto` e todo bot novo nasce nele, seja qual for o caminho (criado pelo dono, da Agência, o chefe, uma sugestão aprovada). O dono ainda muda o modo de um bot em particular no chat; a regra só decide o que é aplicado agora e o que um bot novo recebe. Desligar não volta os bots ao Manual. No app é uma chave "Modo automático" no formulário de nova equipe e no cabeçalho da crew. O Codex trata `auto` como o Manual (pede a aprovação do dono) e o Antigravity não tem modo (30): a regra vale, mas o efeito é do Claude Code. - O Claude Code só lê a flag ao iniciar e não entra em `bypassPermissions` no meio da sessão. Por isso mudar o modo reinicia o processo com `--resume`, e a conversa continua: na hora se o bot está parado, ou quando o turno em andamento e as aprovações dele terminam, sem cortar o trabalho. Um bot que ainda não teve turno não tem conversa a retomar e reinicia com `--session-id` (7.3). - `--model` vem do modelo do bot (`Bot.model`), que o dono escolhe no chat ou ao criar o bot (15.1): `fable`, `opus`, `sonnet` ou `haiku`, os apelidos do Claude Code, que apontam sempre para a versão mais nova de cada família. `default` deixa a flag de fora, e o Claude Code usa o padrão da conta, que depende do plano (19). Bot novo começa em `default`. O Claude Code não troca de modelo sozinho conforme a tarefa. Mudar o modelo reinicia o processo como a troca de modo, e `--resume` com `--model` passa a usar o modelo novo. O `system/init` de cada turno traz o modelo em uso (`claude-opus-5-5`); o daemon o guarda em `Bot.modelInUse` para o app dizer qual é o padrão do plano. Um modelo que a conta não pode usar falha o turno com o erro `model_not_found` (8.1), e o processo continua vivo. - `--effort` vem do esforço do bot (`Bot.effort`), que o dono escolhe no chat (15.1): `low`, `medium`, `high`, `xhigh` ou `max`, os níveis do Claude Code, do mais rápido ao que mais pensa. Quanto mais alto, mais o bot pensa antes de responder e mais gasta do plano. `default` deixa a flag de fora, e o Claude Code usa o nível que ele mesmo define para o modelo (19); é o recomendado. Bot novo começa em `default`. A flag vale só para a sessão e não é guardada pelo Claude Code: o daemon a passa de novo a cada start. Mudar o esforço reinicia o processo como a troca de modo e de modelo, com `--resume`, quando nada estiver em andamento. O Claude Code também aceita a troca no meio da sessão (`/effort ` como mensagem, ou um pedido de controle), mas o daemon reinicia, como faz com o modelo: a linha de comando continua sendo a única fonte do que o bot roda (19). @@ -687,6 +688,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `crews.rename` | `crewId, name` | `Crew` | | `crews.setColor` | `crewId, color` (`#RRGGBB`, ou `null` para tirar) | `Crew`; a cor aparece como um ponto ao lado do nome na lista de equipes | | `crews.setPaused` | `crewId, paused` | `Crew` | +| `crews.setAutoMode` | `crewId, enabled` | `Crew`; ligado, todo bot da crew passa ao modo `auto` (cada um reinicia quando nada estiver em andamento, 7.4) e todo bot que entrar depois já nasce nele (`bots.create`, `catalog.add`, o chefe, sugestões aprovadas); desligado, os bots ficam como estão e os novos voltam ao Manual | | `crews.setLead` | `crewId, botId` (ou `null`, sem chefe) | `Crew`; o chefe antigo e o novo reiniciam quando nada estiver em andamento (10.2) | | `crews.setWorkFolder` | `crewId, workFolder` (caminho, ou `null` para voltar à `shared\`) | `Crew`; os bots reiniciam na pasta nova quando nada estiver em andamento (5) | | `crews.archive` | `crewId` | `Crew` |