From fc1b8a79f048d814f0575698433d4296cf103822 Mon Sep 17 00:00:00 2001
From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com>
Date: Wed, 14 Oct 2026 14:04:56 -0300
Subject: [PATCH] feat(crews): automatic mode as a rule of the crew
A switch in the new-crew form and in the crew header. On, every agent of
the crew moves to auto mode and so does every agent that joins later, by any
path (owner, agency, chief, approved suggestion). crews.setAutoMode and
crews.create autoMode; migration 0030. Spec 7.4 and 11.2 updated.
---
app/src/features/crews/AutoModeSwitch.tsx | 71 ++++++++++++++++++
app/src/features/crews/CrewDialog.tsx | 4 +
app/src/features/crews/CrewView.tsx | 2 +
app/src/features/crews/autoMode.test.tsx | 74 +++++++++++++++++++
app/src/features/crews/crewActions.tsx | 8 ++
app/src/i18n/en/crews.ts | 6 ++
app/src/i18n/es/crews.ts | 6 ++
app/src/i18n/pt-BR/crews.ts | 6 ++
app/src/lib/fakeBots.ts | 2 +-
app/src/lib/fakeCrews.ts | 16 +++-
app/src/lib/protocol.gen.ts | 14 +++-
crates/botloft-core/src/protocol/export.rs | 6 ++
crates/botloft-core/src/protocol/methods.rs | 12 +++
crates/botloft-core/src/protocol/mod.rs | 1 +
crates/botloft-core/src/protocol/model.rs | 3 +
.../migrations/0030_crew_auto_mode.sql | 3 +
crates/botloft-store/src/bots/tests.rs | 1 +
crates/botloft-store/src/crews.rs | 16 ++--
crates/botloft-store/src/lib.rs | 1 +
crates/botloft-store/src/migrate.rs | 1 +
crates/botloftd/src/rpc/dispatch.rs | 1 +
crates/botloftd/src/service/bots.rs | 6 +-
crates/botloftd/src/service/crews.rs | 37 +++++++++-
crates/botloftd/src/workspace/files.rs | 1 +
crates/botloftd/src/workspace/folder.rs | 1 +
crates/botloftd/src/workspace/mod.rs | 2 +
crates/botloftd/tests/agy_bot.rs | 3 +
crates/botloftd/tests/codex_bot.rs | 2 +
crates/botloftd/tests/common/supervised.rs | 1 +
crates/botloftd/tests/crew_folders.rs | 1 +
crates/botloftd/tests/routine_signals.rs | 1 +
crates/botloftd/tests/rpc_crews_bots.rs | 72 ++++++++++++++++++
crates/botloftd/tests/supervisor.rs | 1 +
crates/botloftd/tests/supervisor_launch.rs | 1 +
docs/spec.md | 2 +
35 files changed, 373 insertions(+), 12 deletions(-)
create mode 100644 app/src/features/crews/AutoModeSwitch.tsx
create mode 100644 app/src/features/crews/autoMode.test.tsx
create mode 100644 crates/botloft-store/migrations/0030_crew_auto_mode.sql
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` |