Skip to content

Commit bd8c6ad

Browse files
authored
Merge pull request #258 from httpsphl/feat/find-agent-programs
feat(agent): find Codex wherever it was installed, and Brave or Chrome
2 parents 4df8cc6 + 174cfb7 commit bd8c6ad

35 files changed

Lines changed: 747 additions & 116 deletions
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// Settings, "General": where the experimental agents' programs are. Botloft
2+
// looks in the usual places; when someone installed one somewhere else (mise,
3+
// npm, a folder of their own), they say where here (spec 30).
4+
5+
import { useState } from "react";
6+
import { useT } from "../../i18n";
7+
import type { AgentCheck } from "../../lib/protocol.gen";
8+
import { useApi, useApp } from "../../store/context";
9+
import { Button } from "../../ui/Button";
10+
import { TextField } from "../../ui/Field";
11+
import { attempt } from "../../ui/toast";
12+
13+
const NO_CHECKS: AgentCheck[] = [];
14+
15+
export function AgentPrograms() {
16+
const checks = useApp((state) => state.system?.agentChecks ?? NO_CHECKS);
17+
if (checks.length === 0) {
18+
return null;
19+
}
20+
return (
21+
<div className="flex flex-col gap-4">
22+
{checks.map((check) => (
23+
<Program key={check.agent} check={check} />
24+
))}
25+
</div>
26+
);
27+
}
28+
29+
function Program({ check }: { check: AgentCheck }) {
30+
const t = useT();
31+
const s = t.account.settings;
32+
const api = useApi();
33+
const system = useApp((state) => state.system);
34+
const putSystem = useApp((state) => state.putSystem);
35+
const name = t.bots.dialog.agent.names[check.agent];
36+
const [text, setText] = useState("");
37+
const [busy, setBusy] = useState(false);
38+
39+
const found = check.path !== null;
40+
const status = found
41+
? check.version === null
42+
? s.programFoundNoVersion(check.path ?? "")
43+
: s.programFound(check.version, check.path ?? "")
44+
: s.programMissing(name);
45+
46+
const save = async (path: string) => {
47+
setBusy(true);
48+
const saved = await attempt(s.programFailed, async () => {
49+
const next = await api.call("agents.setPath", { agent: check.agent, path });
50+
if (system) {
51+
putSystem({
52+
...system,
53+
agentChecks: system.agentChecks.map((have) => (have.agent === next.agent ? next : have)),
54+
});
55+
}
56+
});
57+
setBusy(false);
58+
if (saved) {
59+
setText("");
60+
}
61+
};
62+
63+
return (
64+
<div className="flex flex-col gap-2">
65+
<p className="font-medium text-sm">{name}</p>
66+
<p
67+
className={`text-xs leading-relaxed ${found ? "text-muted" : "text-warn"}`}
68+
data-selectable
69+
>
70+
{status}
71+
</p>
72+
<div className="flex items-end gap-2">
73+
<div className="min-w-0 flex-1">
74+
<TextField
75+
label={s.programPath(name)}
76+
value={text}
77+
placeholder={s.programPlaceholder}
78+
onChange={(event) => setText(event.target.value)}
79+
/>
80+
</div>
81+
<Button
82+
variant="secondary"
83+
disabled={busy || text.trim() === ""}
84+
onClick={() => save(text)}
85+
>
86+
{s.programSave}
87+
</Button>
88+
</div>
89+
<p className="text-muted text-xs leading-relaxed">{s.programHint}</p>
90+
</div>
91+
);
92+
}

‎app/src/features/account/GeneralSettings.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { SYSTEM } from "../../lib/system";
1414
import { prefs, usePref } from "../../shell/prefs";
1515
import { useApp } from "../../store/context";
1616
import { Select } from "../../ui/Select";
17+
import { AgentPrograms } from "./AgentPrograms";
1718
import { Section, Toggle } from "./settingsParts";
1819
import { useDaemonSettings } from "./useDaemonSettings";
1920

@@ -49,6 +50,7 @@ export function GeneralSettings() {
4950
disabled={!settings}
5051
onChange={(on) => change({ showExperimentalAgents: on })}
5152
/>
53+
{settings?.showExperimentalAgents && <AgentPrograms />}
5254
</Section>
5355
{settings && enabledAgents.length > 1 && (
5456
<Section title={s.defaultAgent}>

‎app/src/features/account/settings.test.tsx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,30 @@ describe("settings", () => {
8686
).toEqual([{ showExperimentalAgents: true }]);
8787
});
8888

89+
test("an agent that is not found says so, and the path the owner gives is used", async () => {
90+
const fake = new FakeBotloft();
91+
fake.settings = { ...fake.settings, showExperimentalAgents: true };
92+
fake.system = {
93+
...fake.system,
94+
enabledAgents: ["claude", "codex"],
95+
agentChecks: [{ agent: "codex", version: null, path: null }],
96+
};
97+
renderApp(fake);
98+
const dialog = await openSettings();
99+
expect(await within(dialog).findByText(/Codex was not found on this computer/)).toBeDefined();
100+
const save = within(dialog).getByRole("button", { name: "Save" });
101+
expect(save.hasAttribute("disabled")).toBe(true);
102+
103+
fireEvent.change(within(dialog).getByLabelText("Where Codex is"), {
104+
target: { value: "C:/tools/mise/codex.exe" },
105+
});
106+
fireEvent.click(save);
107+
expect(await within(dialog).findByText(/Found: C:\/tools\/mise\/codex\.exe/)).toBeDefined();
108+
expect(
109+
fake.calls.filter((call) => call.method === "agents.setPath").map((c) => c.params),
110+
).toEqual([{ agent: "codex", path: "C:/tools/mise/codex.exe" }]);
111+
});
112+
89113
test("the icon and the window at sign-in show only where they apply", async () => {
90114
renderApp();
91115
const dialog = await openSettings();

‎app/src/features/bots/BotPanels.tsx‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import { X } from "lucide-react";
66
import { useT } from "../../i18n";
77
import type { Bot, Crew } from "../../lib/protocol.gen";
8+
import { useApp } from "../../store/context";
89
import { Button } from "../../ui/Button";
910
import { Callout } from "../../ui/Callout";
1011
import { SidePanel } from "../../ui/SidePanel";
@@ -26,6 +27,19 @@ export function Notices({
2627
}) {
2728
const t = useT();
2829
const notices = [];
30+
const checks = useApp((state) => state.system?.agentChecks);
31+
// An agent that is not on the computer is restarted over and over: say why.
32+
const missing =
33+
bot.agent !== "claude" &&
34+
checks?.some((check) => check.agent === bot.agent && check.path === null) === true;
35+
if (missing) {
36+
const name = t.bots.dialog.agent.names[bot.agent];
37+
notices.push(
38+
<Callout key="program" tone="warn" title={t.bots.notices.programMissing(name)}>
39+
{t.bots.notices.programMissingBody}
40+
</Callout>,
41+
);
42+
}
2943
if (crew.paused && !bot.paused) {
3044
notices.push(
3145
<Callout key="crew" title={t.bots.notices.crewPaused(crew.name)}>

‎app/src/features/bots/agentPicker.test.tsx‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,23 @@ describe("Codex in the new-bot dialog", () => {
4848
expect(dialog.queryByLabelText("Model")).toBeNull();
4949
});
5050
});
51+
52+
describe("an agent whose program is not on the computer", () => {
53+
test("its chat says why it keeps restarting", async () => {
54+
const fake = new FakeBotloft();
55+
fake.system = {
56+
...fake.system,
57+
enabledAgents: ["claude", "codex"],
58+
agentChecks: [{ agent: "codex", version: null, path: null }],
59+
};
60+
const crew = fake.addCrew("Ops");
61+
const bot = fake.addBot(crew.id, "Cody");
62+
fake.bot(bot.id).agent = "codex";
63+
fake.setBotState(bot.id, "backoff");
64+
renderApp(fake);
65+
await crewOpened("Ops");
66+
fireEvent.click(await screen.findByRole("button", { name: /^Cody,/ }));
67+
expect(await screen.findByText("Codex was not found")).toBeDefined();
68+
expect(screen.getByText(/tell Botloft where it is in Settings/)).toBeDefined();
69+
});
70+
});

‎app/src/features/onboarding/Welcome.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export function Welcome() {
2727
const [creating, setCreating] = useState(false);
2828
const [idea, setIdea] = useState<CrewTemplate | undefined>(undefined);
2929
const checks = useApp((state) => state.system?.agentChecks ?? NO_CHECKS);
30-
const otherReady = checks.some((check) => check.version !== null);
30+
const otherReady = checks.some((check) => check.path !== null);
3131
const whenClosed = usePref(prefs.whenClosed);
3232
const { settings } = useDaemonSettings();
3333
const running =
@@ -60,9 +60,15 @@ export function Welcome() {
6060
const agents = checks.map((check) => (
6161
<Check
6262
key={check.agent}
63-
state={check.version === null ? "bad" : "ok"}
63+
state={check.path === null ? "bad" : "ok"}
6464
title={t.bots.dialog.agent.names[check.agent]}
65-
detail={check.version === null ? w.agentMissing : w.version(check.version)}
65+
detail={
66+
check.version !== null
67+
? w.version(check.version)
68+
: check.path !== null
69+
? check.path
70+
: w.agentMissing
71+
}
6672
/>
6773
));
6874

‎app/src/features/onboarding/agents.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ describe("the first screen for someone who does not use Claude Code", () => {
1313
claudeVersion: null,
1414
runtimeError: "Claude Code was not found",
1515
enabledAgents: ["claude", "agy"],
16-
agentChecks: [{ agent: "agy", version: "1.3.1" }],
16+
agentChecks: [{ agent: "agy", version: "1.3.1", path: "C:agyagy.exe" }],
1717
};
1818
renderApp(fake);
1919
expect(await screen.findByText("Antigravity (experimental)")).toBeDefined();
@@ -27,7 +27,7 @@ describe("the first screen for someone who does not use Claude Code", () => {
2727
fake.system = {
2828
...fake.system,
2929
enabledAgents: ["claude", "agy"],
30-
agentChecks: [{ agent: "agy", version: null }],
30+
agentChecks: [{ agent: "agy", version: null, path: null }],
3131
};
3232
renderApp(fake);
3333
expect(await screen.findByText(/Not found\. Install it/)).toBeDefined();

‎app/src/i18n/en/account.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,16 @@ export const account = {
256256
"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.",
257257
defaultAgentHint:
258258
"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.",
259+
programFound: (version: string, path: string) => `Found: ${path} (${version})`,
260+
programFoundNoVersion: (path: string) => `Found: ${path}`,
261+
programMissing: (name: string) =>
262+
`${name} was not found on this computer. Install it, or say below where it is.`,
263+
programPath: (name: string) => `Where ${name} is`,
264+
programPlaceholder: "Full path to the program",
265+
programSave: "Save",
266+
programFailed: "Could not save the path",
267+
programHint:
268+
"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.",
259269
chat: "Chat",
260270
enterSends: "Enter sends the message",
261271
enterSendsOn: "Shift+Enter starts a new line.",

‎app/src/i18n/en/bots.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ export const bots = {
136136
notices: {
137137
crewPaused: (crew: string) => `${crew} is paused`,
138138
crewPausedBody: "Its agents stay stopped until you resume the crew.",
139+
programMissing: (name: string) => `${name} was not found`,
140+
programMissingBody: "Install it, or tell Botloft where it is in Settings.",
139141
},
140142
details: {
141143
title: (name: string) => `About ${name}`,

‎app/src/i18n/es/account.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,16 @@ export const account: Messages["account"] = {
263263
"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.",
264264
defaultAgentHint:
265265
"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ó.",
266+
programFound: (version: string, path: string) => `Encontrado: ${path} (${version})`,
267+
programFoundNoVersion: (path: string) => `Encontrado: ${path}`,
268+
programMissing: (name: string) =>
269+
`${name} no se encontró en este equipo. Instálalo o indica abajo dónde está.`,
270+
programPath: (name: string) => `Dónde está ${name}`,
271+
programPlaceholder: "Ruta completa del programa",
272+
programSave: "Guardar",
273+
programFailed: "No se pudo guardar la ruta",
274+
programHint:
275+
"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.",
266276
chat: "Chat",
267277
enterSends: "Enter envía el mensaje",
268278
enterSendsOn: "Shift+Enter empieza una nueva línea.",

0 commit comments

Comments
 (0)