Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,10 @@ like this:
| Usage meter and "Compact" | yes | an estimate of the conversation size; no compaction |
| Isolation between crews | yes | folders are fenced for its file tools, not for commands you allow |

Claude Code is the full experience. Antigravity and Codex are off until you list them in Botloft's
`config.toml` (`experimental_agents = ["agy"]`, `["codex"]` or both); then a new agent can be made on
them, and Settings has "New agents run on". A Codex agent asks you before it changes a file or runs a
Claude Code is the full experience. Antigravity and Codex are off until you turn on "Let me choose
Antigravity and Codex" in Settings (or list them in Botloft's `config.toml`, `experimental_agents =
["agy"]`, `["codex"]` or both); then a new agent can be made on them, and Settings has "New agents run
on". A Codex agent asks you before it changes a file or runs a
command, like Claude Code, and shows the exact size of its conversation; it can still read any file.
It has its own model list and effort picker, and understands images. See [spec section 30](docs/spec.md) for what was
checked.
Expand Down
9 changes: 9 additions & 0 deletions app/src/features/account/GeneralSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,15 @@ export function GeneralSettings() {
<Section title={s.language}>
<Select label={s.language} value={locale} options={languages} onChange={setLocaleChoice} />
</Section>
<Section title={s.experimentalAgents}>
<Toggle
label={s.showExperimental}
hint={settings?.showExperimentalAgents ? s.showExperimentalOn : s.showExperimentalOff}
checked={settings?.showExperimentalAgents ?? false}
disabled={!settings}
onChange={(on) => change({ showExperimentalAgents: on })}
/>
</Section>
{settings && enabledAgents.length > 1 && (
<Section title={s.defaultAgent}>
<Select
Expand Down
21 changes: 21 additions & 0 deletions app/src/features/account/settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,34 @@ describe("settings", () => {
keepAwake: false,
approvalWaitMinutes: 60,
defaultAgent: "claude",
showExperimentalAgents: false,
}),
);
expect(
fake.calls.filter((call) => call.method === "settings.update").map((c) => c.params),
).toEqual([{ startWithWindows: false }, { keepAwake: false }]);
});

test("the experimental agents are switched on here, with what that means", async () => {
const { fake } = renderApp();
const dialog = await openSettings();
const show = within(dialog).getByRole("switch", {
name: "Let me choose Antigravity and Codex",
});
await waitFor(() => expect(show.hasAttribute("disabled")).toBe(false));
expect(dialog.textContent).toContain("new agents always run on Claude Code");
expect(within(dialog).queryByText("New agents run on")).toBeNull();

fireEvent.click(show);
expect(show.getAttribute("aria-checked")).toBe("true");
expect(dialog.textContent).toContain("can read any file on this computer");
// The choice of agent for new agents appears without waiting.
expect(await within(dialog).findByText("New agents run on")).toBeDefined();
expect(
fake.calls.filter((call) => call.method === "settings.update").map((c) => c.params),
).toEqual([{ showExperimentalAgents: true }]);
});

test("the icon and the window at sign-in show only where they apply", async () => {
renderApp();
const dialog = await openSettings();
Expand Down
13 changes: 10 additions & 3 deletions app/src/features/account/useDaemonSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export function useDaemonSettings(): {
const failed = useT().account.settings.saveFailed;
const settings = useApp((state) => state.settings);
const putSettings = useApp((state) => state.putSettings);
const putSystem = useApp((state) => state.putSystem);

const change = (update: SettingsUpdateParams) => {
const before = settings;
Expand All @@ -24,11 +25,17 @@ export function useDaemonSettings(): {
keepAwake: update.keepAwake ?? before.keepAwake,
approvalWaitMinutes: update.approvalWaitMinutes ?? before.approvalWaitMinutes,
defaultAgent: update.defaultAgent ?? before.defaultAgent,
showExperimentalAgents: update.showExperimentalAgents ?? before.showExperimentalAgents,
});
}
attempt(failed, async () => putSettings(await api.call("settings.update", update))).then(
(saved) => saved || putSettings(before),
);
attempt(failed, async () => {
putSettings(await api.call("settings.update", update));
// The agents to choose from follow this setting; the status has no
// notification, so it is read again now.
if (update.showExperimentalAgents !== undefined) {
putSystem(await api.call("system.status"));
}
}).then((saved) => saved || putSettings(before));
};

return { settings, change };
Expand Down
5 changes: 5 additions & 0 deletions app/src/i18n/en/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,11 @@ export const account = {
pages: "Parts of Settings",
general: "General",
defaultAgent: "New agents run on",
experimentalAgents: "Experimental agents",
showExperimental: "Let me choose Antigravity and Codex",
showExperimentalOff: "Off: new agents always run on Claude Code.",
showExperimentalOn:
"Still being tested. An agent that runs on Codex can read any file on this computer, and one that runs on Antigravity cannot ask you before it acts. Agents already made on them keep working either way.",
defaultAgentHint:
"Used for new agents, the chief of a new crew and the agents a template adds. Each agent keeps the engine it was made with.",
chat: "Chat",
Expand Down
5 changes: 5 additions & 0 deletions app/src/i18n/es/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,11 @@ export const account: Messages["account"] = {
pages: "Partes de la configuración",
general: "General",
defaultAgent: "Los agentes nuevos funcionan con",
experimentalAgents: "Agentes experimentales",
showExperimental: "Poder elegir Antigravity y Codex",
showExperimentalOff: "Apagado: los agentes nuevos siempre funcionan con Claude Code.",
showExperimentalOn:
"Todavía en pruebas. Un agente que funciona con Codex puede leer cualquier archivo de este equipo, y uno que funciona con Antigravity no puede preguntarte antes de actuar. Los agentes ya creados con ellos siguen funcionando igual.",
defaultAgentHint:
"Vale para los agentes nuevos, el jefe de un equipo nuevo y los agentes que añade una plantilla. Cada agente conserva el motor con el que se creó.",
chat: "Chat",
Expand Down
5 changes: 5 additions & 0 deletions app/src/i18n/pt-BR/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,11 @@ export const account: Messages["account"] = {
pages: "Partes das configurações",
general: "Geral",
defaultAgent: "Os agentes novos rodam em",
experimentalAgents: "Agentes experimentais",
showExperimental: "Deixar-me escolher Antigravity e Codex",
showExperimentalOff: "Desligado: os agentes novos sempre rodam no Claude Code.",
showExperimentalOn:
"Ainda em teste. Um agente que roda no Codex pode ler qualquer arquivo deste computador, e um que roda no Antigravity não consegue perguntar a você antes de agir. Os agentes que já foram feitos neles continuam funcionando de qualquer jeito.",
defaultAgentHint:
"Vale para agentes novos, o chefe de uma equipe nova e os agentes que um modelo de equipe adiciona. Cada agente guarda o motor com que foi criado.",
chat: "Conversa",
Expand Down
9 changes: 9 additions & 0 deletions app/src/lib/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export class FakeBotloft implements BotloftApi {
keepAwake: true,
approvalWaitMinutes: 60,
defaultAgent: "claude",
showExperimentalAgents: false,
};
/** Folders the owner sent to the Recycle Bin with a delete, in order. */
readonly recycled: string[] = [];
Expand Down Expand Up @@ -279,7 +280,15 @@ export class FakeBotloft implements BotloftApi {
keepAwake: change.keepAwake ?? this.settings.keepAwake,
approvalWaitMinutes: change.approvalWaitMinutes ?? this.settings.approvalWaitMinutes,
defaultAgent: change.defaultAgent ?? this.settings.defaultAgent,
showExperimentalAgents:
change.showExperimentalAgents ?? this.settings.showExperimentalAgents,
};
if (change.showExperimentalAgents !== undefined) {
this.system = {
...this.system,
enabledAgents: change.showExperimentalAgents ? ["claude", "agy", "codex"] : ["claude"],
};
}
return this.settings;
},
"archive.list": () => ({
Expand Down
9 changes: 7 additions & 2 deletions app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1870,7 +1870,12 @@ approvalWaitMinutes: number,
* The agent bots are made for when nothing says otherwise: new bots,
* the chief of a new crew, the bots of a template (spec 30).
*/
defaultAgent: AgentKind, };
defaultAgent: AgentKind,
/**
* The experimental agents (Antigravity and Codex) can be chosen for
* new bots. Off, bots that already use one keep working (spec 30).
*/
showExperimentalAgents: boolean, };

/**
* The settings to change; the ones left out stay as they are.
Expand All @@ -1879,7 +1884,7 @@ export type SettingsUpdateParams = { startWithWindows?: boolean, keepAwake?: boo
/**
* From 1 to [`APPROVAL_WAIT_MAX_MINUTES`].
*/
approvalWaitMinutes?: number, defaultAgent?: AgentKind, };
approvalWaitMinutes?: number, defaultAgent?: AgentKind, showExperimentalAgents?: boolean, };

/**
* When a routine runs (spec 20.2), kept as structure rather than cron text
Expand Down
2 changes: 2 additions & 0 deletions app/src/store/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export interface AppState {
putRoutine(routine: Routine): void;
putBrowser(browser: BrowserState): void;
putSettings(settings: Settings | null): void;
putSystem(system: SystemStatus): void;
}

export type AppStore = StoreApi<AppState>;
Expand Down Expand Up @@ -202,6 +203,7 @@ export function createAppStore(api: BotloftApi): AppStore {
putRoutine: (routine) => set((state) => withRoutine(state, routine)),
putBrowser: (browser) => set((state) => withBrowser(state, browser)),
putSettings: (settings) => set({ settings }),
putSystem: (system) => set({ system }),
putDelivery: (delivery) =>
set((state) => ({ deliveries: { ...state.deliveries, [delivery.messageId]: delivery } })),
};
Expand Down
6 changes: 6 additions & 0 deletions crates/botloft-core/src/protocol/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ pub struct Settings {
/// The agent bots are made for when nothing says otherwise: new bots,
/// the chief of a new crew, the bots of a template (spec 30).
pub default_agent: AgentKind,
/// The experimental agents (Antigravity and Codex) can be chosen for
/// new bots. Off, bots that already use one keep working (spec 30).
pub show_experimental_agents: bool,
}

/// The settings to change; the ones left out stay as they are.
Expand All @@ -41,6 +44,9 @@ pub struct SettingsUpdateParams {
#[serde(default)]
#[cfg_attr(test, ts(optional))]
pub default_agent: Option<AgentKind>,
#[serde(default)]
#[cfg_attr(test, ts(optional))]
pub show_experimental_agents: Option<bool>,
}

/// The longest a permission request may wait for the owner: a day.
Expand Down
27 changes: 26 additions & 1 deletion crates/botloftd/src/service/settings.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
//! `settings.get` and `settings.update` (spec 11.2): what the owner
//! changes in the app's Settings.

use botloft_core::protocol::{APPROVAL_WAIT_MAX_MINUTES, Settings, SettingsUpdateParams};
use botloft_core::protocol::{
APPROVAL_WAIT_MAX_MINUTES, AgentKind, Settings, SettingsUpdateParams,
};
use tracing::info;

use super::{ApiError, ApiResult};
Expand Down Expand Up @@ -38,6 +40,29 @@ pub fn update(daemon: &Daemon, params: SettingsUpdateParams) -> ApiResult<Settin
.settings
.save(params)
.map_err(|err| ApiError::Settings(err.to_string()))?;
if let Some(show) = params.show_experimental_agents {
let names = if show {
crate::settings::EXPERIMENTAL_AGENTS
.iter()
.map(|name| (*name).to_owned())
.collect()
} else {
Vec::new()
};
daemon.supervisor.set_experimental_agents(names);
}
// Switched off, the default cannot stay an agent that cannot be chosen.
let after = if !daemon.supervisor.agent_enabled(after.default_agent) {
daemon
.settings
.save(SettingsUpdateParams {
default_agent: Some(AgentKind::Claude),
..SettingsUpdateParams::default()
})
.map_err(|err| ApiError::Settings(err.to_string()))?
} else {
after
};
if after.approval_wait_minutes > before.approval_wait_minutes {
daemon.supervisor.workspace_settings_changed();
}
Expand Down
47 changes: 47 additions & 0 deletions crates/botloftd/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ use tokio::sync::watch;

use crate::config::Config;

/// The agents the "experimental agents" setting switches on, by their name
/// in `config.toml`.
pub const EXPERIMENTAL_AGENTS: [&str; 2] = ["agy", "codex"];

pub struct LiveSettings {
/// Where they are saved; `None` keeps them in memory (tests).
file: Option<PathBuf>,
Expand All @@ -34,6 +38,7 @@ impl LiveSettings {
approval_wait_minutes: minutes,
// A name this build does not know is Claude Code.
default_agent: config.default_agent.parse().unwrap_or(AgentKind::Claude),
show_experimental_agents: !config.experimental_agents.is_empty(),
};
Self {
file,
Expand Down Expand Up @@ -91,6 +96,9 @@ impl LiveSettings {
if let Some(agent) = update.default_agent {
current.settings.default_agent = agent;
}
if let Some(show) = update.show_experimental_agents {
current.settings.show_experimental_agents = show;
}
Ok(current.settings)
}

Expand Down Expand Up @@ -125,6 +133,13 @@ fn write(file: &Path, update: SettingsUpdateParams) -> io::Result<()> {
if let Some(agent) = update.default_agent {
set(config.as_table_mut(), "default_agent", agent.as_str());
}
if let Some(show) = update.show_experimental_agents {
let mut names = toml_edit::Array::new();
if show {
names.extend(EXPERIMENTAL_AGENTS);
}
set(config.as_table_mut(), "experimental_agents", names);
}
if let Some(minutes) = update.approval_wait_minutes {
let bots = config["bots"]
.or_insert(toml_edit::table())
Expand Down Expand Up @@ -173,6 +188,7 @@ mod tests {
keep_awake: false,
approval_wait_minutes: 30,
default_agent: AgentKind::Claude,
show_experimental_agents: false,
}
);
assert!(awake.has_changed().expect("sender") && !*awake.borrow_and_update());
Expand All @@ -190,6 +206,7 @@ mod tests {
keep_awake: Some(true),
approval_wait_minutes: Some(240),
default_agent: None,
show_experimental_agents: None,
})
.expect("save");
let text = std::fs::read_to_string(&file).expect("read");
Expand All @@ -208,11 +225,41 @@ mod tests {
keep_awake: true,
approval_wait_minutes: 240,
default_agent: AgentKind::Claude,
show_experimental_agents: false,
}
);
assert_eq!(settings.approval_wait(), Duration::from_secs(4 * 3600));
}

#[test]
fn the_experimental_agents_are_listed_in_the_file_and_taken_out_again() {
let dir = tempfile::tempdir().expect("dir");
let file = dir.path().join("config.toml");
let settings = LiveSettings::new(Some(file.clone()), &Config::default());
assert!(!settings.get().show_experimental_agents);
let on = SettingsUpdateParams {
show_experimental_agents: Some(true),
..SettingsUpdateParams::default()
};
assert!(settings.save(on).expect("save").show_experimental_agents);
let config = Config::parse(&std::fs::read_to_string(&file).expect("read")).expect("parse");
assert_eq!(config.experimental_agents, ["agy", "codex"]);
// A daemon started now shows them.
assert!(
LiveSettings::new(None, &config)
.get()
.show_experimental_agents
);

let off = SettingsUpdateParams {
show_experimental_agents: Some(false),
..SettingsUpdateParams::default()
};
assert!(!settings.save(off).expect("save").show_experimental_agents);
let config = Config::parse(&std::fs::read_to_string(&file).expect("read")).expect("parse");
assert!(config.experimental_agents.is_empty());
}

#[test]
fn a_new_wait_goes_in_the_bots_table() {
let dir = tempfile::tempdir().expect("dir");
Expand Down
Loading
Loading