+ );
+}
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