diff --git a/app/src/features/account/AgentPrograms.tsx b/app/src/features/account/AgentPrograms.tsx new file mode 100644 index 00000000..e28c6d76 --- /dev/null +++ b/app/src/features/account/AgentPrograms.tsx @@ -0,0 +1,92 @@ +// Settings, "General": where the experimental agents' programs are. Botloft +// looks in the usual places; when someone installed one somewhere else (mise, +// npm, a folder of their own), they say where here (spec 30). + +import { useState } from "react"; +import { useT } from "../../i18n"; +import type { AgentCheck } from "../../lib/protocol.gen"; +import { useApi, useApp } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { TextField } from "../../ui/Field"; +import { attempt } from "../../ui/toast"; + +const NO_CHECKS: AgentCheck[] = []; + +export function AgentPrograms() { + const checks = useApp((state) => state.system?.agentChecks ?? NO_CHECKS); + if (checks.length === 0) { + return null; + } + return ( +
+ {checks.map((check) => ( + + ))} +
+ ); +} + +function Program({ check }: { check: AgentCheck }) { + const t = useT(); + const s = t.account.settings; + const api = useApi(); + const system = useApp((state) => state.system); + const putSystem = useApp((state) => state.putSystem); + const name = t.bots.dialog.agent.names[check.agent]; + const [text, setText] = useState(""); + const [busy, setBusy] = useState(false); + + const found = check.path !== null; + const status = found + ? check.version === null + ? s.programFoundNoVersion(check.path ?? "") + : s.programFound(check.version, check.path ?? "") + : s.programMissing(name); + + const save = async (path: string) => { + setBusy(true); + const saved = await attempt(s.programFailed, async () => { + const next = await api.call("agents.setPath", { agent: check.agent, path }); + if (system) { + putSystem({ + ...system, + agentChecks: system.agentChecks.map((have) => (have.agent === next.agent ? next : have)), + }); + } + }); + setBusy(false); + if (saved) { + setText(""); + } + }; + + return ( +
+

{name}

+

+ {status} +

+
+
+ setText(event.target.value)} + /> +
+ +
+

{s.programHint}

+
+ ); +} diff --git a/app/src/features/account/GeneralSettings.tsx b/app/src/features/account/GeneralSettings.tsx index e28047e3..b711e28a 100644 --- a/app/src/features/account/GeneralSettings.tsx +++ b/app/src/features/account/GeneralSettings.tsx @@ -14,6 +14,7 @@ import { SYSTEM } from "../../lib/system"; import { prefs, usePref } from "../../shell/prefs"; import { useApp } from "../../store/context"; import { Select } from "../../ui/Select"; +import { AgentPrograms } from "./AgentPrograms"; import { Section, Toggle } from "./settingsParts"; import { useDaemonSettings } from "./useDaemonSettings"; @@ -49,6 +50,7 @@ export function GeneralSettings() { disabled={!settings} onChange={(on) => change({ showExperimentalAgents: on })} /> + {settings?.showExperimentalAgents && } {settings && enabledAgents.length > 1 && (
diff --git a/app/src/features/account/settings.test.tsx b/app/src/features/account/settings.test.tsx index 5a9067d3..91cd8ca8 100644 --- a/app/src/features/account/settings.test.tsx +++ b/app/src/features/account/settings.test.tsx @@ -86,6 +86,30 @@ describe("settings", () => { ).toEqual([{ showExperimentalAgents: true }]); }); + test("an agent that is not found says so, and the path the owner gives is used", async () => { + const fake = new FakeBotloft(); + fake.settings = { ...fake.settings, showExperimentalAgents: true }; + fake.system = { + ...fake.system, + enabledAgents: ["claude", "codex"], + agentChecks: [{ agent: "codex", version: null, path: null }], + }; + renderApp(fake); + const dialog = await openSettings(); + expect(await within(dialog).findByText(/Codex was not found on this computer/)).toBeDefined(); + const save = within(dialog).getByRole("button", { name: "Save" }); + expect(save.hasAttribute("disabled")).toBe(true); + + fireEvent.change(within(dialog).getByLabelText("Where Codex is"), { + target: { value: "C:/tools/mise/codex.exe" }, + }); + fireEvent.click(save); + expect(await within(dialog).findByText(/Found: C:\/tools\/mise\/codex\.exe/)).toBeDefined(); + expect( + fake.calls.filter((call) => call.method === "agents.setPath").map((c) => c.params), + ).toEqual([{ agent: "codex", path: "C:/tools/mise/codex.exe" }]); + }); + test("the icon and the window at sign-in show only where they apply", async () => { renderApp(); const dialog = await openSettings(); diff --git a/app/src/features/bots/BotPanels.tsx b/app/src/features/bots/BotPanels.tsx index afc38609..bfbd825e 100644 --- a/app/src/features/bots/BotPanels.tsx +++ b/app/src/features/bots/BotPanels.tsx @@ -5,6 +5,7 @@ import { X } from "lucide-react"; import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; +import { useApp } from "../../store/context"; import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; import { SidePanel } from "../../ui/SidePanel"; @@ -26,6 +27,19 @@ export function Notices({ }) { const t = useT(); const notices = []; + const checks = useApp((state) => state.system?.agentChecks); + // An agent that is not on the computer is restarted over and over: say why. + const missing = + bot.agent !== "claude" && + checks?.some((check) => check.agent === bot.agent && check.path === null) === true; + if (missing) { + const name = t.bots.dialog.agent.names[bot.agent]; + notices.push( + + {t.bots.notices.programMissingBody} + , + ); + } if (crew.paused && !bot.paused) { notices.push( diff --git a/app/src/features/bots/agentPicker.test.tsx b/app/src/features/bots/agentPicker.test.tsx index 890813c6..dd7f3ddf 100644 --- a/app/src/features/bots/agentPicker.test.tsx +++ b/app/src/features/bots/agentPicker.test.tsx @@ -48,3 +48,23 @@ describe("Codex in the new-bot dialog", () => { expect(dialog.queryByLabelText("Model")).toBeNull(); }); }); + +describe("an agent whose program is not on the computer", () => { + test("its chat says why it keeps restarting", async () => { + const fake = new FakeBotloft(); + fake.system = { + ...fake.system, + enabledAgents: ["claude", "codex"], + agentChecks: [{ agent: "codex", version: null, path: null }], + }; + const crew = fake.addCrew("Ops"); + const bot = fake.addBot(crew.id, "Cody"); + fake.bot(bot.id).agent = "codex"; + fake.setBotState(bot.id, "backoff"); + renderApp(fake); + await crewOpened("Ops"); + fireEvent.click(await screen.findByRole("button", { name: /^Cody,/ })); + expect(await screen.findByText("Codex was not found")).toBeDefined(); + expect(screen.getByText(/tell Botloft where it is in Settings/)).toBeDefined(); + }); +}); diff --git a/app/src/features/onboarding/Welcome.tsx b/app/src/features/onboarding/Welcome.tsx index 3b2e617b..7f359e9c 100644 --- a/app/src/features/onboarding/Welcome.tsx +++ b/app/src/features/onboarding/Welcome.tsx @@ -27,7 +27,7 @@ export function Welcome() { const [creating, setCreating] = useState(false); const [idea, setIdea] = useState(undefined); const checks = useApp((state) => state.system?.agentChecks ?? NO_CHECKS); - const otherReady = checks.some((check) => check.version !== null); + const otherReady = checks.some((check) => check.path !== null); const whenClosed = usePref(prefs.whenClosed); const { settings } = useDaemonSettings(); const running = @@ -60,9 +60,15 @@ export function Welcome() { const agents = checks.map((check) => ( )); diff --git a/app/src/features/onboarding/agents.test.tsx b/app/src/features/onboarding/agents.test.tsx index 4dea0daa..073bff89 100644 --- a/app/src/features/onboarding/agents.test.tsx +++ b/app/src/features/onboarding/agents.test.tsx @@ -13,7 +13,7 @@ describe("the first screen for someone who does not use Claude Code", () => { claudeVersion: null, runtimeError: "Claude Code was not found", enabledAgents: ["claude", "agy"], - agentChecks: [{ agent: "agy", version: "1.3.1" }], + agentChecks: [{ agent: "agy", version: "1.3.1", path: "C:agyagy.exe" }], }; renderApp(fake); expect(await screen.findByText("Antigravity (experimental)")).toBeDefined(); @@ -27,7 +27,7 @@ describe("the first screen for someone who does not use Claude Code", () => { fake.system = { ...fake.system, enabledAgents: ["claude", "agy"], - agentChecks: [{ agent: "agy", version: null }], + agentChecks: [{ agent: "agy", version: null, path: null }], }; renderApp(fake); expect(await screen.findByText(/Not found\. Install it/)).toBeDefined(); diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index fcbdfacc..a3abc7f2 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -256,6 +256,16 @@ export const account = { "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.", + programFound: (version: string, path: string) => `Found: ${path} (${version})`, + programFoundNoVersion: (path: string) => `Found: ${path}`, + programMissing: (name: string) => + `${name} was not found on this computer. Install it, or say below where it is.`, + programPath: (name: string) => `Where ${name} is`, + programPlaceholder: "Full path to the program", + programSave: "Save", + programFailed: "Could not save the path", + programHint: + "Leave it alone if the agent was found. If you installed it with a manager like mise or npm, paste the full path of its program.", chat: "Chat", enterSends: "Enter sends the message", enterSendsOn: "Shift+Enter starts a new line.", diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts index 24330e33..9d352ce1 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -136,6 +136,8 @@ export const bots = { notices: { crewPaused: (crew: string) => `${crew} is paused`, crewPausedBody: "Its agents stay stopped until you resume the crew.", + programMissing: (name: string) => `${name} was not found`, + programMissingBody: "Install it, or tell Botloft where it is in Settings.", }, details: { title: (name: string) => `About ${name}`, diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index 1a712437..8c85e95e 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -263,6 +263,16 @@ export const account: Messages["account"] = { "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ó.", + programFound: (version: string, path: string) => `Encontrado: ${path} (${version})`, + programFoundNoVersion: (path: string) => `Encontrado: ${path}`, + programMissing: (name: string) => + `${name} no se encontró en este equipo. Instálalo o indica abajo dónde está.`, + programPath: (name: string) => `Dónde está ${name}`, + programPlaceholder: "Ruta completa del programa", + programSave: "Guardar", + programFailed: "No se pudo guardar la ruta", + programHint: + "Déjalo como está si se encontró el agente. Si lo instalaste con un gestor como mise o npm, pega la ruta completa de su programa.", chat: "Chat", enterSends: "Enter envía el mensaje", enterSendsOn: "Shift+Enter empieza una nueva línea.", diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts index 759fa35c..b65e55df 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -133,6 +133,8 @@ export const bots: Messages["bots"] = { notices: { crewPaused: (crew: string) => `El equipo ${crew} está en pausa`, crewPausedBody: "Sus agentes siguen detenidos hasta que reanudes el equipo.", + programMissing: (name: string) => `${name} no se encontró`, + programMissingBody: "Instálalo o dile a Botloft dónde está, en Configuración.", }, details: { title: (name: string) => `Acerca de ${name}`, diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index 6eec1a69..a62931c6 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -261,6 +261,16 @@ export const account: Messages["account"] = { "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.", + programFound: (version: string, path: string) => `Encontrado: ${path} (${version})`, + programFoundNoVersion: (path: string) => `Encontrado: ${path}`, + programMissing: (name: string) => + `${name} não foi encontrado neste computador. Instale ou diga abaixo onde ele está.`, + programPath: (name: string) => `Onde está o ${name}`, + programPlaceholder: "Caminho completo do programa", + programSave: "Salvar", + programFailed: "Não foi possível salvar o caminho", + programHint: + "Deixe como está se o agente foi encontrado. Se você o instalou por um gerenciador como mise ou npm, cole o caminho completo do programa dele.", chat: "Conversa", enterSends: "Enter envia a mensagem", enterSendsOn: "Shift+Enter começa uma nova linha.", diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts index 4f739090..968a591e 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -132,6 +132,8 @@ export const bots: Messages["bots"] = { notices: { crewPaused: (crew: string) => `A equipe ${crew} está pausada`, crewPausedBody: "Os agentes dela ficam parados até você retomar a equipe.", + programMissing: (name: string) => `${name} não foi encontrado`, + programMissingBody: "Instale ou diga ao Botloft onde ele está, em Configurações.", }, details: { title: (name: string) => `Sobre ${name}`, diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 36978edf..f2567c97 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -261,6 +261,18 @@ export class FakeBotloft implements BotloftApi { daemonVersion: this.system.daemonVersion, protocol: PROTOCOL_VERSION, }), + "agents.setPath": ({ agent, path }) => { + const check = { + agent, + version: path === "" ? null : "1.0.0", + path: path === "" ? null : path, + }; + this.system = { + ...this.system, + agentChecks: this.system.agentChecks.map((have) => (have.agent === agent ? check : have)), + }; + return check; + }, "agents.models": () => [ { id: "gemini-3.8-flash-low", name: "Gemini 3.8 Flash (Low)" }, { id: "gemini-3.8-flash-high", name: "Gemini 3.8 Flash (High)" }, diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 04724dde..cd0bbbbb 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -532,7 +532,11 @@ export type AgentCheck = { agent: AgentKind, * What `--version` said; `null` when the program was not found or did * not answer. */ -version: string | null, }; +version: string | null, +/** + * The program Botloft would run for it; `null` when none was found. + */ +path: string | null, }; /** * The usage of an agent's account, with the plan it is on. @@ -543,6 +547,15 @@ export type AgentUsage = { agent: AgentKind, */ plan: string | null, usage: AccountUsage, }; +/** + * The program of an agent, set by the owner. + */ +export type AgentsSetPathParams = { agent: AgentKind, +/** + * The executable; empty goes back to looking for it. + */ +path: string, }; + export type BotIdParams = { botId: BotId, }; export type BotsRestartParams = { botId: BotId, @@ -2245,6 +2258,7 @@ export interface RpcMethods { "system.refresh": { params: undefined; result: SystemStatus }; "settings.get": { params: undefined; result: Settings }; "settings.update": { params: SettingsUpdateParams; result: Settings }; + "agents.setPath": { params: AgentsSetPathParams; result: AgentCheck }; "crews.list": { params: undefined; result: Array }; "crews.create": { params: CrewsCreateParams; result: Crew }; "crews.rename": { params: CrewsRenameParams; result: Crew }; diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index cac8af2c..299a6c9b 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -162,6 +162,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -260,6 +261,11 @@ fn export_bindings() { &out.name::(), &out.name::(), ); + out.method( + method::AGENTS_SET_PATH, + &out.name::(), + &out.name::(), + ); out.method(method::CREWS_LIST, "undefined", &crews); out.method( method::CREWS_CREATE, diff --git a/crates/botloft-core/src/protocol/methods.rs b/crates/botloft-core/src/protocol/methods.rs index 86ac0386..49329036 100644 --- a/crates/botloft-core/src/protocol/methods.rs +++ b/crates/botloft-core/src/protocol/methods.rs @@ -109,6 +109,18 @@ pub struct AgentCheck { /// What `--version` said; `null` when the program was not found or did /// not answer. pub version: Option, + /// The program Botloft would run for it; `null` when none was found. + pub path: Option, +} + +/// The program of an agent, set by the owner. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct AgentsSetPathParams { + pub agent: AgentKind, + /// The executable; empty goes back to looking for it. + pub path: String, } /// The owner, as the app shows them in its account area (spec 15.1). diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 40ded9b2..b8c4ed2c 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -220,6 +220,7 @@ pub mod method { pub const REACTIONS_SET: &str = "reactions.set"; pub const SETTINGS_GET: &str = "settings.get"; pub const SETTINGS_UPDATE: &str = "settings.update"; + pub const AGENTS_SET_PATH: &str = "agents.setPath"; pub const USAGE_TOKENS: &str = "usage.tokens"; } diff --git a/crates/botloftd/src/agent/agy.rs b/crates/botloftd/src/agent/agy.rs index 51e90871..f82ac58a 100644 --- a/crates/botloftd/src/agent/agy.rs +++ b/crates/botloftd/src/agent/agy.rs @@ -39,25 +39,16 @@ impl Agent for AgyAgent { if !configured.is_empty() { return Ok(PathBuf::from(configured)); } - let mut candidates = Vec::new(); + let mut first = Vec::new(); if let Some(local) = std::env::var_os("LOCALAPPDATA") { - candidates.push(PathBuf::from(local).join("agy").join("bin").join("agy.exe")); + first.push(PathBuf::from(local).join("agy").join("bin")); } - if let Some(path) = std::env::var_os("PATH") { - for dir in std::env::split_paths(&path) { - candidates.push(dir.join("agy.exe")); - candidates.push(dir.join("agy")); - } - } - candidates - .into_iter() - .find(|candidate| candidate.is_file()) - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "agy was not found; install Antigravity or set agy_path in config.toml", - ) - }) + super::find::program("agy", None, &first).ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "agy was not found; install Antigravity or set agy_path in config.toml", + ) + }) } fn args(&self, plan: &LaunchPlan<'_>) -> Vec { diff --git a/crates/botloftd/src/agent/codex.rs b/crates/botloftd/src/agent/codex.rs index 0c7c5c8a..509f819e 100644 --- a/crates/botloftd/src/agent/codex.rs +++ b/crates/botloftd/src/agent/codex.rs @@ -31,8 +31,8 @@ impl Agent for CodexAgent { if !configured.is_empty() { return Ok(PathBuf::from(configured)); } - let mut candidates = Vec::new(); // The desktop app keeps the executable in a folder named by a hash. + let mut first = Vec::new(); if let Some(local) = std::env::var_os("LOCALAPPDATA") { let bin = PathBuf::from(local) .join("OpenAI") @@ -42,27 +42,22 @@ impl Agent for CodexAgent { .into_iter() .flatten() .flatten() - .map(|entry| entry.path().join("codex.exe")) - .filter(|path| path.is_file()) + .map(|entry| entry.path()) + .filter(|dir| dir.join("codex.exe").is_file()) .collect(); - found.sort_by_key(|path| std::fs::metadata(path).and_then(|m| m.modified()).ok()); - candidates.extend(found.into_iter().rev()); + found.sort_by_key(|dir| { + std::fs::metadata(dir.join("codex.exe")) + .and_then(|m| m.modified()) + .ok() + }); + first.extend(found.into_iter().rev()); } - if let Some(path) = std::env::var_os("PATH") { - for dir in std::env::split_paths(&path) { - candidates.push(dir.join("codex.exe")); - candidates.push(dir.join("codex")); - } - } - candidates - .into_iter() - .find(|candidate| candidate.is_file()) - .ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "codex was not found; install Codex or set codex_path in config.toml", - ) - }) + super::find::program("codex", Some("@openai/codex"), &first).ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "codex was not found; install Codex or set codex_path in config.toml", + ) + }) } fn args(&self, _plan: &LaunchPlan<'_>) -> Vec { diff --git a/crates/botloftd/src/agent/find.rs b/crates/botloftd/src/agent/find.rs new file mode 100644 index 00000000..b21da874 --- /dev/null +++ b/crates/botloftd/src/agent/find.rs @@ -0,0 +1,184 @@ +//! Looking for an agent's program (spec 30): the owner's `PATH` as the user +//! has it (not the daemon's), the folders where the usual managers put +//! programs (mise, npm, Volta, Scoop, pnpm, Cargo, Homebrew), and, for a +//! program npm installed as a `.cmd`, the native executable in its package. +//! A `.cmd` cannot be run directly (spec 7.4), the native one can. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +/// How deep the package of an npm program is searched for its executable. +const PACKAGE_DEPTH: usize = 9; + +/// The program called `name` (`codex`, `agy`), looked for in `first`, on the +/// owner's `PATH` and in the usual manager folders. `package` is the npm +/// package that has it as a native executable, when there is one. +pub(super) fn program(name: &str, package: Option<&str>, first: &[PathBuf]) -> Option { + let mut dirs: Vec = first.to_vec(); + dirs.extend(path_dirs()); + dirs.extend(manager_dirs()); + for dir in &dirs { + for file in file_names(name) { + let candidate = dir.join(file); + if candidate.is_file() { + return Some(candidate); + } + } + // An npm program is a `.cmd` here and its package has the real one. + if let Some(package) = package + && dir.join(format!("{name}.cmd")).is_file() + && let Some(native) = native_in_package(&dir.join("node_modules").join(package), name) + { + return Some(native); + } + } + None +} + +#[cfg(windows)] +fn file_names(name: &str) -> Vec { + vec![format!("{name}.exe")] +} + +#[cfg(not(windows))] +fn file_names(name: &str) -> Vec { + vec![name.to_owned()] +} + +/// The `PATH` of the owner's user (the daemon runs as a task and may not see +/// what the owner added later), then the daemon's own. +fn path_dirs() -> Vec { + let user = crate::platform::user_environment() + .ok() + .and_then(|vars| { + vars.into_iter() + .find(|(name, _)| name.to_string_lossy().eq_ignore_ascii_case("PATH")) + }) + .map(|(_, value)| value); + let mut dirs = Vec::new(); + for value in user.into_iter().chain(std::env::var_os("PATH")) { + dirs.extend(split(&value)); + } + dirs +} + +/// Split on the separator only: a stray quote in a real `PATH` must not +/// swallow the rest (`std::env::split_paths` treats quotes as grouping). +fn split(value: &OsString) -> Vec { + let text = value.to_string_lossy(); + let separator = if cfg!(windows) { ';' } else { ':' }; + text.split(separator) + .map(|part| part.trim().trim_matches('"')) + .filter(|part| !part.is_empty()) + .map(PathBuf::from) + .collect() +} + +fn manager_dirs() -> Vec { + let mut dirs = Vec::new(); + let var = |name: &str| std::env::var_os(name).map(PathBuf::from); + if let Some(local) = var("LOCALAPPDATA") { + dirs.push(local.join("mise").join("shims")); + dirs.push(local.join("pnpm")); + dirs.push(local.join("Volta").join("bin")); + } + if let Some(roaming) = var("APPDATA") { + dirs.push(roaming.join("npm")); + } + if let Some(home) = dirs::home_dir() { + for relative in [ + ".local/share/mise/shims", + ".local/bin", + ".volta/bin", + ".npm-global/bin", + ".bun/bin", + ".cargo/bin", + "scoop/shims", + ] { + dirs.push( + relative + .split('/') + .fold(home.clone(), |dir, part| dir.join(part)), + ); + } + } + #[cfg(not(windows))] + { + dirs.push(PathBuf::from("/opt/homebrew/bin")); + dirs.push(PathBuf::from("/usr/local/bin")); + } + dirs +} + +/// The native executable called `name` inside an installed npm package. +fn native_in_package(root: &Path, name: &str) -> Option { + let wanted = file_names(name).into_iter().next()?; + let mut stack = vec![(root.to_path_buf(), 0usize)]; + while let Some((dir, depth)) = stack.pop() { + let Ok(entries) = std::fs::read_dir(&dir) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.file_name().is_some_and(|file| file == wanted.as_str()) && path.is_file() { + return Some(path); + } + if depth < PACKAGE_DEPTH && path.is_dir() { + stack.push((path, depth + 1)); + } + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_program_in_the_first_folders_is_found() { + let dir = tempfile::tempdir().expect("dir"); + let file = file_names("zed").remove(0); + std::fs::write(dir.path().join(&file), b"").expect("write"); + let found = program("zed", None, &[dir.path().to_path_buf()]); + assert_eq!(found, Some(dir.path().join(file))); + assert_eq!(program("not-a-real-program-x", None, &[]), None); + } + + #[test] + fn an_npm_cmd_leads_to_the_native_program_in_its_package() { + let dir = tempfile::tempdir().expect("dir"); + std::fs::write(dir.path().join("zed.cmd"), b"").expect("cmd"); + let vendor = dir + .path() + .join("node_modules") + .join("@acme") + .join("zed") + .join("node_modules") + .join("@acme") + .join("zed-win32-x64") + .join("vendor"); + std::fs::create_dir_all(&vendor).expect("vendor"); + let native = vendor.join(file_names("zed").remove(0)); + std::fs::write(&native, b"").expect("native"); + // On Windows `zed.exe` is not in the folder itself, only the `.cmd`. + let found = program("zed", Some("@acme/zed"), &[dir.path().to_path_buf()]); + if cfg!(windows) { + assert_eq!(found, Some(native)); + } else { + // Elsewhere there is no `.cmd`: the plain name is what is looked for. + assert!(found.is_none() || found == Some(native)); + } + } + + #[test] + fn the_path_is_split_on_the_separator_only() { + let separator = if cfg!(windows) { ';' } else { ':' }; + let value = OsString::from(format!("a{separator}\"b c\"{separator}{separator}d")); + let dirs = split(&value); + assert_eq!( + dirs, + [PathBuf::from("a"), PathBuf::from("b c"), PathBuf::from("d")] + ); + } +} diff --git a/crates/botloftd/src/agent/mod.rs b/crates/botloftd/src/agent/mod.rs index 390e2d8b..5a73d650 100644 --- a/crates/botloftd/src/agent/mod.rs +++ b/crates/botloftd/src/agent/mod.rs @@ -9,6 +9,7 @@ mod codex; mod codex_decoder; mod codex_usage; mod codex_wire; +mod find; use std::ffi::OsString; use std::io; diff --git a/crates/botloftd/src/browser/program.rs b/crates/botloftd/src/browser/program.rs index d81fcd26..ca6c394c 100644 --- a/crates/botloftd/src/browser/program.rs +++ b/crates/botloftd/src/browser/program.rs @@ -1,15 +1,16 @@ //! Which browser a bot runs (spec 21.2): the one in the config, else the -//! Edge that comes with Windows, or the first Edge, Chrome or Chromium -//! installed on macOS (Chrome or Chromium first on Linux). +//! Edge that comes with Windows, then Chrome, then Brave; the first Edge, +//! Chrome, Brave or Chromium installed on macOS (Chrome or Chromium first +//! on Linux). use std::path::PathBuf; /// What is missing when no browser is found. #[cfg(windows)] -pub const NOT_FOUND: &str = "Microsoft Edge was not found on this computer"; +pub const NOT_FOUND: &str = "no Microsoft Edge, Google Chrome or Brave was found on this computer"; #[cfg(not(windows))] pub const NOT_FOUND: &str = - "no Microsoft Edge, Google Chrome or Chromium was found on this computer"; + "no Microsoft Edge, Google Chrome, Brave or Chromium was found on this computer"; /// The browser to run: `configured`, or one installed on the computer. pub fn find(configured: &str) -> Option { @@ -23,15 +24,25 @@ pub fn find(configured: &str) -> Option { #[cfg(windows)] fn installed() -> Vec { - ["ProgramFiles(x86)", "ProgramFiles", "LOCALAPPDATA"] + // Edge first: it comes with Windows. Then Chrome, then Brave. + const BROWSERS: &[&[&str]] = &[ + &["Microsoft", "Edge", "Application", "msedge.exe"], + &["Google", "Chrome", "Application", "chrome.exe"], + &["BraveSoftware", "Brave-Browser", "Application", "brave.exe"], + ]; + let bases: Vec = ["ProgramFiles(x86)", "ProgramFiles", "LOCALAPPDATA"] .iter() .filter_map(std::env::var_os) - .map(|base| { - PathBuf::from(base) - .join("Microsoft") - .join("Edge") - .join("Application") - .join("msedge.exe") + .map(PathBuf::from) + .collect(); + BROWSERS + .iter() + .flat_map(|parts| { + bases.iter().map(move |base| { + parts + .iter() + .fold(base.clone(), |path, part| path.join(part)) + }) }) .collect() } @@ -42,6 +53,7 @@ fn installed() -> Vec { const APPS: &[&str] = &[ "Microsoft Edge.app/Contents/MacOS/Microsoft Edge", "Google Chrome.app/Contents/MacOS/Google Chrome", + "Brave Browser.app/Contents/MacOS/Brave Browser", "Chromium.app/Contents/MacOS/Chromium", ]; let folders = [ @@ -66,6 +78,8 @@ fn installed() -> Vec { "google-chrome", "chromium", "chromium-browser", + "brave-browser", + "brave", "microsoft-edge-stable", "microsoft-edge", ]; diff --git a/crates/botloftd/src/config.rs b/crates/botloftd/src/config.rs index b3304234..6c1194ce 100644 --- a/crates/botloftd/src/config.rs +++ b/crates/botloftd/src/config.rs @@ -147,7 +147,7 @@ impl Default for CloudConfig { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct BrowserConfig { - /// Empty means the Microsoft Edge that comes with Windows. + /// Empty means the Edge that comes with Windows, then Chrome or Brave. pub path: String, /// Closes a browser nobody used or watched for this long. pub idle_minutes: u64, diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 84738436..e9831647 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -8,9 +8,9 @@ use serde_json::Value; use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; use crate::service::{ - self, ApiError, ApiResult, archive, attachments, autobackup, backup, bots, catalog, chat, - cloud, crew_access, crews, delete, deliveries, desktop, files, lead, mcp, messages, mobile, - models, modes, questions, reactions, routines, rules, screens, settings, tasks, usage, + self, ApiError, ApiResult, agents, archive, attachments, autobackup, backup, bots, catalog, + chat, cloud, crew_access, crews, delete, deliveries, desktop, files, lead, mcp, messages, + mobile, models, modes, questions, reactions, routines, rules, screens, settings, tasks, usage, }; use crate::state::Daemon; @@ -135,6 +135,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(screens::list(daemon, parse(params)?)), method::SETTINGS_GET => reply(settings::get(daemon)), method::SETTINGS_UPDATE => reply(settings::update(daemon, parse(params)?)), + method::AGENTS_SET_PATH => reply(agents::set_path(daemon, parse(params)?)), method::USAGE_TOKENS => reply(usage::tokens(daemon, parse(params)?)), method::SESSION_HELLO => Err(RpcError::new( error_code::CONFLICT, diff --git a/crates/botloftd/src/service/agents.rs b/crates/botloftd/src/service/agents.rs new file mode 100644 index 00000000..b7586a67 --- /dev/null +++ b/crates/botloftd/src/service/agents.rs @@ -0,0 +1,101 @@ +//! Where an experimental agent's program is (spec 30): the owner can tell +//! Botloft when it is somewhere it does not look. + +use std::path::Path; +use std::sync::Mutex; +use std::time::{Duration, Instant}; + +use botloft_core::protocol::{AgentCheck, AgentKind, AgentsSetPathParams}; +use tracing::info; + +use super::{ApiError, ApiResult}; +use crate::state::Daemon; + +/// Saves the program of `params.agent` in `config.toml` and uses it from +/// now on; an empty path goes back to looking for it. The answer is the +/// agent's check with the new path, so the app shows at once whether it +/// was found. A bot that waited for the program starts on its own shortly. +pub fn set_path(daemon: &Daemon, params: AgentsSetPathParams) -> ApiResult { + if params.agent == AgentKind::Claude { + return Err(ApiError::validation( + "the path of Claude Code is set with claude_path in config.toml", + )); + } + let path = params.path.trim().trim_matches('"').to_owned(); + if !path.is_empty() && !Path::new(&path).is_file() { + return Err(ApiError::validation("there is no file at that path")); + } + daemon + .settings + .save_program(params.agent, &path) + .map_err(|err| ApiError::Settings(err.to_string()))?; + daemon.supervisor.set_program(params.agent, path); + forget_checks(); + daemon.supervisor.wake(); + info!( + agent = params.agent.as_str(), + "the program of an agent changed" + ); + Ok(check_of(daemon, params.agent)) +} + +/// When it was asked, what it was asked about (the programs and which +/// agents are on), and the answer. +type Cache = Option<(Instant, String, Vec)>; + +static CACHE: Mutex = Mutex::new(None); + +/// Whether the other agents the owner enabled are installed. Asking runs the +/// program, so the answer is kept for a minute. +pub(super) fn checks(daemon: &Daemon) -> Vec { + let mut cache = CACHE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let asked = [AgentKind::Agy, AgentKind::Codex] + .into_iter() + .map(|kind| { + format!( + "{}:{}", + daemon.supervisor.agent_enabled(kind), + daemon.supervisor.program_setting(kind) + ) + }) + .collect::>() + .join("|"); + if let Some((at, same, checks)) = cache.as_ref() + && same == &asked + && at.elapsed() < Duration::from_secs(60) + { + return checks.clone(); + } + let checks: Vec = [AgentKind::Agy, AgentKind::Codex] + .into_iter() + .filter(|kind| daemon.supervisor.agent_enabled(*kind)) + .map(|kind| check_of(daemon, kind)) + .collect(); + *cache = Some((Instant::now(), asked, checks.clone())); + checks +} + +/// Asked again at the next status, after the program changed. +pub(super) fn forget_checks() { + *CACHE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = None; +} + +/// What looking for the program of `kind` finds. +fn check_of(daemon: &Daemon, kind: AgentKind) -> AgentCheck { + let program = crate::agent::of(kind).and_then(|agent| { + agent + .locate(&daemon.supervisor.program_setting(kind), None) + .ok() + .filter(|program| program.is_file()) + }); + let version = program.as_deref().and_then(crate::agent::program_version); + AgentCheck { + agent: kind, + version, + path: program.map(|path| path.display().to_string()), + } +} diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index 5abb0765..66d27192 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -1,6 +1,7 @@ //! What the RPC methods do, independent of JSON-RPC. Each operation runs //! under the store lock, so checks and writes cannot interleave. +pub mod agents; pub mod archive; pub mod attachments; pub mod autobackup; @@ -32,9 +33,7 @@ pub mod settings; pub mod tasks; pub mod usage; -use botloft_core::protocol::{ - AgentCheck, AgentKind, OwnerAccount, PROTOCOL_VERSION, SystemStatus, error_code, -}; +use botloft_core::protocol::{AgentKind, OwnerAccount, PROTOCOL_VERSION, SystemStatus, error_code}; use botloft_core::slug; use botloft_core::validate::ValidationError; use botloft_store::StoreError; @@ -123,46 +122,11 @@ pub fn status(daemon: &Daemon) -> ApiResult { .into_iter() .filter(|kind| daemon.supervisor.agent_enabled(*kind)) .collect(), - agent_checks: agent_checks(daemon), + agent_checks: agents::checks(daemon), agent_usage: daemon.agent_usage(), }) } -/// Whether the other agents the owner enabled are installed. Asking runs the -/// program, so the answer is kept for a minute. -fn agent_checks(daemon: &Daemon) -> Vec { - use std::sync::Mutex; - use std::time::{Duration, Instant}; - static CACHE: Mutex)>> = Mutex::new(None); - let mut cache = CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some((at, checks)) = cache.as_ref() - && at.elapsed() < Duration::from_secs(60) - { - return checks.clone(); - } - let mut checks = Vec::new(); - for kind in [AgentKind::Agy, AgentKind::Codex] { - if !daemon.supervisor.agent_enabled(kind) { - continue; - } - let Some(agent) = crate::agent::of(kind) else { - continue; - }; - let version = agent - .locate(daemon.supervisor.program_setting(kind), None) - .ok() - .and_then(|program| crate::agent::program_version(&program)); - checks.push(AgentCheck { - agent: kind, - version, - }); - } - *cache = Some((Instant::now(), checks.clone())); - checks -} - /// [`slug::unique`] for checks that can fail. fn pick_slug(base: &str, mut taken: impl FnMut(&str) -> ApiResult) -> ApiResult { let mut failure = None; diff --git a/crates/botloftd/src/service/models.rs b/crates/botloftd/src/service/models.rs index f9111ab3..856cd797 100644 --- a/crates/botloftd/src/service/models.rs +++ b/crates/botloftd/src/service/models.rs @@ -171,7 +171,7 @@ pub fn agent_models(daemon: &Daemon, params: AgentsModelsParams) -> ApiResult crate::agent::codex_models(&program), diff --git a/crates/botloftd/src/service/settings.rs b/crates/botloftd/src/service/settings.rs index 62cae9c8..d478aaa0 100644 --- a/crates/botloftd/src/service/settings.rs +++ b/crates/botloftd/src/service/settings.rs @@ -50,6 +50,8 @@ pub fn update(daemon: &Daemon, params: SettingsUpdateParams) -> ApiResult io::Result<()> { + let key = match kind { + AgentKind::Agy => "agy_path", + AgentKind::Codex => "codex_path", + AgentKind::Claude => return Ok(()), + }; + let _guard = self.lock(); + match &self.file { + Some(file) => write_text(file, key, path), + None => Ok(()), + } + } + fn lock(&self) -> MutexGuard<'_, Current> { self.current .lock() @@ -152,6 +167,22 @@ fn write(file: &Path, update: SettingsUpdateParams) -> io::Result<()> { std::fs::rename(&staged, file) } +/// Sets one text key of `config.toml` and leaves the rest as it was. +fn write_text(file: &Path, key: &str, value: &str) -> io::Result<()> { + let text = match std::fs::read_to_string(file) { + Ok(text) => text, + Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(), + Err(err) => return Err(err), + }; + let mut config: toml_edit::DocumentMut = text + .parse() + .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?; + set(config.as_table_mut(), key, value); + let staged = file.with_extension("toml.new"); + std::fs::write(&staged, config.to_string())?; + std::fs::rename(&staged, file) +} + /// Sets `key` in `table`, keeping a comment after the old value. fn set(table: &mut dyn toml_edit::TableLike, key: &str, value: impl Into) { let mut value = value.into(); @@ -260,6 +291,35 @@ mod tests { assert!(config.experimental_agents.is_empty()); } + #[test] + fn an_agents_program_is_saved_as_its_own_key_and_cleared() { + let dir = tempfile::tempdir().expect("dir"); + let file = dir.path().join("config.toml"); + std::fs::write( + &file, + "# mine +port = 45999 +", + ) + .expect("write"); + let settings = LiveSettings::new(Some(file.clone()), &Config::default()); + settings + .save_program(AgentKind::Codex, "C:/tools/codex.exe") + .expect("save"); + let text = std::fs::read_to_string(&file).expect("read"); + assert!(text.starts_with( + "# mine +port = 45999 +" + )); + let config = Config::parse(&text).expect("valid"); + assert_eq!(config.codex_path, "C:/tools/codex.exe"); + assert_eq!(config.agy_path, ""); + settings.save_program(AgentKind::Codex, "").expect("clear"); + let config = Config::parse(&std::fs::read_to_string(&file).expect("read")).expect("valid"); + assert_eq!(config.codex_path, ""); + } + #[test] fn a_new_wait_goes_in_the_bots_table() { let dir = tempfile::tempdir().expect("dir"); diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs index 3531bebf..4cfa131c 100644 --- a/crates/botloftd/src/supervisor/mod.rs +++ b/crates/botloftd/src/supervisor/mod.rs @@ -51,6 +51,9 @@ pub struct Supervisor { /// The experimental agents the owner switched on: `settings` has what /// `config.toml` said at start, the Settings screen changes this. experimental: Mutex>, + /// The programs of `agy` and `codex` the owner set: the config at + /// start, then what the Settings screen says. + programs: Mutex<(String, String)>, events: broadcast::Sender, inner: Mutex, /// Generations are unique across bots and daemon restarts, so a client @@ -100,6 +103,7 @@ impl Supervisor { daemon, runtime, experimental: Mutex::new(settings.experimental_agents.clone()), + programs: Mutex::new((settings.agy_path.clone(), settings.codex_path.clone())), settings, events, inner: Mutex::default(), @@ -161,23 +165,32 @@ impl Supervisor { .unwrap_or_else(|poisoned| poisoned.into_inner()) = names; } - /// The `agy_path` of the config; empty looks in the usual places. - pub fn agy_path(&self) -> &str { - &self.settings.agy_path - } - - /// The `codex_path` of the config; empty looks in the usual places. - pub fn codex_path(&self) -> &str { - &self.settings.codex_path + /// The program of an agent the owner set; empty looks in the usual + /// places. + pub fn program_setting(&self, kind: botloft_core::protocol::AgentKind) -> String { + use botloft_core::protocol::AgentKind; + let programs = self + .programs + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match kind { + AgentKind::Claude => String::new(), + AgentKind::Agy => programs.0.clone(), + AgentKind::Codex => programs.1.clone(), + } } - /// The configured program of an agent, to look for it. - pub fn program_setting(&self, kind: botloft_core::protocol::AgentKind) -> &str { + /// Uses `path` as the program of `kind` from the next start on. + pub fn set_program(&self, kind: botloft_core::protocol::AgentKind, path: String) { use botloft_core::protocol::AgentKind; + let mut programs = self + .programs + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); match kind { - AgentKind::Claude => "", - AgentKind::Agy => self.agy_path(), - AgentKind::Codex => self.codex_path(), + AgentKind::Claude => {} + AgentKind::Agy => programs.0 = path, + AgentKind::Codex => programs.1 = path, } } diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs index bd675c44..a36f7f4d 100644 --- a/crates/botloftd/src/supervisor/spawn.rs +++ b/crates/botloftd/src/supervisor/spawn.rs @@ -119,7 +119,7 @@ pub(super) fn launch_spec( io::Error::other(format!("bots on {} cannot start yet", bot.agent.as_str())) })?; let program = agent.locate( - daemon.supervisor.program_setting(bot.agent), + &daemon.supervisor.program_setting(bot.agent), claude.map(|claude| claude.path.as_path()), )?; let mcp = workspace.join(".botloft").join("mcp.json"); diff --git a/crates/botloftd/src/tools/browser_reply.rs b/crates/botloftd/src/tools/browser_reply.rs index de600b92..39a16e28 100644 --- a/crates/botloftd/src/tools/browser_reply.rs +++ b/crates/botloftd/src/tools/browser_reply.rs @@ -108,8 +108,8 @@ pub(super) fn describe(err: &BrowserError) -> String { pub(super) fn unavailable(err: &BrowserError) -> String { format!( - "Your browser could not start: {err}. Tell the owner; Botloft uses Microsoft Edge \ - (Google Chrome or Chromium off Windows), or another Chromium browser set in its config." + "Your browser could not start: {err}. Tell the owner; Botloft uses Microsoft Edge, \ + Google Chrome or Brave (or Chromium off Windows), or another Chromium browser set in its config." ) } diff --git a/crates/botloftd/tests/settings.rs b/crates/botloftd/tests/settings.rs index 9350716a..e355fff0 100644 --- a/crates/botloftd/tests/settings.rs +++ b/crates/botloftd/tests/settings.rs @@ -93,3 +93,69 @@ async fn the_experimental_agents_are_switched_on_and_off_in_settings() { .expect("status"); assert_eq!(agents(status), json!(["claude"])); } + +#[tokio::test] +async fn the_owner_tells_where_an_agents_program_is() { + let t = TestDaemon::start().await; + std::fs::create_dir_all(&t.paths.home).expect("home"); + let mut app = t.session().await; + app.call("settings.update", json!({ "showExperimentalAgents": true })) + .await + .expect("on"); + + // A file that is not there is refused, and Claude Code has its own key. + let missing = app + .call( + "agents.setPath", + json!({ "agent": "codex", "path": "C:/nowhere/codex.exe" }), + ) + .await; + assert!(missing.is_err(), "no file, no path"); + let claude = app + .call("agents.setPath", json!({ "agent": "claude", "path": "x" })) + .await; + assert!(claude.is_err()); + + let program = t.paths.home.join("my-codex.exe"); + std::fs::write(&program, b"").expect("file"); + let check = app + .call( + "agents.setPath", + json!({ "agent": "codex", "path": program.display().to_string() }), + ) + .await + .expect("set"); + assert_eq!(check["agent"], json!("codex")); + assert_eq!(check["path"], json!(program.display().to_string())); + assert_eq!( + t.daemon + .supervisor + .program_setting(botloft_core::protocol::AgentKind::Codex), + program.display().to_string() + ); + + // The status shows it at once, not after the minute of its cache. + let status = app + .call("system.status", json!(null)) + .await + .expect("status"); + let codex = status["agentChecks"] + .as_array() + .expect("checks") + .iter() + .find(|check| check["agent"] == json!("codex")) + .expect("codex check") + .clone(); + assert_eq!(codex["path"], json!(program.display().to_string())); + + // Empty goes back to looking for it. + app.call("agents.setPath", json!({ "agent": "codex", "path": "" })) + .await + .expect("clear"); + assert_eq!( + t.daemon + .supervisor + .program_setting(botloft_core::protocol::AgentKind::Codex), + "" + ); +} diff --git a/docs/spec.md b/docs/spec.md index e47baa89..ef6dce62 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1380,7 +1380,7 @@ Cada bot tem um navegador próprio para pesquisar e usar sites: um Microsoft Edg - Sobe na primeira tool `browser_*` do bot e fica aberto enquanto ele o usa. Um processo por bot. - Comando: `msedge.exe --headless=new --remote-debugging-port=0 --user-data-dir=\browsers\ --no-first-run --no-default-browser-check --mute-audio --disable-extensions --disable-sync --disable-blink-features=AutomationControlled about:blank`. Sem `--disable-extensions`, um perfil novo recebe as extensões instaladas para todo o computador, e elas abrem abas próprias no navegador do bot (visto com o Edge 154). Com a porta 0, o Edge escolhe uma porta livre em 127.0.0.1 e a escreve com o caminho do WebSocket em `DevToolsActivePort`, na pasta do perfil (visto com o Edge 154); o daemon apaga o arquivo antigo antes de subir e espera o novo por até 30 s. Costuma levar menos de 1 s, mas a primeira abertura do Edge no runner Ubuntu do CI passou de 20 s. Se o navegador fecha ou não fica pronto, o erro traz a linha do stderr dele que melhor explica (uma falha de sandbox ou de namespace, senão a primeira fatal, senão a última); o stderr não vai para o log. - Como o `claude.exe`: sem janela (`CREATE_NO_WINDOW`), num Job Object próprio com `KILL_ON_JOB_CLOSE` (morre junto com o daemon) e com o ambiente padrão do usuário (7.4). -- Achar o Edge: `[browser] path`, senão `msedge.exe` em `%ProgramFiles(x86)%` e `%ProgramFiles%` (`Microsoft\Edge\Application`) e em `%LOCALAPPDATA%`. No macOS, Edge, Chrome ou Chromium em `/Applications` e em `~/Applications`; no Linux, o primeiro de `google-chrome-stable`, `google-chrome`, `chromium`, `chromium-browser`, `microsoft-edge-stable` e `microsoft-edge` no `PATH` do ambiente do dono (14.1). O Edge vem por último no Linux: é o menos comum lá, e no runner Ubuntu do CI ele levou segundos para abrir a primeira aba de cada perfil novo (passando dos 5 s de espera da primeira aba), onde o Chrome levou menos de 1. Fora do Windows, o grupo de processos faz o papel do Job Object (14.1). Sem navegador, a tool responde ao bot que ele não está disponível, e o painel diz o mesmo. +- Achar o navegador: `[browser] path`, senão, no Windows, o primeiro que existir entre `msedge.exe` (`Microsoft\Edge\Application`), `chrome.exe` (`Google\Chrome\Application`) e `brave.exe` (`BraveSoftware\Brave-Browser\Application`), em `%ProgramFiles(x86)%`, `%ProgramFiles%` e `%LOCALAPPDATA%` (o Edge vem com o Windows; quem usa Chrome ou Brave fica coberto). No macOS, Edge, Chrome, Brave ou Chromium em `/Applications` e em `~/Applications`; no Linux, o primeiro de `google-chrome-stable`, `google-chrome`, `chromium`, `chromium-browser`, `brave-browser`, `brave`, `microsoft-edge-stable` e `microsoft-edge` no `PATH` do ambiente do dono (14.1). O Edge vem por último no Linux: é o menos comum lá, e no runner Ubuntu do CI ele levou segundos para abrir a primeira aba de cada perfil novo (passando dos 5 s de espera da primeira aba), onde o Chrome levou menos de 1. Fora do Windows, o grupo de processos faz o papel do Job Object (14.1). Sem navegador, a tool responde ao bot que ele não está disponível, e o painel diz o mesmo. - **Perfil:** `\browsers\\`, fora da pasta do bot e em `%LOCALAPPDATA%` (5): cookies e sessões são do navegador, não dos arquivos que o bot faz. O perfil fica entre aberturas, então um login feito continua valendo. Arquivar ou excluir o bot fecha o navegador e apaga o perfil. - **Descansar:** um navegador aberto que ninguém usa não deve gastar o computador. Assim que o turno do bot termina (o estado dele deixa de ser `busy` ou `needs_approval`, 7.1), o daemon põe o navegador para descansar: tira um quadro da aba ativa, para o painel ter o que mostrar, e minimiza a janela do Edge (`Browser.setWindowBounds` com `windowState: "minimized"`; a janela já não aparecia, mas para o Edge ela existe). As páginas passam a valer como as de uma janela minimizada: `visibilityState` vira `hidden`, os timers caem para um por segundo ou menos, animações e quadros param. Medido com o Edge 154, uma página que gastava 90% de um núcleo fica abaixo de 1% (19). Nada se perde: abas, formulários e sessões continuam como estavam. Não descansa no meio de uma tool nem nas mãos do dono (21.10). - **Acordar:** na hora, com a janela de volta a `normal`, na próxima tool `browser_*` do bot, quando o dono assume o controle e quando ele recarrega a página. Depois de algo que o dono fez com o bot fora de um turno, o navegador volta a descansar sozinho: 20 s sem uso, conferido a cada 30 s. @@ -2680,7 +2680,7 @@ Descoberta e saúde (binário, versão mínima, login, comando de entrar); lanç ### 30.3.2 Codex (`codex app-server`), experimental -- **Ligar:** como o AGY (interruptor em Configurações ou `experimental_agents = ["codex"]` no `config.toml`); `codex_path` aponta para o executável (vazio procura em `%LOCALAPPDATA%\OpenAI\Codex\bin\\codex.exe` e no PATH). +- **Ligar:** como o AGY (interruptor em Configurações ou `experimental_agents = ["codex"]` no `config.toml`); `codex_path` aponta para o executável. Vazio, procura em `%LOCALAPPDATA%\OpenAI\Codex\bin\\codex.exe`, no `PATH` do usuário (não só o do daemon; separado só por `;`) e nas pastas dos gerenciadores (mise, pnpm, Volta, npm, Scoop, Cargo, Bun e, fora do Windows, Homebrew); de um `codex.cmd` do npm ele chega ao `codex.exe` nativo do pacote `@openai/codex`. O `agy` segue a mesma busca. Quem instalou em outro lugar diz onde em Configurações (`agents.setPath`, que grava `codex_path` ou `agy_path` e vale na hora); `SystemStatus.agentChecks` traz o `path` achado, e o agente que não achou o programa mostra no chat que ele não foi encontrado, em vez de só reiniciar. - **Processo:** `codex app-server -c features.apps=false -c features.hooks=false -c features.plugins=false -c notify=[] -c mcp_servers..enabled=false` (um para cada MCP do `config.toml` do dono), na `CODEX_HOME` do dono: o login vale sem copiar o token. O invólucro `CodexControl` (`agent/codex_wire.rs`) recebe a linha neutra do Botloft e a vira `turn/start` quando a thread existe e nenhum turno roda; as mensagens esperam em fila. - **Aperto de mão:** `initialize`, `initialized`, `thread/start` (ou `thread/resume` com o id guardado; se não existir, `thread/start`). A thread leva `cwd` (a pasta do bot), `approvalPolicy: "never"`, `sandbox: "read-only"`, `model` (`Bot.agentModel`) e o servidor MCP `botloft` com o token deste processo em `http_headers`. - **Eventos:** `item/started` `userMessage` é o recibo de leitura da mensagem mais antiga escrita e grava a thread a retomar; `item/agentMessage/delta` é o texto ao vivo e `item/completed` `agentMessage` fecha a resposta; `commandExecution`, `fileChange` e `mcpToolCall` aparecem como `Bash`, `Write`/`Edit` e `mcp____`; `thread/tokenUsage/updated` dá o contexto (a última requisição e `modelContextWindow`) e os tokens do turno (o total da thread menos o que era no começo); `turn/completed` fecha o turno com `durationMs` e o erro.