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` |