-
{t.chat.tools.input}
-
{pretty(tool.input)}
+
+ {command ? t.chat.tools.command : t.chat.tools.input}
+
+
{command ? command.text : pretty(tool.input)}
+ {command && !command.whole && (
+
{t.chat.tools.commandCut}
+ )}
{tool.output !== null && (
<>
diff --git a/app/src/features/chat/chat.test.tsx b/app/src/features/chat/chat.test.tsx
index fd2f4b8a..a28f54d2 100644
--- a/app/src/features/chat/chat.test.tsx
+++ b/app/src/features/chat/chat.test.tsx
@@ -67,12 +67,16 @@ describe("chat", () => {
let call = fake.chat.items[0];
act(() => {
call = fake.chat.tool(scout.id, "Bash", {
- summary: "Run the tests",
- input: '{"command":"npm test"}',
+ summary: "npm test",
+ explanation: "Run the tests",
+ input: '{"command":"npm test","description":"Run the tests"}',
});
});
const line = within(chat()).getByRole("button", { name: /Run a command/ });
+ // The bot's words on the line; the command itself opens below.
expect(within(line).getByText("Run the tests")).toBeDefined();
+ expect(within(chat()).queryByText("npm test")).toBeNull();
+ expect(within(sidebar()).getByText("Run a command · Run the tests")).toBeDefined();
expect(within(line).getByLabelText("Running")).toBeDefined();
act(() => {
if (call) {
@@ -84,7 +88,8 @@ describe("chat", () => {
expect(within(line).getByLabelText("Done")).toBeDefined();
fireEvent.click(line);
expect(within(chat()).getByText("12 passed")).toBeDefined();
- expect(within(chat()).getByText(/"command": "npm test"/)).toBeDefined();
+ expect(within(chat()).getByText("Command")).toBeDefined();
+ expect(within(chat()).getByText("npm test")).toBeDefined();
expect(within(chat()).getByText("Done in 4.2 s")).toBeDefined();
});
@@ -95,7 +100,10 @@ describe("chat", () => {
fake.chat.ask(scout.id, "Bash", "rm -rf build", '{"command":"rm -rf build"}');
});
const card = screen.getByRole("region", { name: "Scout asks to run a command" });
- expect(within(card).getByText("rm -rf build")).toBeDefined();
+ // The bot said nothing about it: the card says so and shows the command.
+ expect(within(card).getByText(/^Scout did not say what this command is for\./)).toBeDefined();
+ const command = within(card).getByText("rm -rf build");
+ expect(command.closest("details")?.open).toBe(true);
fireEvent.change(within(card).getByLabelText("Note for Scout if you deny"), {
target: { value: "keep the cache" },
});
@@ -114,6 +122,64 @@ describe("chat", () => {
expect(await within(chat()).findByText("Allowed: write a file")).toBeDefined();
});
+ test("a command request leads with the bot's explanation and keeps the command a click away", async () => {
+ const { fake, scout } = crew();
+ await openScout(fake);
+ const script = "cd C:/Work && python - <<'EOF'\nprint('hello')\nEOF";
+ const why = "Instala o pacote requests, para o script acessar páginas da internet";
+ act(() => {
+ fake.chat.ask(
+ scout.id,
+ "Bash",
+ "cd C:/Work && python - <<'EOF' print('hello') EOF",
+ JSON.stringify({ command: script, description: why }),
+ why,
+ );
+ });
+ const card = screen.getByRole("region", { name: "Scout asks to run a command" });
+ expect(within(card).getByText(why)).toBeDefined();
+ // The bot wrote it, so the card says whose words these are and where
+ // the real thing is; the command is whole, with its line breaks.
+ expect(
+ within(card).getByText("Scout wrote this. What actually runs is the command below."),
+ ).toBeDefined();
+ const folded = within(card).getByText("See the command").closest("details");
+ expect(folded?.open).toBe(false);
+ expect(folded?.querySelector("div div")?.textContent).toBe(script);
+ expect(within(card).queryByText(/too long to show/)).toBeNull();
+ fireEvent.click(within(card).getByRole("button", { name: "Allow" }));
+ const answered = await within(chat()).findByText("Allowed: run a command");
+ expect(within(answered.closest("p") as HTMLElement).getByText(why)).toBeDefined();
+ });
+
+ test("a command too long to keep says it is cut, and other tools keep their details", async () => {
+ const { fake, scout } = crew();
+ await openScout(fake);
+ act(() => {
+ fake.chat.ask(
+ scout.id,
+ "PowerShell",
+ "Get-Date",
+ '{"command":"Get-Date; Get-Da…',
+ "Shows today",
+ );
+ });
+ const card = screen.getByRole("region", { name: "Scout asks to run a command" });
+ expect(within(card).getByText("This command is too long to show in full.")).toBeDefined();
+ expect(within(card).getByText('{"command":"Get-Date; Get-Da…')).toBeDefined();
+ fireEvent.click(within(card).getByRole("button", { name: "Deny" }));
+ await within(chat()).findByText("Denied: run a command");
+
+ act(() => {
+ fake.chat.ask(scout.id, "WebFetch", "https://example.com", '{"url":"https://example.com"}');
+ });
+ const page = screen.getByRole("region", { name: "Scout asks to read a web page" });
+ expect(within(page).getByText("https://example.com")).toBeDefined();
+ const details = within(page).getByText("Details").closest("details");
+ expect(details?.open).toBe(false);
+ expect(details?.textContent).toContain('"url": "https://example.com"');
+ });
+
test("files are picked or dropped, shown before sending, and sent with the text", async () => {
const { fake } = crew();
await openScout(fake);
diff --git a/app/src/features/chat/command.ts b/app/src/features/chat/command.ts
new file mode 100644
index 00000000..2adaf97c
--- /dev/null
+++ b/app/src/features/chat/command.ts
@@ -0,0 +1,31 @@
+// A shell command a bot runs or asks to run (spec 10.1). Its input holds
+// the command and, maybe, what the bot says it is for; the daemon sends
+// that apart, as the item's `explanation`.
+
+/** The tools that run a shell command. */
+export function isCommand(tool: string): boolean {
+ return tool === "Bash" || tool === "PowerShell";
+}
+
+export interface Command {
+ /** The command as the bot wrote it, line breaks and all. */
+ text: string;
+ /** False when it was too long to keep and the end is missing. */
+ whole: boolean;
+}
+
+/** The command in a call's input. One cut short does not parse: it is shown as it came. */
+export function commandOf(input: string): Command {
+ try {
+ const parsed: unknown = JSON.parse(input);
+ if (typeof parsed === "object" && parsed !== null && "command" in parsed) {
+ const { command } = parsed;
+ if (typeof command === "string") {
+ return { text: command, whole: true };
+ }
+ }
+ return { text: JSON.stringify(parsed, null, 2), whole: true };
+ } catch {
+ return { text: input, whole: false };
+ }
+}
diff --git a/app/src/features/chat/context.test.tsx b/app/src/features/chat/context.test.tsx
new file mode 100644
index 00000000..ead40c34
--- /dev/null
+++ b/app/src/features/chat/context.test.tsx
@@ -0,0 +1,143 @@
+import { act, cleanup, fireEvent, screen, within } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import { FakeBotloft } from "../../lib/fake";
+import { tokens } from "../../lib/format";
+import type { ContextUsage } from "../../lib/protocol.gen";
+import { crewOpened, openBot, renderApp, sidebar } from "../../test/app";
+
+afterEach(cleanup);
+
+const holds = (usedTokens: number, windowTokens = 1_000_000): ContextUsage => ({
+ usedTokens,
+ windowTokens,
+ autoCompactTokens: windowTokens - 33_000,
+ compacting: false,
+ updatedAt: 1,
+});
+
+/** A crew "Ops" with @scout, open on its chat. */
+async function openScout(context: ContextUsage | null = holds(556_000)) {
+ const fake = new FakeBotloft();
+ const ops = fake.addCrew("Ops");
+ const scout = fake.addBot(ops.id, "Scout", "Finds sources");
+ fake.setBotState(scout.id, "idle");
+ fake.bot(scout.id).context = context;
+ renderApp(fake);
+ await crewOpened("Ops");
+ openBot("Scout");
+ await screen.findByRole("list", { name: "Messages" });
+ return { fake, scout };
+}
+
+const meter = () => screen.getByRole("button", { name: /^Conversation space: / });
+const compactions = (fake: FakeBotloft) =>
+ fake.calls.filter((call) => call.method === "bots.compact");
+
+function open() {
+ fireEvent.click(meter());
+ return screen.getByRole("dialog", { name: "Conversation space" });
+}
+
+describe("conversation space", () => {
+ test("token counts read short", () => {
+ expect(tokens(850)).toBe("850");
+ expect(tokens(24_340)).toBe("24.3k");
+ expect(tokens(22_000)).toBe("22k");
+ expect(tokens(556_000)).toBe("556k");
+ expect(tokens(967_000)).toBe("967k");
+ expect(tokens(1_000_000)).toBe("1M");
+ expect(tokens(1_240_000)).toBe("1.2M");
+ });
+
+ test("it shows once Claude Code told it, and follows what arrives", async () => {
+ const { fake, scout } = await openScout(null);
+ expect(screen.queryByRole("button", { name: /^Conversation space/ })).toBeNull();
+
+ act(() => fake.setContext(scout.id, holds(556_000)));
+ expect(meter().getAttribute("aria-label")).toBe("Conversation space: 556k of 1M used (56%)");
+ const menu = open();
+ expect(within(menu).getByText("556k / 1M (56%)")).toBeDefined();
+ expect(
+ within(menu).getByText("411k left before it compacts by itself, at 967k."),
+ ).toBeDefined();
+ expect(within(menu).getByText(/Everything Scout read and wrote/)).toBeDefined();
+
+ act(() => fake.setContext(scout.id, holds(968_500)));
+ expect(within(menu).getByText("969k / 1M (97%)")).toBeDefined();
+ expect(within(menu).getByText(/full enough to compact by itself/)).toBeDefined();
+
+ act(() => fake.setContext(scout.id, { ...holds(24_300, 200_000), autoCompactTokens: null }));
+ expect(within(menu).getByText("24.3k / 200k (12%)")).toBeDefined();
+ expect(within(menu).getByText("It does not compact by itself.")).toBeDefined();
+ });
+
+ test("the owner compacts the conversation and the chat says so", async () => {
+ const { fake, scout } = await openScout();
+ const menu = open();
+ fireEvent.click(within(menu).getByRole("button", { name: "Compact now" }));
+ const waiting = await within(menu).findByRole("button", { name: "Compacting…" });
+ expect((waiting as HTMLButtonElement).disabled).toBe(true);
+ expect(compactions(fake).map((call) => call.params)).toEqual([{ botId: scout.id }]);
+ expect(screen.queryByText(/compacts the conversation when/)).toBeNull();
+
+ // Claude Code is done: the notice, then the new size.
+ act(() => {
+ fake.chat.add(scout.id, {
+ kind: "notice",
+ level: "info",
+ code: "compacted",
+ text: "The conversation was compacted: earlier messages are now a summary.",
+ });
+ fake.setContext(scout.id, holds(24_000));
+ });
+ expect(
+ await screen.findByText(/what came before is now a summary, and there is room/),
+ ).toBeDefined();
+ expect(within(menu).getByText("24k / 1M (2%)")).toBeDefined();
+ expect(within(menu).getByRole("button", { name: "Compact now" })).toBeDefined();
+ // Housekeeping does not become the conversation's last line.
+ expect(within(sidebar()).queryByText(/compacted/)).toBeNull();
+ });
+
+ test("a busy bot compacts when it finishes what it is doing", async () => {
+ const { fake, scout } = await openScout();
+ act(() => fake.setBotState(scout.id, "busy"));
+ fireEvent.click(within(open()).getByRole("button", { name: "Compact now" }));
+ expect(
+ await screen.findByText("Scout compacts the conversation when it finishes what it's doing."),
+ ).toBeDefined();
+ });
+
+ test("a bot that is not running cannot compact", async () => {
+ const { fake, scout } = await openScout();
+ act(() => fake.setBotState(scout.id, "offline"));
+ const menu = open();
+ const button = within(menu).getByRole("button", { name: "Compact now" });
+ expect((button as HTMLButtonElement).disabled).toBe(true);
+ expect(within(menu).getByText("Scout is not running, so it cannot compact now.")).toBeDefined();
+ });
+
+ test("compacting by itself and a compaction that failed read in the owner's words", async () => {
+ const { fake, scout } = await openScout();
+ act(() => {
+ fake.chat.add(scout.id, {
+ kind: "notice",
+ level: "info",
+ code: "auto_compacted",
+ text: "The conversation was full, so it was compacted.",
+ });
+ fake.chat.add(scout.id, {
+ kind: "notice",
+ level: "warning",
+ code: "compact_failed",
+ text: "No messages to compact",
+ });
+ });
+ expect(
+ await screen.findByText(/was full, so it was compacted: what came before/),
+ ).toBeDefined();
+ expect(
+ screen.getByText("The conversation could not be compacted: No messages to compact"),
+ ).toBeDefined();
+ });
+});
diff --git a/app/src/features/chat/effort.test.tsx b/app/src/features/chat/effort.test.tsx
new file mode 100644
index 00000000..87511aa1
--- /dev/null
+++ b/app/src/features/chat/effort.test.tsx
@@ -0,0 +1,131 @@
+import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
+import { afterEach, describe, expect, test } from "vitest";
+import { FakeBotloft } from "../../lib/fake";
+import type { Bot } from "../../lib/protocol.gen";
+import { crewOpened, openBot, renderApp } from "../../test/app";
+
+afterEach(cleanup);
+
+/** A crew "Ops" with @scout, open on its chat. */
+async function openScout(setup?: (bot: Bot, fake: FakeBotloft) => void) {
+ const fake = new FakeBotloft();
+ const ops = fake.addCrew("Ops");
+ const scout = fake.addBot(ops.id, "Scout", "Finds sources");
+ fake.setBotState(scout.id, "idle");
+ // What Claude Code reported for a bot on Sonnet.
+ fake.bot(scout.id).modelInUse = "claude-sonnet-5-5";
+ fake.bot(scout.id).effortDefault = "medium";
+ setup?.(fake.bot(scout.id), fake);
+ renderApp(fake);
+ await crewOpened("Ops");
+ openBot("Scout");
+ await screen.findByRole("list", { name: "Messages" });
+ return { fake, scout };
+}
+
+const picker = () => screen.getByRole("button", { name: /^Effort: / });
+const efforts = (fake: FakeBotloft) =>
+ fake.calls.filter((call) => call.method === "bots.setEffort").map((call) => call.params);
+
+function open() {
+ fireEvent.click(picker());
+ const menu = screen.getByRole("dialog", { name: "Effort" });
+ return { menu, slider: within(menu).queryByRole("slider", { name: "Effort" }) };
+}
+
+function slideTo(slider: HTMLElement | null, stop: number) {
+ fireEvent.change(slider as HTMLElement, { target: { value: String(stop) } });
+}
+
+describe("effort", () => {
+ test("a bot runs at its model's own level until the owner moves the slider", async () => {
+ const { fake, scout } = await openScout();
+ expect(picker().getAttribute("aria-label")).toBe("Effort: Medium");
+ const { menu, slider } = open();
+ expect((slider as HTMLInputElement).value).toBe("1");
+ expect(slider?.getAttribute("aria-valuetext")).toBe("Medium (Recommended)");
+ expect(within(menu).getByText("Faster")).toBeDefined();
+ expect(within(menu).getByText("Smarter")).toBeDefined();
+ expect(within(menu).getByText(/Recommended for Sonnet 5.5/)).toBeDefined();
+ expect(within(menu).queryByRole("button", { name: "Use recommended" })).toBeNull();
+
+ slideTo(slider, 3);
+ // The words follow the thumb at once; the bot is told when it rests.
+ expect(within(menu).getByText(/thinks a lot more/)).toBeDefined();
+ expect(picker().getAttribute("aria-label")).toBe("Effort: Extra high");
+ expect(efforts(fake)).toEqual([]);
+ await waitFor(() => expect(efforts(fake)).toEqual([{ botId: scout.id, effort: "xhigh" }]));
+ expect(screen.queryByText(/switches to/)).toBeNull();
+
+ fireEvent.click(within(menu).getByRole("button", { name: "Use recommended" }));
+ await screen.findByRole("button", { name: "Effort: Medium" });
+ expect(efforts(fake).at(-1)).toEqual({ botId: scout.id, effort: "default" });
+ });
+
+ test("the recommended stop is the model's own level, not a level of the owner's", async () => {
+ const { fake, scout } = await openScout((bot) => {
+ bot.effort = "high";
+ });
+ expect(picker().getAttribute("aria-label")).toBe("Effort: High");
+ const { slider } = open();
+ slideTo(slider, 1);
+ await waitFor(() => expect(efforts(fake)).toEqual([{ botId: scout.id, effort: "default" }]));
+ });
+
+ test("several stops in a row tell the bot only the last", async () => {
+ const { fake, scout } = await openScout();
+ const { slider } = open();
+ slideTo(slider, 2);
+ slideTo(slider, 3);
+ slideTo(slider, 4);
+ await waitFor(() => expect(efforts(fake)).toEqual([{ botId: scout.id, effort: "max" }]));
+ });
+
+ test("a stop left on the slider is kept when the menu closes", async () => {
+ const { fake, scout } = await openScout();
+ const { slider } = open();
+ slideTo(slider, 0);
+ fireEvent.keyDown(window, { key: "Escape" });
+ expect(screen.queryByRole("dialog", { name: "Effort" })).toBeNull();
+ await waitFor(() => expect(efforts(fake)).toEqual([{ botId: scout.id, effort: "low" }]));
+ });
+
+ test("a busy bot changes effort when it finishes what it is doing", async () => {
+ const { fake, scout } = await openScout();
+ act(() => fake.setBotState(scout.id, "busy"));
+ const { slider } = open();
+ slideTo(slider, 0);
+ expect(
+ await screen.findByText("Scout switches to Low effort when it finishes what it's doing."),
+ ).toBeDefined();
+ });
+
+ test("a model without effort levels has no slider", async () => {
+ await openScout((bot) => {
+ bot.modelInUse = "claude-haiku-4-5-20251001";
+ bot.effortDefault = "none";
+ });
+ expect(picker().getAttribute("aria-label")).toBe("Effort: Not available");
+ const { menu, slider } = open();
+ expect(slider).toBeNull();
+ expect(
+ within(menu).getByText(
+ "Haiku 4.5 has no effort levels: Scout always answers at the same pace.",
+ ),
+ ).toBeDefined();
+ expect(within(menu).queryByRole("button", { name: "Use recommended" })).toBeNull();
+ });
+
+ test("nothing is marked until Claude Code said the model's level", async () => {
+ const { fake, scout } = await openScout((bot) => {
+ bot.effortDefault = null;
+ });
+ expect(picker().getAttribute("aria-label")).toBe("Effort: Recommended");
+ const { menu, slider } = open();
+ expect(slider?.hasAttribute("data-unset")).toBe(true);
+ expect(within(menu).getByText("Scout uses the level recommended for its model")).toBeDefined();
+ // With no model level to compare, the stop is the owner's own.
+ slideTo(slider, 1);
+ await waitFor(() => expect(efforts(fake)).toEqual([{ botId: scout.id, effort: "medium" }]));
+ });
+});
diff --git a/app/src/features/chat/rows.test.ts b/app/src/features/chat/rows.test.ts
index a5435a5b..f8dfa7a0 100644
--- a/app/src/features/chat/rows.test.ts
+++ b/app/src/features/chat/rows.test.ts
@@ -18,6 +18,7 @@ const tool = (name: string) =>
toolUseId: name,
name,
summary: "",
+ explanation: null,
input: "{}",
status: "done",
output: null,
diff --git a/app/src/features/crews/CrewView.tsx b/app/src/features/crews/CrewView.tsx
index bc53019c..b322903a 100644
--- a/app/src/features/crews/CrewView.tsx
+++ b/app/src/features/crews/CrewView.tsx
@@ -8,6 +8,7 @@ import {
Pencil,
Play,
Plus,
+ Trash2,
} from "lucide-react";
import { useState } from "react";
import { useShallow } from "zustand/react/shallow";
@@ -27,8 +28,9 @@ import { CrewRoutines } from "../routines/RoutineList";
import { TaskList } from "../tasks/TaskList";
import { CrewBots } from "./CrewBots";
import { CrewDialog } from "./CrewDialog";
+import { DeleteCrew } from "./DeleteCrew";
-type Open = "bot" | "rename" | "archive" | { move: string } | null;
+type Open = "bot" | "rename" | "archive" | "delete" | { move: string } | null;
type Pane = "bots" | "timeline" | "tasks" | "routines";
export function CrewView({ crew }: { crew: Crew }) {
@@ -109,6 +111,7 @@ export function CrewView({ crew }: { crew: Crew }) {
danger: true,
onSelect: () => setOpen("archive"),
},
+ { label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") },
]}
/>
@@ -188,6 +191,7 @@ export function CrewView({ crew }: { crew: Crew }) {
{words.archiveBody(bots.length)}
)}
+ {open === "delete" && }
);
}
diff --git a/app/src/features/crews/DeleteCrew.tsx b/app/src/features/crews/DeleteCrew.tsx
new file mode 100644
index 00000000..1c6466f6
--- /dev/null
+++ b/app/src/features/crews/DeleteCrew.tsx
@@ -0,0 +1,63 @@
+import { useState } from "react";
+import { useT } from "../../i18n";
+import type { Crew } from "../../lib/protocol.gen";
+import { useApi, useApp } from "../../store/context";
+import { Confirm } from "../../ui/Confirm";
+import { KeptFolder } from "../../ui/KeptFolder";
+import { attempt } from "../../ui/toast";
+
+/**
+ * Asks before deleting a crew for good (spec 7.6), saying what goes and
+ * what stays, with the choice to send its folders to the Recycle Bin.
+ * `bots` is how many bots go with it.
+ */
+export function DeleteCrew({
+ crew,
+ bots,
+ onClose,
+ onDeleted,
+}: {
+ crew: Crew;
+ bots: number;
+ onClose(): void;
+ onDeleted?(): void;
+}) {
+ const words = useT().crews.view;
+ const api = useApi();
+ const dropCrew = useApp((state) => state.dropCrew);
+ // The folders stay unless the owner says otherwise.
+ const [recycle, setRecycle] = useState(false);
+ // What goes to the bin is the crew's own folder, where `shared` is
+ // (spec 5); a work folder the owner chose is somewhere else and stays.
+ const shown =
+ recycle && !crew.workFolderChosen
+ ? crew.workFolder.replace(/[\\/]shared$/, "")
+ : crew.workFolder;
+ const said = !recycle
+ ? words.deleteKept
+ : crew.workFolderChosen
+ ? words.deleteRecycledChosen
+ : words.deleteRecycled;
+ return (
+
+ attempt(words.failed.delete, async () => {
+ const asked = recycle ? { crewId: crew.id, recycleFolder: true } : { crewId: crew.id };
+ dropCrew((await api.call("crews.delete", asked)).crewId);
+ onDeleted?.();
+ })
+ }
+ >
+ {words.deleteBody(crew.name, bots, crew.archivedAt === null)}
+
+ {said}
+
+
+ );
+}
diff --git a/app/src/features/crews/botMenu.test.tsx b/app/src/features/crews/botMenu.test.tsx
index f26c0988..a8be2fb9 100644
--- a/app/src/features/crews/botMenu.test.tsx
+++ b/app/src/features/crews/botMenu.test.tsx
@@ -40,6 +40,7 @@ describe("a bot's right-click menu", () => {
"Restart with a new conversation",
"Open folder",
"Archive bot",
+ "Delete bot",
]);
// The crew's page is still the one open.
expect(screen.getByRole("heading", { level: 1, name: "Ops" })).toBeDefined();
diff --git a/app/src/features/messages/MessageRow.tsx b/app/src/features/messages/MessageRow.tsx
index 08075b3d..0db72590 100644
--- a/app/src/features/messages/MessageRow.tsx
+++ b/app/src/features/messages/MessageRow.tsx
@@ -9,7 +9,7 @@ import { DeliveryStatus } from "./DeliveryStatus";
function BotName({ bot }: { bot: Bot | undefined }) {
const t = useT();
if (!bot) {
- return {t.messages.row.archivedBot} ;
+ return {t.messages.row.goneBot} ;
}
return (
diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts
index c4ba71d8..2bc6745c 100644
--- a/app/src/i18n/en/account.ts
+++ b/app/src/i18n/en/account.ts
@@ -87,6 +87,17 @@ export const account = {
size: "Size",
sizeHint: "Ctrl+= and Ctrl+- change the size too, and Ctrl+0 goes back to the default.",
language: "Language",
+ archived: "Archived",
+ archivedIntro:
+ "Bots and crews you archived are stopped and out of sight, and Botloft still keeps their conversations. Delete one to remove it for good.",
+ archivedEmpty: "Nothing is archived.",
+ archivedLoadFailed: "Could not load what is archived",
+ /** `when` is relative, like "2 days ago". */
+ archivedCrew: (bots: number, when: string) =>
+ `Crew · ${bots === 1 ? "1 bot" : `${bots} bots`} · archived ${when}`,
+ archivedBot: (crew: string, when: string) => `Bot in ${crew} · archived ${when}`,
+ deleteArchived: "Delete",
+ deleteArchivedOne: (name: string) => `Delete ${name}`,
about: "About",
claudeCode: (version: string) => `Claude Code ${version}`,
botloft: (version: string) => `Botloft ${version}`,
diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts
index 47896821..a8234f5e 100644
--- a/app/src/i18n/en/bots.ts
+++ b/app/src/i18n/en/bots.ts
@@ -48,6 +48,7 @@ export const bots = {
makeChief: "Make crew chief",
stopChief: "Stop being chief",
archive: "Archive bot",
+ delete: "Delete bot",
failed: {
pause: "Could not pause the bot",
resume: "Could not resume the bot",
@@ -55,6 +56,7 @@ export const bots = {
openFolder: "Could not open the folder",
chief: "Could not change the chief",
archive: "Could not archive the bot",
+ delete: "Could not delete the bot",
},
fresh: {
title: "Start a new conversation?",
@@ -67,6 +69,18 @@ export const bots = {
confirm: "Archive bot",
body: "The bot stops and leaves the crew. Messages still waiting for it are not delivered.",
},
+ deleteConfirm: {
+ title: (name: string) => `Delete ${name}?`,
+ confirm: "Delete bot",
+ /** `running` is false for an archived bot, which stopped long ago. */
+ removed: (name: string, running: boolean) =>
+ `${name} ${running ? "stops now and leaves" : "leaves"} Botloft for good, with its conversation, its routines and the tasks it was part of. This can't be undone.`,
+ chief: (crew: string) => `${crew} will be left without a chief.`,
+ kept: (name: string) => `${name}'s folder stays on your computer, with everything in it:`,
+ recycle: "Move this folder to the Recycle Bin",
+ recycled: (name: string) =>
+ `${name}'s folder goes to the Recycle Bin, where you can still get it back:`,
+ },
},
dialog: {
newTitle: "New bot",
diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts
index 30ef29c9..479f4d05 100644
--- a/app/src/i18n/en/chat.ts
+++ b/app/src/i18n/en/chat.ts
@@ -43,7 +43,7 @@ export const chat = {
inbound: {
task: "Task",
result: "Result",
- archivedBot: "An archived bot",
+ goneBot: "A bot no longer in the crew",
},
run: {
working: "Working",
@@ -60,11 +60,16 @@ export const chat = {
input: "Input",
output: "Output",
error: "Error",
+ command: "Command",
+ commandCut: "This command is too long to show in full.",
},
approval: {
asks: (bot: string, action: string) => `${bot} asks to ${action}`,
wants: (bot: string, action: string) => `${bot} wants to ${action}`,
- fullInput: "Full input",
+ explainedBy: (bot: string) => `${bot} wrote this. What actually runs is the command below.`,
+ unexplained: (bot: string) =>
+ `${bot} did not say what this command is for. If you are not sure, deny it and ask.`,
+ command: "See the command",
noteLabel: (bot: string) => `Note for ${bot} if you deny`,
notePlaceholder: "Why not? Sent to the bot if you deny (optional)",
allow: "Allow",
@@ -135,6 +140,58 @@ export const chat = {
later: (bot: string, model: string) =>
`${bot} switches to ${model} when it finishes what it's doing.`,
},
+ effort: {
+ title: "Effort",
+ button: (level: string) => `Effort: ${level}`,
+ short: "Effort",
+ faster: "Faster",
+ smarter: "Smarter",
+ names: {
+ low: "Low",
+ medium: "Medium",
+ high: "High",
+ xhigh: "Extra high",
+ max: "Max",
+ },
+ hints: {
+ low: (bot: string) => `${bot} answers fastest and thinks the least, for simple tasks`,
+ medium: (bot: string) => `${bot} balances speed and thinking, good for most work`,
+ high: (bot: string) => `${bot} thinks more before answering, for harder tasks`,
+ xhigh: (bot: string) => `${bot} thinks a lot more, for long and complex work`,
+ max: (bot: string) =>
+ `${bot} thinks as much as it can: slowest, and uses the most of your plan`,
+ },
+ recommended: "Recommended",
+ recommendedFor: (model: string) => `Recommended for ${model}`,
+ useRecommended: "Use recommended",
+ unknown: (bot: string) => `${bot} uses the level recommended for its model`,
+ unavailable: "Not available",
+ none: (bot: string, model: string) =>
+ `${model} has no effort levels: ${bot} always answers at the same pace.`,
+ thisModel: "This model",
+ cost: "More effort uses up your plan's limit faster.",
+ failed: "Could not change the effort",
+ later: (bot: string, level: string) =>
+ `${bot} switches to ${level} effort when it finishes what it's doing.`,
+ },
+ context: {
+ title: "Conversation space",
+ button: (used: string, total: string, percent: number) =>
+ `Conversation space: ${used} of ${total} used (${percent}%)`,
+ used: (used: string, total: string, percent: number) => `${used} / ${total} (${percent}%)`,
+ about: (bot: string) =>
+ `Everything ${bot} read and wrote in this conversation takes up space. The fuller it is, the more of your plan each message uses.`,
+ autoLeft: (left: string, at: string) => `${left} left before it compacts by itself, at ${at}.`,
+ autoNow: "It is full enough to compact by itself on the next message.",
+ noAuto: "It does not compact by itself.",
+ compact: "Compact now",
+ compactHint: (bot: string) =>
+ `Replaces what came before with a summary, so ${bot} has room again and each message uses less.`,
+ compacting: "Compacting…",
+ later: (bot: string) => `${bot} compacts the conversation when it finishes what it's doing.`,
+ notRunning: (bot: string) => `${bot} is not running, so it cannot compact now.`,
+ failed: "Could not compact the conversation",
+ },
suggestion: {
title: (bot: string) => `${bot} suggests a new bot`,
why: "Why",
@@ -176,5 +233,10 @@ export const chat = {
modelUnavailable:
"This bot's model isn't available: it may not be on your Claude plan. Pick another model below the chat.",
turnFailed: (detail: string) => `The bot couldn't finish this: ${detail}`,
+ compacted:
+ "The conversation was compacted: what came before is now a summary, and there is room again.",
+ autoCompacted:
+ "The conversation was full, so it was compacted: what came before is now a summary.",
+ compactFailed: (detail: string) => `The conversation could not be compacted: ${detail}`,
},
};
diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts
index 0ea025b5..b84f232e 100644
--- a/app/src/i18n/en/crews.ts
+++ b/app/src/i18n/en/crews.ts
@@ -49,6 +49,7 @@ export const crews = {
`The bots will work in ${path}. Each one restarts when it finishes what it's doing. Files already made stay where they are.`,
move: "Move",
archive: "Archive crew",
+ delete: "Delete crew",
tabs: {
label: "Crew views",
bots: "Bots",
@@ -62,12 +63,26 @@ export const crews = {
bots === 0
? "The crew leaves the app."
: `The crew leaves the app. ${bots === 1 ? "Its bot stops" : `Its ${bots} bots stop`}, and messages still waiting for them are not delivered.`,
+ deleteTitle: (crew: string) => `Delete ${crew}?`,
+ /** `running` is false for an archived crew, which stopped long ago. */
+ deleteBody: (crew: string, bots: number, running: boolean) =>
+ bots === 0
+ ? `${crew} leaves Botloft for good. This can't be undone.`
+ : `${crew} and ${bots === 1 ? "its bot" : `its ${bots} bots`} ${running ? "stop now and leave" : "leave"} Botloft for good, with their conversations, routines and tasks. This can't be undone.`,
+ deleteKept:
+ "The folders stay on your computer, with everything in them: each bot's own folder and the crew's work folder:",
+ deleteRecycle: "Move the crew's folders to the Recycle Bin",
+ deleteRecycled:
+ "The crew's folder goes to the Recycle Bin, with each bot's folder and the work folder in it. You can still get it back from there:",
+ deleteRecycledChosen:
+ "Each bot's own folder goes to the Recycle Bin, where you can still get it back. The work folder you chose stays where it is:",
failed: {
pause: "Could not pause the crew",
resume: "Could not resume the crew",
changeFolder: "Could not change the folder",
openFolder: "Could not open the folder",
archive: "Could not archive the crew",
+ delete: "Could not delete the crew",
},
},
bots: {
diff --git a/app/src/i18n/en/messages.ts b/app/src/i18n/en/messages.ts
index 19bdafd4..01c2b7a7 100644
--- a/app/src/i18n/en/messages.ts
+++ b/app/src/i18n/en/messages.ts
@@ -33,7 +33,7 @@ export const messages = {
row: {
you: "You",
system: "Botloft",
- archivedBot: "an archived bot",
+ goneBot: "a bot no longer in the crew",
to: "to",
task: "Task",
result: "Result",
diff --git a/app/src/i18n/en/shell.ts b/app/src/i18n/en/shell.ts
index 745c3686..7be45d3c 100644
--- a/app/src/i18n/en/shell.ts
+++ b/app/src/i18n/en/shell.ts
@@ -22,6 +22,9 @@ export const shell = {
reconnecting: "Reconnecting…",
},
loadFailed: "Could not load your crews",
+ /** A deleted bot's or crew's folder that could not go to the Recycle Bin. */
+ recycleFailed: (path: string) =>
+ `The folder ${path} did not go to the Recycle Bin and is still there`,
pickCrew: "Pick a crew on the left, or create one with the + button.",
botsCantStart: "Bots can't start",
signIn: {
diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts
index 9b79d765..828327b5 100644
--- a/app/src/i18n/es/account.ts
+++ b/app/src/i18n/es/account.ts
@@ -86,6 +86,16 @@ export const account: Messages["account"] = {
size: "Tamaño",
sizeHint: "Ctrl+= y Ctrl+- también cambian el tamaño, y Ctrl+0 vuelve al predeterminado.",
language: "Idioma",
+ archived: "Archivados",
+ archivedIntro:
+ "Los bots y los equipos que archivaste están detenidos y fuera de la vista, y Botloft todavía guarda sus conversaciones. Elimina uno para quitarlo para siempre.",
+ archivedEmpty: "No hay nada archivado.",
+ archivedLoadFailed: "No se pudo cargar lo archivado",
+ archivedCrew: (bots: number, when: string) =>
+ `Equipo · ${bots === 1 ? "1 bot" : `${bots} bots`} · archivado ${when}`,
+ archivedBot: (crew: string, when: string) => `Bot de ${crew} · archivado ${when}`,
+ deleteArchived: "Eliminar",
+ deleteArchivedOne: (name: string) => `Eliminar ${name}`,
about: "Acerca de",
claudeCode: (version: string) => `Claude Code ${version}`,
botloft: (version: string) => `Botloft ${version}`,
diff --git a/app/src/i18n/es/bots.ts b/app/src/i18n/es/bots.ts
index ef9fa0e4..0cb7480a 100644
--- a/app/src/i18n/es/bots.ts
+++ b/app/src/i18n/es/bots.ts
@@ -44,6 +44,7 @@ export const bots: Messages["bots"] = {
makeChief: "Hacer jefe del equipo",
stopChief: "Dejar de ser jefe",
archive: "Archivar bot",
+ delete: "Eliminar bot",
failed: {
pause: "No se pudo pausar el bot",
resume: "No se pudo reanudar el bot",
@@ -51,6 +52,7 @@ export const bots: Messages["bots"] = {
openFolder: "No se pudo abrir la carpeta",
chief: "No se pudo cambiar el jefe",
archive: "No se pudo archivar el bot",
+ delete: "No se pudo eliminar el bot",
},
fresh: {
title: "¿Empezar una conversación nueva?",
@@ -63,6 +65,18 @@ export const bots: Messages["bots"] = {
confirm: "Archivar bot",
body: "El bot se detiene y sale del equipo. Los mensajes que aún lo esperan no se entregan.",
},
+ deleteConfirm: {
+ title: (name: string) => `¿Eliminar ${name}?`,
+ confirm: "Eliminar bot",
+ removed: (name: string, running: boolean) =>
+ `${name} ${running ? "se detiene ahora y sale" : "sale"} de Botloft para siempre, con su conversación, sus rutinas y las tareas en las que participaba. No se puede deshacer.`,
+ chief: (crew: string) => `${crew} se queda sin jefe.`,
+ kept: (name: string) =>
+ `La carpeta de ${name} sigue en tu ordenador, con todo lo que contiene:`,
+ recycle: "Enviar esta carpeta a la Papelera",
+ recycled: (name: string) =>
+ `La carpeta de ${name} va a la Papelera, de donde todavía puedes recuperarla:`,
+ },
},
dialog: {
newTitle: "Nuevo bot",
diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts
index a69002a1..5c49b32f 100644
--- a/app/src/i18n/es/chat.ts
+++ b/app/src/i18n/es/chat.ts
@@ -44,7 +44,7 @@ export const chat: Messages["chat"] = {
inbound: {
task: "Tarea",
result: "Resultado",
- archivedBot: "Un bot archivado",
+ goneBot: "Un bot que ya no está en el equipo",
},
run: {
working: "Trabajando",
@@ -65,11 +65,17 @@ export const chat: Messages["chat"] = {
input: "Entrada",
output: "Salida",
error: "Error",
+ command: "Comando",
+ commandCut: "Este comando es demasiado largo para mostrarse entero.",
},
approval: {
asks: (bot: string, action: string) => `${bot} pide ${action}`,
wants: (bot: string, action: string) => `${bot} quiere ${action}`,
- fullInput: "Entrada completa",
+ explainedBy: (bot: string) =>
+ `${bot} escribió esto. Lo que de verdad se ejecuta es el comando de abajo.`,
+ unexplained: (bot: string) =>
+ `${bot} no dijo para qué sirve este comando. Si tienes dudas, deniégalo y pregunta.`,
+ command: "Ver el comando",
noteLabel: (bot: string) => `Nota para ${bot} si deniegas`,
notePlaceholder: "¿Por qué no? Se envía al bot si deniegas (opcional)",
allow: "Permitir",
@@ -141,6 +147,58 @@ export const chat: Messages["chat"] = {
later: (bot: string, model: string) =>
`${bot} cambia a ${model} cuando termine lo que está haciendo.`,
},
+ effort: {
+ title: "Esfuerzo",
+ button: (level: string) => `Esfuerzo: ${level}`,
+ short: "Esfuerzo",
+ faster: "Más rápido",
+ smarter: "Más inteligente",
+ names: {
+ low: "Bajo",
+ medium: "Medio",
+ high: "Alto",
+ xhigh: "Extra alto",
+ max: "Máximo",
+ },
+ hints: {
+ low: (bot: string) => `${bot} responde más rápido y piensa menos, para tareas simples`,
+ medium: (bot: string) => `${bot} equilibra rapidez y razonamiento, bueno para casi todo`,
+ high: (bot: string) => `${bot} piensa más antes de responder, para tareas más difíciles`,
+ xhigh: (bot: string) => `${bot} piensa mucho más, para trabajo largo y complejo`,
+ max: (bot: string) =>
+ `${bot} piensa todo lo que puede: es el más lento y el que más gasta de tu plan`,
+ },
+ recommended: "Recomendado",
+ recommendedFor: (model: string) => `Recomendado para ${model}`,
+ useRecommended: "Usar el recomendado",
+ unknown: (bot: string) => `${bot} usa el nivel recomendado para su modelo`,
+ unavailable: "No disponible",
+ none: (bot: string, model: string) =>
+ `${model} no tiene niveles de esfuerzo: ${bot} responde siempre al mismo ritmo.`,
+ thisModel: "Este modelo",
+ cost: "Más esfuerzo gasta el límite de tu plan más rápido.",
+ failed: "No se pudo cambiar el esfuerzo",
+ later: (bot: string, level: string) =>
+ `${bot} cambia al esfuerzo ${level} cuando termine lo que está haciendo.`,
+ },
+ context: {
+ title: "Espacio de la conversación",
+ button: (used: string, total: string, percent: number) =>
+ `Espacio de la conversación: ${used} de ${total} en uso (${percent}%)`,
+ used: (used: string, total: string, percent: number) => `${used} / ${total} (${percent}%)`,
+ about: (bot: string) =>
+ `Todo lo que ${bot} leyó y escribió en esta conversación ocupa espacio. Cuanto más llena, más de tu plan gasta cada mensaje.`,
+ autoLeft: (left: string, at: string) => `Faltan ${left} para que se compacte sola, en ${at}.`,
+ autoNow: "Ya está lo bastante llena para compactarse sola en el próximo mensaje.",
+ noAuto: "No se compacta sola.",
+ compact: "Compactar ahora",
+ compactHint: (bot: string) =>
+ `Cambia lo anterior por un resumen: ${bot} vuelve a tener espacio y cada mensaje gasta menos.`,
+ compacting: "Compactando…",
+ later: (bot: string) => `${bot} compacta la conversación cuando termine lo que está haciendo.`,
+ notRunning: (bot: string) => `${bot} no está en marcha, así que no se puede compactar ahora.`,
+ failed: "No se pudo compactar la conversación",
+ },
suggestion: {
title: (bot: string) => `${bot} sugiere un bot nuevo`,
why: "Por qué",
@@ -183,5 +241,9 @@ export const chat: Messages["chat"] = {
modelUnavailable:
"El modelo de este bot no está disponible: quizá no esté en tu plan de Claude. Elige otro modelo debajo del chat.",
turnFailed: (detail: string) => `El bot no pudo terminar esto: ${detail}`,
+ compacted:
+ "La conversación se compactó: lo anterior ahora es un resumen, y vuelve a haber espacio.",
+ autoCompacted: "La conversación estaba llena y se compactó: lo anterior ahora es un resumen.",
+ compactFailed: (detail: string) => `No se pudo compactar la conversación: ${detail}`,
},
};
diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts
index b5b164f4..06d239c4 100644
--- a/app/src/i18n/es/crews.ts
+++ b/app/src/i18n/es/crews.ts
@@ -46,6 +46,7 @@ export const crews: Messages["crews"] = {
`Los bots trabajarán en ${path}. Cada uno se reinicia cuando termine lo que está haciendo. Lo que ya hicieron se queda donde está.`,
move: "Mover",
archive: "Archivar equipo",
+ delete: "Eliminar equipo",
tabs: {
label: "Vistas del equipo",
bots: "Bots",
@@ -59,12 +60,25 @@ export const crews: Messages["crews"] = {
bots === 0
? "El equipo sale de la app."
: `El equipo sale de la app. ${bots === 1 ? "Su bot se detiene" : `Sus ${bots} bots se detienen`}, y los mensajes que aún ${bots === 1 ? "lo" : "los"} esperan no se entregan.`,
+ deleteTitle: (crew: string) => `¿Eliminar ${crew}?`,
+ deleteBody: (crew: string, bots: number, running: boolean) =>
+ bots === 0
+ ? `${crew} sale de Botloft para siempre. No se puede deshacer.`
+ : `${crew} y ${bots === 1 ? "su bot" : `sus ${bots} bots`} ${running ? "se detienen ahora y salen" : "salen"} de Botloft para siempre, con sus conversaciones, rutinas y tareas. No se puede deshacer.`,
+ deleteKept:
+ "Las carpetas siguen en tu ordenador, con todo lo que contienen: la carpeta de cada bot y la carpeta de trabajo del equipo:",
+ deleteRecycle: "Enviar las carpetas del equipo a la Papelera",
+ deleteRecycled:
+ "La carpeta del equipo va a la Papelera, con la carpeta de cada bot y la carpeta de trabajo dentro. Todavía puedes recuperarla de allí:",
+ deleteRecycledChosen:
+ "La carpeta de cada bot va a la Papelera, de donde todavía puedes recuperarla. La carpeta de trabajo que elegiste se queda donde está:",
failed: {
pause: "No se pudo pausar el equipo",
resume: "No se pudo reanudar el equipo",
changeFolder: "No se pudo cambiar la carpeta",
openFolder: "No se pudo abrir la carpeta",
archive: "No se pudo archivar el equipo",
+ delete: "No se pudo eliminar el equipo",
},
},
bots: {
diff --git a/app/src/i18n/es/messages.ts b/app/src/i18n/es/messages.ts
index b210d238..cbfdf4f1 100644
--- a/app/src/i18n/es/messages.ts
+++ b/app/src/i18n/es/messages.ts
@@ -31,7 +31,7 @@ export const messages: Messages["messages"] = {
row: {
you: "Tú",
system: "Botloft",
- archivedBot: "un bot archivado",
+ goneBot: "un bot que ya no está en el equipo",
to: "para",
task: "Tarea",
result: "Resultado",
diff --git a/app/src/i18n/es/shell.ts b/app/src/i18n/es/shell.ts
index 8020d799..e4f239d3 100644
--- a/app/src/i18n/es/shell.ts
+++ b/app/src/i18n/es/shell.ts
@@ -20,6 +20,7 @@ export const shell: Messages["shell"] = {
reconnecting: "Reconectando…",
},
loadFailed: "No se pudieron cargar tus equipos",
+ recycleFailed: (path: string) => `La carpeta ${path} no fue a la Papelera y sigue allí`,
pickCrew: "Elige un equipo a la izquierda, o crea uno con el botón +.",
botsCantStart: "Los bots no pueden iniciar",
signIn: {
diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts
index c798b426..7a82b859 100644
--- a/app/src/i18n/pt-BR/account.ts
+++ b/app/src/i18n/pt-BR/account.ts
@@ -87,6 +87,16 @@ export const account: Messages["account"] = {
size: "Tamanho",
sizeHint: "Ctrl+= e Ctrl+- também mudam o tamanho, e Ctrl+0 volta ao padrão.",
language: "Idioma",
+ archived: "Arquivados",
+ archivedIntro:
+ "Os bots e as equipes que você arquivou estão parados e fora da vista, e o Botloft ainda guarda as conversas deles. Exclua um para tirá-lo de vez.",
+ archivedEmpty: "Nada está arquivado.",
+ archivedLoadFailed: "Não foi possível carregar o que está arquivado",
+ archivedCrew: (bots: number, when: string) =>
+ `Equipe · ${bots === 1 ? "1 bot" : `${bots} bots`} · arquivada ${when}`,
+ archivedBot: (crew: string, when: string) => `Bot de ${crew} · arquivado ${when}`,
+ deleteArchived: "Excluir",
+ deleteArchivedOne: (name: string) => `Excluir ${name}`,
about: "Sobre",
claudeCode: (version: string) => `Claude Code ${version}`,
botloft: (version: string) => `Botloft ${version}`,
diff --git a/app/src/i18n/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts
index add69c70..8469dc91 100644
--- a/app/src/i18n/pt-BR/bots.ts
+++ b/app/src/i18n/pt-BR/bots.ts
@@ -44,6 +44,7 @@ export const bots: Messages["bots"] = {
makeChief: "Tornar chefe da equipe",
stopChief: "Deixar de ser chefe",
archive: "Arquivar bot",
+ delete: "Excluir bot",
failed: {
pause: "Não foi possível pausar o bot",
resume: "Não foi possível retomar o bot",
@@ -51,6 +52,7 @@ export const bots: Messages["bots"] = {
openFolder: "Não foi possível abrir a pasta",
chief: "Não foi possível trocar o chefe",
archive: "Não foi possível arquivar o bot",
+ delete: "Não foi possível excluir o bot",
},
fresh: {
title: "Começar uma nova conversa?",
@@ -63,6 +65,18 @@ export const bots: Messages["bots"] = {
confirm: "Arquivar bot",
body: "O bot para e sai da equipe. As mensagens que ainda esperam por ele não são entregues.",
},
+ deleteConfirm: {
+ title: (name: string) => `Excluir ${name}?`,
+ confirm: "Excluir bot",
+ removed: (name: string, running: boolean) =>
+ `${name} ${running ? "para agora e sai" : "sai"} do Botloft de vez, com a conversa, as rotinas e as tarefas de que fazia parte. Não dá para desfazer.`,
+ chief: (crew: string) => `${crew} fica sem chefe.`,
+ kept: (name: string) =>
+ `A pasta de ${name} continua no seu computador, com tudo o que está nela:`,
+ recycle: "Mandar esta pasta para a Lixeira",
+ recycled: (name: string) =>
+ `A pasta de ${name} vai para a Lixeira, de onde ainda dá para recuperar:`,
+ },
},
dialog: {
newTitle: "Novo bot",
diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts
index 62d4a9d1..47bc0c53 100644
--- a/app/src/i18n/pt-BR/chat.ts
+++ b/app/src/i18n/pt-BR/chat.ts
@@ -44,7 +44,7 @@ export const chat: Messages["chat"] = {
inbound: {
task: "Tarefa",
result: "Resultado",
- archivedBot: "Um bot arquivado",
+ goneBot: "Um bot que não está mais na equipe",
},
run: {
working: "Trabalhando",
@@ -65,11 +65,16 @@ export const chat: Messages["chat"] = {
input: "Entrada",
output: "Saída",
error: "Erro",
+ command: "Comando",
+ commandCut: "Este comando é longo demais para aparecer inteiro.",
},
approval: {
asks: (bot: string, action: string) => `${bot} pede para ${action}`,
wants: (bot: string, action: string) => `${bot} quer ${action}`,
- fullInput: "Entrada completa",
+ explainedBy: (bot: string) => `${bot} escreveu isto. O que roda de verdade é o comando abaixo.`,
+ unexplained: (bot: string) =>
+ `${bot} não disse para que serve este comando. Na dúvida, negue e pergunte.`,
+ command: "Ver o comando",
noteLabel: (bot: string) => `Observação para ${bot} se você negar`,
notePlaceholder: "Por que não? Vai para o bot se você negar (opcional)",
allow: "Permitir",
@@ -139,6 +144,59 @@ export const chat: Messages["chat"] = {
later: (bot: string, model: string) =>
`${bot} muda para ${model} quando terminar o que está fazendo.`,
},
+ effort: {
+ title: "Esforço",
+ button: (level: string) => `Esforço: ${level}`,
+ short: "Esforço",
+ faster: "Mais rápido",
+ smarter: "Mais inteligente",
+ names: {
+ low: "Baixo",
+ medium: "Médio",
+ high: "Alto",
+ xhigh: "Extra alto",
+ max: "Máximo",
+ },
+ hints: {
+ low: (bot: string) => `${bot} responde mais rápido e pensa menos, para tarefas simples`,
+ medium: (bot: string) => `${bot} equilibra rapidez e raciocínio, bom para quase tudo`,
+ high: (bot: string) => `${bot} pensa mais antes de responder, para tarefas mais difíceis`,
+ xhigh: (bot: string) => `${bot} pensa bem mais, para trabalho longo e complexo`,
+ max: (bot: string) =>
+ `${bot} pensa o máximo que pode: é o mais lento e o que mais gasta do seu plano`,
+ },
+ recommended: "Recomendado",
+ recommendedFor: (model: string) => `Recomendado para o ${model}`,
+ useRecommended: "Usar o recomendado",
+ unknown: (bot: string) => `${bot} usa o nível recomendado para o modelo dele`,
+ unavailable: "Indisponível",
+ none: (bot: string, model: string) =>
+ `${model} não tem níveis de esforço: ${bot} responde sempre no mesmo ritmo.`,
+ thisModel: "Este modelo",
+ cost: "Mais esforço gasta o limite do seu plano mais rápido.",
+ failed: "Não foi possível mudar o esforço",
+ later: (bot: string, level: string) =>
+ `${bot} muda para o esforço ${level} quando terminar o que está fazendo.`,
+ },
+ context: {
+ title: "Espaço da conversa",
+ button: (used: string, total: string, percent: number) =>
+ `Espaço da conversa: ${used} de ${total} em uso (${percent}%)`,
+ used: (used: string, total: string, percent: number) => `${used} / ${total} (${percent}%)`,
+ about: (bot: string) =>
+ `Tudo o que ${bot} leu e escreveu nesta conversa ocupa espaço. Quanto mais cheia, mais do seu plano cada mensagem gasta.`,
+ autoLeft: (left: string, at: string) =>
+ `Faltam ${left} para ela se compactar sozinha, em ${at}.`,
+ autoNow: "Já está cheia o bastante para se compactar sozinha na próxima mensagem.",
+ noAuto: "Ela não se compacta sozinha.",
+ compact: "Compactar agora",
+ compactHint: (bot: string) =>
+ `Troca o que veio antes por um resumo: ${bot} volta a ter espaço e cada mensagem gasta menos.`,
+ compacting: "Compactando…",
+ later: (bot: string) => `${bot} compacta a conversa quando terminar o que está fazendo.`,
+ notRunning: (bot: string) => `${bot} não está rodando, então não dá para compactar agora.`,
+ failed: "Não foi possível compactar a conversa",
+ },
suggestion: {
title: (bot: string) => `${bot} sugere um bot novo`,
why: "Por quê",
@@ -181,5 +239,9 @@ export const chat: Messages["chat"] = {
modelUnavailable:
"O modelo deste bot não está disponível: talvez não faça parte do seu plano do Claude. Escolha outro modelo abaixo do chat.",
turnFailed: (detail: string) => `O bot não conseguiu terminar isto: ${detail}`,
+ compacted:
+ "A conversa foi compactada: o que veio antes agora é um resumo, e há espaço de novo.",
+ autoCompacted: "A conversa ficou cheia e foi compactada: o que veio antes agora é um resumo.",
+ compactFailed: (detail: string) => `Não foi possível compactar a conversa: ${detail}`,
},
};
diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts
index 526e27ed..36ea056a 100644
--- a/app/src/i18n/pt-BR/crews.ts
+++ b/app/src/i18n/pt-BR/crews.ts
@@ -47,6 +47,7 @@ export const crews: Messages["crews"] = {
`Os bots vão trabalhar em ${path}. Cada um reinicia quando terminar o que está fazendo. O que já foi feito fica onde está.`,
move: "Mudar",
archive: "Arquivar equipe",
+ delete: "Excluir equipe",
tabs: {
label: "Visões da equipe",
bots: "Bots",
@@ -60,12 +61,25 @@ export const crews: Messages["crews"] = {
bots === 0
? "A equipe sai do app."
: `A equipe sai do app. ${bots === 1 ? "O bot dela para" : `Os ${bots} bots dela param`}, e as mensagens que ainda esperam por ${bots === 1 ? "ele" : "eles"} não são entregues.`,
+ deleteTitle: (crew: string) => `Excluir ${crew}?`,
+ deleteBody: (crew: string, bots: number, running: boolean) =>
+ bots === 0
+ ? `${crew} sai do Botloft de vez. Não dá para desfazer.`
+ : `${crew} e ${bots === 1 ? "o bot dela" : `os ${bots} bots dela`} ${running ? "param agora e saem" : "saem"} do Botloft de vez, com as conversas, as rotinas e as tarefas. Não dá para desfazer.`,
+ deleteKept:
+ "As pastas continuam no seu computador, com tudo o que está nelas: a pasta de cada bot e a pasta de trabalho da equipe:",
+ deleteRecycle: "Mandar as pastas da equipe para a Lixeira",
+ deleteRecycled:
+ "A pasta da equipe vai para a Lixeira, com a pasta de cada bot e a pasta de trabalho dentro. Ainda dá para recuperar de lá:",
+ deleteRecycledChosen:
+ "A pasta de cada bot vai para a Lixeira, de onde ainda dá para recuperar. A pasta de trabalho que você escolheu fica onde está:",
failed: {
pause: "Não foi possível pausar a equipe",
resume: "Não foi possível retomar a equipe",
changeFolder: "Não foi possível mudar a pasta",
openFolder: "Não foi possível abrir a pasta",
archive: "Não foi possível arquivar a equipe",
+ delete: "Não foi possível excluir a equipe",
},
},
bots: {
diff --git a/app/src/i18n/pt-BR/messages.ts b/app/src/i18n/pt-BR/messages.ts
index b48332d5..7b73e49d 100644
--- a/app/src/i18n/pt-BR/messages.ts
+++ b/app/src/i18n/pt-BR/messages.ts
@@ -32,7 +32,7 @@ export const messages: Messages["messages"] = {
row: {
you: "Você",
system: "Botloft",
- archivedBot: "um bot arquivado",
+ goneBot: "um bot que não está mais na equipe",
to: "para",
task: "Tarefa",
result: "Resultado",
diff --git a/app/src/i18n/pt-BR/shell.ts b/app/src/i18n/pt-BR/shell.ts
index bb82a25a..e95ed305 100644
--- a/app/src/i18n/pt-BR/shell.ts
+++ b/app/src/i18n/pt-BR/shell.ts
@@ -20,6 +20,7 @@ export const shell: Messages["shell"] = {
reconnecting: "Reconectando…",
},
loadFailed: "Não foi possível carregar suas equipes",
+ recycleFailed: (path: string) => `A pasta ${path} não foi para a Lixeira e continua lá`,
pickCrew: "Escolha uma equipe à esquerda, ou crie uma com o botão +.",
botsCantStart: "Os bots não conseguem iniciar",
signIn: {
diff --git a/app/src/index.css b/app/src/index.css
index 0cce2368..05d1659d 100644
--- a/app/src/index.css
+++ b/app/src/index.css
@@ -8,6 +8,7 @@
@import "./mascot.css";
@import "./mascot-flame.css";
@import "./panels.css";
+@import "./effort.css";
@import "./motion-less.css";
/* Colors are tokens so the light and dark themes swap in one place
diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts
index 82e5f6ce..0f0b04d3 100644
--- a/app/src/lib/fake.ts
+++ b/app/src/lib/fake.ts
@@ -16,6 +16,7 @@ import {
type Bot,
type BotId,
type BotState,
+ type ContextUsage,
type Crew,
type CrewId,
PROTOCOL_VERSION,
@@ -58,6 +59,10 @@ export class FakeBotloft implements BotloftApi {
/** How many times the app asked for a new Claude Code check. */
refreshes = 0;
settings: Settings = { startWithWindows: true, keepAwake: true, approvalWaitMinutes: 60 };
+ /** Folders the owner sent to the Recycle Bin with a delete, in order. */
+ readonly recycled: string[] = [];
+ /** Why the next folder cannot go to the bin, if it cannot. */
+ recycleError: string | null = null;
/** Every call, in order. */
readonly calls: { method: Method; params: unknown }[] = [];
readonly chat = new FakeChat(this);
@@ -147,6 +152,12 @@ export class FakeBotloft implements BotloftApi {
this.emit({ name: "bot.state", params: { botId, state, generation: bot.generation } });
}
+ /** What Claude Code tells about the bot's conversation (spec 8.6). */
+ setContext(botId: BotId, context: ContextUsage | null): void {
+ this.bot(botId).context = context;
+ this.emit({ name: "bot.context", params: { botId, context } });
+ }
+
/** A new id with `prefix`, like the daemon's. */
id(prefix: string): string {
counter += 1;
@@ -183,6 +194,15 @@ export class FakeBotloft implements BotloftApi {
);
}
+ /** The Recycle Bin takes `path`, or the app hears why it did not. */
+ recycle(path: string): void {
+ const error = this.recycleError;
+ if (error === null) {
+ this.recycled.push(path);
+ }
+ this.emit({ name: "folder.recycled", params: { path, error } });
+ }
+
changedCrew(crew: Crew): Crew {
this.emit({ name: "crew.changed", params: crew });
return crew;
@@ -221,6 +241,10 @@ export class FakeBotloft implements BotloftApi {
};
return this.settings;
},
+ "archive.list": () => ({
+ crews: [...this.crews.values()].filter((crew) => crew.archivedAt !== null),
+ bots: [...this.bots.values()].filter((bot) => bot.archivedAt !== null),
+ }),
...crewHandlers(this),
...botHandlers(this),
...this.chat.handlers(),
diff --git a/app/src/lib/fakeBots.ts b/app/src/lib/fakeBots.ts
index 6057818a..fc235685 100644
--- a/app/src/lib/fakeBots.ts
+++ b/app/src/lib/fakeBots.ts
@@ -4,7 +4,7 @@
import type { FakeBotloft, Handlers } from "./fake";
import { purgeBot } from "./fakeDelete";
-import { checkName } from "./fakeRules";
+import { checkName, conflict } from "./fakeRules";
import { AVATAR_PALETTE, type Bot } from "./protocol.gen";
type BotMethods = Extract;
@@ -35,6 +35,9 @@ export function botHandlers(fake: FakeBotloft): Pick {
permissionMode: "default",
model: model ?? "default",
modelInUse: null,
+ effort: "default",
+ effortDefault: null,
+ context: null,
state: crew.paused ? "offline" : "launching",
generation: crew.paused ? null : 1,
workspace: `C:\\Users\\owner\\Botloft\\${crew.slug}\\${handle}`,
@@ -68,9 +71,29 @@ export function botHandlers(fake: FakeBotloft): Pick {
},
"bots.setModel": ({ botId, model }) => {
const bot = fake.bot(botId);
- bot.model = model;
+ if (bot.model !== model) {
+ bot.model = model;
+ // What a model does by itself is asked again of the new one.
+ bot.effortDefault = null;
+ }
return fake.changedBot(bot);
},
+ "bots.setEffort": ({ botId, effort }) => {
+ const bot = fake.bot(botId);
+ bot.effort = effort;
+ return fake.changedBot(bot);
+ },
+ "bots.compact": ({ botId }) => {
+ const bot = fake.bot(botId);
+ if (bot.state !== "idle" && bot.state !== "busy" && bot.state !== "needs_approval") {
+ throw conflict(`bot ${botId} is not running`);
+ }
+ // It waits behind the turn in progress; a test ends it with `setContext`.
+ if (bot.context && !bot.context.compacting) {
+ fake.setContext(botId, { ...bot.context, compacting: true });
+ }
+ return bot;
+ },
"bots.restart": ({ botId }) => {
const bot = fake.bot(botId);
fake.setBotState(botId, "launching", (bot.generation ?? 0) + 1);
@@ -91,7 +114,7 @@ export function botHandlers(fake: FakeBotloft): Pick {
}
return bot;
},
- "bots.delete": ({ botId }) => {
+ "bots.delete": ({ botId, recycleFolder }) => {
const bot = fake.bot(botId, false);
purgeBot(fake, bot);
// A deleted chief leaves its crew without one.
@@ -102,6 +125,9 @@ export function botHandlers(fake: FakeBotloft): Pick {
}
const deleted = { botId, crewId: bot.crewId };
fake.emit({ name: "bot.deleted", params: deleted });
+ if (recycleFolder) {
+ fake.recycle(bot.workspace);
+ }
return deleted;
},
};
diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts
index 0db5c99b..ed35db71 100644
--- a/app/src/lib/fakeChat.ts
+++ b/app/src/lib/fakeChat.ts
@@ -38,15 +38,23 @@ export function activityLine(
break;
case "tool":
kind = "tool";
- text = body.summary;
+ text = body.explanation ?? body.summary;
tool = body.name;
break;
case "approval":
kind = "approval";
- text = body.summary;
+ text = body.explanation ?? body.summary;
tool = body.toolName;
break;
case "notice":
+ // A compaction is housekeeping, not news about the conversation.
+ if (
+ body.code === "compacted" ||
+ body.code === "auto_compacted" ||
+ body.code === "compact_failed"
+ ) {
+ return null;
+ }
kind = "notice";
text = body.text;
break;
@@ -93,6 +101,7 @@ export class FakeChat {
toolUseId: this.fake.id("toolu"),
name,
summary: "",
+ explanation: null,
input: "{}",
status: "running",
output: null,
@@ -113,8 +122,14 @@ export class FakeChat {
return this.add(botId, { kind: "turn", durationMs: 4200, costUsd: 0.0123, error });
}
- /** A permission request waiting for the owner. */
- ask(botId: BotId, toolName: string, summary: string, input = "{}"): ChatItem {
+ /** A permission request waiting for the owner; `explanation` is what the bot says a command is for. */
+ ask(
+ botId: BotId,
+ toolName: string,
+ summary: string,
+ input = "{}",
+ explanation: string | null = null,
+ ): ChatItem {
const approval: Approval = {
id: this.fake.id("apr"),
botId,
@@ -132,6 +147,7 @@ export class FakeChat {
approvalId: approval.id,
toolName,
summary,
+ explanation,
input,
status: "pending",
note: null,
diff --git a/app/src/lib/fakeCrews.ts b/app/src/lib/fakeCrews.ts
index 549817cb..d71539f4 100644
--- a/app/src/lib/fakeCrews.ts
+++ b/app/src/lib/fakeCrews.ts
@@ -82,8 +82,8 @@ export function crewHandlers(fake: FakeBotloft): Pick {
}
return crew;
},
- "crews.delete": ({ crewId }) => {
- fake.crew(crewId, false);
+ "crews.delete": ({ crewId, recycleFolder }) => {
+ const crew = fake.crew(crewId, false);
for (const bot of [...fake.bots.values()]) {
if (bot.crewId === crewId) {
purgeBot(fake, bot);
@@ -91,6 +91,9 @@ export function crewHandlers(fake: FakeBotloft): Pick {
}
fake.crews.delete(crewId);
fake.emit({ name: "crew.deleted", params: { crewId } });
+ if (recycleFolder) {
+ fake.recycle(`C:\\Users\\owner\\Botloft\\${crew.slug}`);
+ }
return { crewId };
},
};
diff --git a/app/src/lib/format.ts b/app/src/lib/format.ts
index b308d054..215f449b 100644
--- a/app/src/lib/format.ts
+++ b/app/src/lib/format.ts
@@ -76,6 +76,22 @@ export function fileSize(bytes: number): string {
return `${mb < 10 ? decimal(mb, 1) : Math.round(mb)} MB`;
}
+/** Tokens as the owner reads them: "850", "24.3k", "556k", "1M", "1.2M". */
+export function tokens(count: number): string {
+ if (count < 1000) {
+ return String(count);
+ }
+ const thousands = count / 1000;
+ if (thousands < 100) {
+ return `${decimal(thousands, thousands % 1 < 0.05 ? 0 : 1)}k`;
+ }
+ if (thousands < 999.5) {
+ return `${Math.round(thousands)}k`;
+ }
+ const millions = count / 1_000_000;
+ return `${decimal(millions, Math.abs(millions - Math.round(millions)) < 0.05 ? 0 : 1)}M`;
+}
+
/** "0.8 s", "42 s", "3 min 5 s". */
export function duration(ms: number): string {
const seconds = ms / 1000;
diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts
index 3b0b4bc3..05e75478 100644
--- a/app/src/lib/protocol.gen.ts
+++ b/app/src/lib/protocol.gen.ts
@@ -136,6 +136,57 @@ tool: string | null,
*/
at: number, };
+/**
+ * How much a bot thinks before it answers (spec 7.4): Claude Code's
+ * `--effort` levels, or the level its model uses by itself.
+ */
+export type BotEffort = "default" | "low" | "medium" | "high" | "xhigh" | "max";
+
+/**
+ * The effort a model runs at when the owner picks none, as Claude Code
+ * reports it (spec 7.4).
+ */
+export type ModelEffort = "none" | "low" | "medium" | "high" | "xhigh" | "max";
+
+/**
+ * How full a bot's conversation is, in tokens, as Claude Code counts it
+ * (spec 8.6).
+ */
+export type ContextUsage = {
+/**
+ * What the conversation holds now.
+ */
+usedTokens: number,
+/**
+ * The most it can hold.
+ */
+windowTokens: number,
+/**
+ * Where Claude Code compacts the conversation by itself; `null` when it
+ * does not.
+ */
+autoCompactTokens: number | null,
+/**
+ * The conversation is being compacted right now.
+ */
+compacting: boolean,
+/**
+ * Unix time in milliseconds.
+ */
+updatedAt: number, };
+
+/**
+ * Params of the `bot.context` notification.
+ */
+export type BotContextChanged = { botId: BotId,
+/**
+ * `null` when the bot began a new conversation and its size is not
+ * known yet.
+ */
+context: ContextUsage | null, };
+
+export type BotsSetEffortParams = { botId: BotId, effort: BotEffort, };
+
/**
* A persistent Claude Code session with a name, a role and instructions.
*/
@@ -157,7 +208,16 @@ color: string, paused: boolean, permissionMode: PermissionMode, model: BotModel,
* The model id Claude Code reported when the bot last started a turn
* (`claude-opus-5-5`); `null` before its first turn.
*/
-modelInUse: string | null, state: BotState,
+modelInUse: string | null, effort: BotEffort,
+/**
+ * The effort the bot's model uses by itself, as Claude Code last
+ * reported it; `null` until it is known.
+ */
+effortDefault: ModelEffort | null,
+/**
+ * How full the conversation is; `null` until Claude Code said so.
+ */
+context: ContextUsage | null, state: BotState,
/**
* Current process generation; `null` if the bot has not started since
* the daemon did. Changes on every (re)start.
@@ -372,6 +432,47 @@ export type BotDeleted = { botId: BotId, crewId: CrewId, };
*/
export type CrewDeleted = { crewId: CrewId, };
+/**
+ * What the owner archived (`archive.list`): out of the app, kept in the
+ * database, and still there to delete (spec 7.6). `bots` has every
+ * archived bot, those of archived crews too.
+ */
+export type Archive = { crews: Array, bots: Array, };
+
+/**
+ * Params of `bots.delete` (spec 7.6).
+ */
+export type BotsDeleteParams = { botId: BotId,
+/**
+ * Also move the bot's folder to the Recycle Bin; it stays when absent.
+ */
+recycleFolder?: boolean, };
+
+/**
+ * Params of `crews.delete` (spec 7.6).
+ */
+export type CrewsDeleteParams = { crewId: CrewId,
+/**
+ * Also move the crew's own folder, with each bot's folder and
+ * `shared`, to the Recycle Bin; it stays when absent. A work folder
+ * the owner chose is never moved.
+ */
+recycleFolder?: boolean, };
+
+/**
+ * Params of the `folder.recycled` notification: how the move of a deleted
+ * bot's or crew's folder to the Recycle Bin ended (spec 7.6).
+ */
+export type FolderRecycled = {
+/**
+ * Absolute path the folder had.
+ */
+path: string,
+/**
+ * Why the folder is still there; `null` when it is in the bin.
+ */
+error: string | null, };
+
/**
* Who wrote a message.
*/
@@ -604,7 +705,7 @@ export type NoticeLevel = "info" | "warning" | "error";
/**
* What a notice is about, so the app can say it in the owner's language.
*/
-export type NoticeCode = "signed_out" | "usage_limit" | "turn_failed" | "model_unavailable";
+export type NoticeCode = "signed_out" | "usage_limit" | "turn_failed" | "model_unavailable" | "compacted" | "auto_compacted" | "compact_failed";
export type InboundItem = { message: Message, };
@@ -615,6 +716,11 @@ export type ToolItem = { toolUseId: string, name: string,
* One line about what the call does, e.g. the command or the file.
*/
summary: string,
+/**
+ * What the bot says a command is for, in its own words (spec 10.1);
+ * absent when it said nothing, for other tools and for older items.
+ */
+explanation: string | null,
/**
* The call's input as JSON, cut at 4 KB.
*/
@@ -629,7 +735,13 @@ output: string | null,
*/
file: string | null, };
-export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, input: string, status: ApprovalStatus,
+export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string,
+/**
+ * What the bot says the command is for, as in `ToolItem`. The bot
+ * wrote it: it goes before the command, never in its place. Kept only
+ * here, not in the `Approval`.
+ */
+explanation: string | null, input: string, status: ApprovalStatus,
/**
* What the owner wrote with a denial.
*/
@@ -1038,7 +1150,8 @@ export interface RpcMethods {
"crews.setWorkFolder": { params: CrewsSetWorkFolderParams; result: Crew };
"crews.setLead": { params: CrewsSetLeadParams; result: Crew };
"crews.archive": { params: CrewIdParams; result: Crew };
- "crews.delete": { params: CrewIdParams; result: CrewDeleted };
+ "crews.delete": { params: CrewsDeleteParams; result: CrewDeleted };
+ "archive.list": { params: undefined; result: Archive };
"bots.list": { params: BotsListParams; result: Array };
"bots.create": { params: BotsCreateParams; result: Bot };
"bots.update": { params: BotsUpdateParams; result: Bot };
@@ -1046,7 +1159,7 @@ export interface RpcMethods {
"bots.setPermissionMode": { params: BotsSetPermissionModeParams; result: Bot };
"bots.setModel": { params: BotsSetModelParams; result: Bot };
"bots.archive": { params: BotIdParams; result: Bot };
- "bots.delete": { params: BotIdParams; result: BotDeleted };
+ "bots.delete": { params: BotsDeleteParams; result: BotDeleted };
"bots.restart": { params: BotsRestartParams; result: Bot };
"chat.history": { params: ChatHistoryParams; result: Array };
"approvals.answer": { params: ApprovalsAnswerParams; result: Approval };
@@ -1058,6 +1171,8 @@ export interface RpcMethods {
"deliveries.list": { params: DeliveriesListParams; result: Array };
"deliveries.retry": { params: DeliveryIdParams; result: Delivery };
"tasks.list": { params: TasksListParams; result: Array };
+ "bots.setEffort": { params: BotsSetEffortParams; result: Bot };
+ "bots.compact": { params: BotIdParams; result: Bot };
"routines.list": { params: RoutinesListParams; result: Array };
"routines.create": { params: RoutinesCreateParams; result: Routine };
"routines.update": { params: RoutinesUpdateParams; result: Routine };
@@ -1085,12 +1200,14 @@ export interface RpcNotifications {
"bot.changed": Bot;
"crew.deleted": CrewDeleted;
"bot.deleted": BotDeleted;
+ "folder.recycled": FolderRecycled;
"bot.state": BotStateChanged;
"chat.item": ChatItemChanged;
"chat.delta": ChatDelta;
"message.created": Message;
"delivery.changed": Delivery;
"task.changed": Task;
+ "bot.context": BotContextChanged;
"routine.changed": Routine;
"routine.run": RoutineRun;
"browser.changed": BrowserState;
diff --git a/app/src/shell/Workspace.tsx b/app/src/shell/Workspace.tsx
index 80447e4c..1dcf878d 100644
--- a/app/src/shell/Workspace.tsx
+++ b/app/src/shell/Workspace.tsx
@@ -17,6 +17,7 @@ import { useApp } from "../store/context";
import { Callout } from "../ui/Callout";
import { useBotAlerts } from "./alerts";
import { useAttentionMark } from "./attention";
+import { useFolderNotices } from "./folders";
import { useOpenAtSignIn } from "./signIn";
import { TitleBar } from "./TitleBar";
import { useTray } from "./tray";
@@ -31,6 +32,7 @@ export function Workspace() {
useAttentionMark();
useTray();
useBotAlerts();
+ useFolderNotices();
useOpenAtSignIn();
let main: ReactNode;
diff --git a/app/src/shell/folders.ts b/app/src/shell/folders.ts
new file mode 100644
index 00000000..28bc214c
--- /dev/null
+++ b/app/src/shell/folders.ts
@@ -0,0 +1,21 @@
+// Telling the owner when the folder of a deleted bot or crew could not go
+// to the Recycle Bin (spec 7.6): the daemon moves it after answering, so
+// the news comes as a notification.
+
+import { useEffect } from "react";
+import { t } from "../i18n";
+import { useApi } from "../store/context";
+import { notifyError } from "../ui/toast";
+
+export function useFolderNotices(): void {
+ const api = useApi();
+ useEffect(
+ () =>
+ api.subscribe((event) => {
+ if (event.name === "folder.recycled" && event.params.error !== null) {
+ notifyError(t().shell.recycleFailed(event.params.path), event.params.error);
+ }
+ }),
+ [api],
+ );
+}
diff --git a/app/src/store/app.ts b/app/src/store/app.ts
index d2b634df..8c9ac349 100644
--- a/app/src/store/app.ts
+++ b/app/src/store/app.ts
@@ -222,6 +222,13 @@ export function applyEvent(state: AppState, event: ServerEvent): Partial
+
- {t.common.details}
+ {label ?? t.common.details}
{children}
diff --git a/app/src/ui/KeptFolder.tsx b/app/src/ui/KeptFolder.tsx
new file mode 100644
index 00000000..e6ed6ecc
--- /dev/null
+++ b/app/src/ui/KeptFolder.tsx
@@ -0,0 +1,34 @@
+import type { ReactNode } from "react";
+
+/**
+ * In a confirmation: what happens to a folder on disk, and where it is.
+ * `recycle` adds the choice to send it to the Recycle Bin.
+ */
+export function KeptFolder({
+ path,
+ children,
+ recycle,
+}: {
+ path: string;
+ children: ReactNode;
+ recycle?: { label: string; checked: boolean; onChange(checked: boolean): void };
+}) {
+ return (
+
+
{children}
+
+ {path}
+
+ {recycle && (
+
+ recycle.onChange(event.target.checked)}
+ />
+ {recycle.label}
+
+ )}
+
+ );
+}
diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs
index cbfd3e32..23e335f4 100644
--- a/crates/botloft-core/src/chat.rs
+++ b/crates/botloft-core/src/chat.rs
@@ -3,7 +3,8 @@
use serde_json::Value;
-use crate::protocol::{Activity, ActivityKind, ChatBody, SenderKind};
+use crate::command::{COMMAND_INPUT_MAX, is_command};
+use crate::protocol::{Activity, ActivityKind, ChatBody, NoticeCode, SenderKind};
/// Longest tool input kept, in bytes.
pub const TOOL_INPUT_MAX: usize = 4 * 1024;
@@ -57,6 +58,7 @@ pub fn tool_input_max(tool: &str) -> usize {
match tool {
PLAN_TOOL => PLAN_INPUT_MAX,
SUGGEST_TOOL => SUGGESTION_INPUT_MAX,
+ tool if is_command(tool) => COMMAND_INPUT_MAX,
_ => TOOL_INPUT_MAX,
}
}
@@ -74,9 +76,8 @@ pub fn tool_summary(name: &str, input: &Value) -> String {
.map(file_name)
};
let summary = match name {
- "Bash" | "PowerShell" => field(input, "description")
- .map(str::to_owned)
- .or_else(|| field(input, "command").map(str::to_owned)),
+ // The command itself; what the bot says about it is the explanation.
+ "Bash" | "PowerShell" => field(input, "command").map(str::to_owned),
"Read" | "Edit" | "MultiEdit" | "Write" | "NotebookEdit" => path(),
"Grep" | "Glob" => field(input, "pattern").map(str::to_owned),
"WebFetch" => field(input, "url").map(str::to_owned),
@@ -147,8 +148,24 @@ pub fn activity(body: &ChatBody, at: i64) -> Option
{
_ => (ActivityKind::Message, item.message.body.clone()),
},
ChatBody::Reply(item) => (ActivityKind::Reply, item.text.clone()),
- ChatBody::Tool(item) => (ActivityKind::Tool, item.summary.clone()),
- ChatBody::Approval(item) => (ActivityKind::Approval, item.summary.clone()),
+ // A command reads better in the bot's words, when it gave them.
+ ChatBody::Tool(item) => (
+ ActivityKind::Tool,
+ item.explanation.as_ref().unwrap_or(&item.summary).clone(),
+ ),
+ ChatBody::Approval(item) => (
+ ActivityKind::Approval,
+ item.explanation.as_ref().unwrap_or(&item.summary).clone(),
+ ),
+ // A compaction is housekeeping, not news about the conversation.
+ ChatBody::Notice(item)
+ if matches!(
+ item.code,
+ Some(NoticeCode::Compacted | NoticeCode::AutoCompacted | NoticeCode::CompactFailed)
+ ) =>
+ {
+ return None;
+ }
ChatBody::Notice(item) => (ActivityKind::Notice, item.text.clone()),
ChatBody::Turn(_) => return None,
};
@@ -188,10 +205,10 @@ mod tests {
"Bash",
&json!({ "command": "npm test", "description": "Run the tests" })
),
- "Run the tests"
+ "npm test"
);
assert_eq!(
- tool_summary("Bash", &json!({ "command": "ls\n-la" })),
+ tool_summary("PowerShell", &json!({ "command": "ls\n-la" })),
"ls -la"
);
assert_eq!(
@@ -254,10 +271,29 @@ mod tests {
),
"Move the cache"
);
+ assert_eq!(tool_input_max("Bash"), COMMAND_INPUT_MAX);
+ assert_eq!(tool_input_max("Read"), TOOL_INPUT_MAX);
assert_eq!(tool_label("mcp__botloft__send_message"), "send_message");
assert_eq!(tool_label("Bash"), "Bash");
}
+ #[test]
+ fn a_compaction_does_not_change_the_conversation_list_line() {
+ use crate::protocol::{NoticeItem, NoticeLevel};
+ let notice = |code| {
+ ChatBody::Notice(NoticeItem {
+ level: NoticeLevel::Info,
+ code: Some(code),
+ text: "The conversation was compacted.".into(),
+ })
+ };
+ assert_eq!(activity(¬ice(NoticeCode::Compacted), 1), None);
+ assert_eq!(activity(¬ice(NoticeCode::AutoCompacted), 1), None);
+ assert_eq!(activity(¬ice(NoticeCode::CompactFailed), 1), None);
+ let limit = activity(¬ice(NoticeCode::UsageLimit), 1).expect("a line");
+ assert_eq!(limit.kind, ActivityKind::Notice);
+ }
+
#[test]
fn long_lines_are_flattened_and_cut() {
let text = format!("first line\nsecond {}", "x".repeat(300));
diff --git a/crates/botloft-core/src/command.rs b/crates/botloft-core/src/command.rs
new file mode 100644
index 00000000..c6d96dfc
--- /dev/null
+++ b/crates/botloft-core/src/command.rs
@@ -0,0 +1,88 @@
+//! Shell commands a bot runs (`Bash`, `PowerShell`): the one kind of tool
+//! call whose input carries the bot's own words about it, which the owner
+//! reads before allowing the command (spec 10.1).
+
+use serde_json::Value;
+
+use crate::chat::one_line;
+
+/// Longest command input kept, in bytes: the owner can read all of what
+/// they allow, as with a plan.
+pub const COMMAND_INPUT_MAX: usize = 32 * 1024;
+/// Longest explanation, in characters.
+const EXPLANATION_MAX_CHARS: usize = 300;
+
+/// Whether `tool` runs a shell command.
+pub fn is_command(tool: &str) -> bool {
+ matches!(tool, "Bash" | "PowerShell")
+}
+
+/// What the bot says a command is for: the `description` Claude Code's
+/// shell tools take next to `command`, on one line. The model writes it and
+/// may leave it out, and one that only repeats the command explains nothing.
+pub fn tool_explanation(tool: &str, input: &Value) -> Option {
+ if !is_command(tool) {
+ return None;
+ }
+ let flat = |name: &str| {
+ let text = input.get(name).and_then(Value::as_str)?;
+ Some(text.split_whitespace().collect::>().join(" "))
+ };
+ let said = flat("description").filter(|said| !said.is_empty())?;
+ if flat("command").as_deref() == Some(said.as_str()) {
+ return None;
+ }
+ Some(one_line(&said, EXPLANATION_MAX_CHARS))
+}
+
+#[cfg(test)]
+mod tests {
+ use serde_json::json;
+
+ use super::*;
+
+ #[test]
+ fn the_explanation_is_what_the_bot_wrote_about_a_command() {
+ let input = json!({ "command": "pip install requests", "description": "Instala o\npacote requests" });
+ assert_eq!(
+ tool_explanation("Bash", &input).as_deref(),
+ Some("Instala o pacote requests")
+ );
+ assert_eq!(
+ tool_explanation("PowerShell", &input).as_deref(),
+ Some("Instala o pacote requests")
+ );
+ }
+
+ #[test]
+ fn a_command_without_one_has_none() {
+ assert_eq!(
+ tool_explanation("Bash", &json!({ "command": "make" })),
+ None
+ );
+ let blank = json!({ "command": "make", "description": " \n" });
+ assert_eq!(tool_explanation("Bash", &blank), None);
+ let not_text = json!({ "command": "make", "description": 3 });
+ assert_eq!(tool_explanation("Bash", ¬_text), None);
+ // Repeating the command is not explaining it.
+ let echo = json!({ "command": "npm run build", "description": "npm run build" });
+ assert_eq!(tool_explanation("Bash", &echo), None);
+ }
+
+ #[test]
+ fn other_tools_have_none() {
+ // A helper's `description` is its title, already the summary.
+ let helper = json!({ "description": "Find the endpoints", "prompt": "..." });
+ assert_eq!(tool_explanation("Agent", &helper), None);
+ let file = json!({ "file_path": "a.md", "description": "x" });
+ assert_eq!(tool_explanation("Write", &file), None);
+ }
+
+ #[test]
+ fn a_long_one_is_cut() {
+ let input = json!({ "command": "make", "description": "a".repeat(500) });
+ let said = tool_explanation("Bash", &input).expect("explanation");
+ assert_eq!(said.chars().count(), EXPLANATION_MAX_CHARS);
+ assert!(said.ends_with('…'));
+ }
+}
diff --git a/crates/botloft-core/src/lib.rs b/crates/botloft-core/src/lib.rs
index d29b5529..70d16923 100644
--- a/crates/botloft-core/src/lib.rs
+++ b/crates/botloft-core/src/lib.rs
@@ -3,6 +3,7 @@
pub mod avatar;
pub mod chat;
+pub mod command;
pub mod envelope;
pub mod ids;
pub mod protocol;
diff --git a/crates/botloft-core/src/protocol/chat.rs b/crates/botloft-core/src/protocol/chat.rs
index 97d55450..48abe9c0 100644
--- a/crates/botloft-core/src/protocol/chat.rs
+++ b/crates/botloft-core/src/protocol/chat.rs
@@ -44,6 +44,12 @@ text_enum!(
TurnFailed => "turn_failed",
/// The bot's model does not exist or the account cannot use it.
ModelUnavailable => "model_unavailable",
+ /// The conversation was compacted because the owner asked.
+ Compacted => "compacted",
+ /// Claude Code compacted the conversation by itself: it was full.
+ AutoCompacted => "auto_compacted",
+ /// The conversation could not be compacted; `text` holds the detail.
+ CompactFailed => "compact_failed",
}
);
@@ -116,6 +122,10 @@ pub struct ToolItem {
pub name: String,
/// One line about what the call does, e.g. the command or the file.
pub summary: String,
+ /// What the bot says a command is for, in its own words (spec 10.1);
+ /// absent when it said nothing, for other tools and for older items.
+ #[serde(default)]
+ pub explanation: Option,
/// The call's input as JSON, cut at 4 KB.
pub input: String,
pub status: ToolStatus,
@@ -134,6 +144,11 @@ pub struct ApprovalItem {
pub approval_id: ApprovalId,
pub tool_name: String,
pub summary: String,
+ /// What the bot says the command is for, as in `ToolItem`. The bot
+ /// wrote it: it goes before the command, never in its place. Kept only
+ /// here, not in the `Approval`.
+ #[serde(default)]
+ pub explanation: Option,
pub input: String,
pub status: ApprovalStatus,
/// What the owner wrote with a denial.
@@ -264,14 +279,17 @@ mod tests {
tool_use_id: "toolu_1".into(),
name: "Bash".into(),
summary: "npm test".into(),
+ explanation: None,
input: "{}".into(),
status: ToolStatus::Running,
output: None,
file: None,
});
- let json = serde_json::to_value(&tool).expect("serialize");
+ let mut json = serde_json::to_value(&tool).expect("serialize");
assert_eq!(json["kind"], "tool");
assert_eq!(json["toolUseId"], "toolu_1");
+ // Items stored before a field existed still read.
+ json.as_object_mut().expect("object").remove("explanation");
assert_eq!(serde_json::from_value::(json).ok(), Some(tool));
}
}
diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs
index c7a302a7..ab9d8176 100644
--- a/crates/botloft-core/src/protocol/export.rs
+++ b/crates/botloft-core/src/protocol/export.rs
@@ -18,6 +18,8 @@ use crate::ids::{
mod browser;
#[path = "export_routines.rs"]
mod routines;
+#[path = "export_session.rs"]
+mod session;
use crate::validate;
const HEADER: &str = "\
@@ -99,6 +101,7 @@ fn export_bindings() {
out.decl::();
out.decl::();
out.decl::();
+ session::decls(&mut out);
out.decl::();
out.decl::();
out.decl::();
@@ -127,6 +130,10 @@ fn export_bindings() {
out.decl::();
out.decl::();
out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
out.decl::();
out.decl::();
out.decl::();
@@ -229,9 +236,10 @@ fn export_bindings() {
let bot_deleted = out.name::();
out.method(
method::CREWS_DELETE,
- &out.name::(),
+ &out.name::(),
&crew_deleted,
);
+ out.method(method::ARCHIVE_LIST, "undefined", &out.name::());
out.method(method::BOTS_LIST, &out.name::(), &bots);
out.method(method::BOTS_CREATE, &out.name::(), &bot);
out.method(method::BOTS_UPDATE, &out.name::(), &bot);
@@ -253,7 +261,7 @@ fn export_bindings() {
out.method(method::BOTS_ARCHIVE, &out.name::(), &bot);
out.method(
method::BOTS_DELETE,
- &out.name::(),
+ &out.name::(),
&bot_deleted,
);
out.method(method::BOTS_RESTART, &out.name::(), &bot);
@@ -309,6 +317,7 @@ fn export_bindings() {
&out.name::(),
&out.name::>(),
);
+ session::methods(&mut out);
routines::methods(&mut out);
browser::methods(&mut out);
out.text.push_str("}\n\n");
@@ -328,6 +337,12 @@ fn export_bindings() {
" \"{}\": {bot_deleted};",
notification::BOT_DELETED
);
+ let _ = writeln!(
+ out.text,
+ " \"{}\": {};",
+ notification::FOLDER_RECYCLED,
+ out.name::()
+ );
let state = out.name::();
let item = out.name::();
let delta = out.name::();
@@ -346,6 +361,7 @@ fn export_bindings() {
notification::DELIVERY_CHANGED
);
let _ = writeln!(out.text, " \"{}\": {task};", notification::TASK_CHANGED);
+ session::notifications(&mut out);
routines::notifications(&mut out);
browser::notifications(&mut out);
out.text.push_str("}\n\n");
diff --git a/crates/botloft-core/src/protocol/export_session.rs b/crates/botloft-core/src/protocol/export_session.rs
new file mode 100644
index 00000000..186ddf5b
--- /dev/null
+++ b/crates/botloft-core/src/protocol/export_session.rs
@@ -0,0 +1,30 @@
+//! The effort and context part of the TypeScript bindings (spec 7.4, 8.6).
+
+use std::fmt::Write as _;
+
+use super::super::*;
+use super::Out;
+
+/// Before `Bot`, which holds them.
+pub(super) fn decls(out: &mut Out) {
+ out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
+ out.decl::();
+}
+
+pub(super) fn methods(out: &mut Out) {
+ let bot = out.name::();
+ out.method(
+ method::BOTS_SET_EFFORT,
+ &out.name::(),
+ &bot,
+ );
+ out.method(method::BOTS_COMPACT, &out.name::(), &bot);
+}
+
+pub(super) fn notifications(out: &mut Out) {
+ let context = out.name::();
+ let _ = writeln!(out.text, " \"{}\": {context};", notification::BOT_CONTEXT);
+}
diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs
index c487e107..8505c334 100644
--- a/crates/botloft-core/src/protocol/mod.rs
+++ b/crates/botloft-core/src/protocol/mod.rs
@@ -60,6 +60,7 @@ mod methods;
mod model;
mod routines;
mod screens;
+mod session;
mod settings;
pub use browser::*;
@@ -71,6 +72,7 @@ pub use methods::*;
pub use model::*;
pub use routines::*;
pub use screens::*;
+pub use session::*;
pub use settings::*;
/// Version negotiated in `session.hello`. 2 replaced the terminal with the
@@ -90,12 +92,15 @@ pub mod method {
pub const CREWS_SET_LEAD: &str = "crews.setLead";
pub const CREWS_ARCHIVE: &str = "crews.archive";
pub const CREWS_DELETE: &str = "crews.delete";
+ pub const ARCHIVE_LIST: &str = "archive.list";
pub const BOTS_LIST: &str = "bots.list";
pub const BOTS_CREATE: &str = "bots.create";
pub const BOTS_UPDATE: &str = "bots.update";
pub const BOTS_SET_PAUSED: &str = "bots.setPaused";
pub const BOTS_SET_PERMISSION_MODE: &str = "bots.setPermissionMode";
pub const BOTS_SET_MODEL: &str = "bots.setModel";
+ pub const BOTS_SET_EFFORT: &str = "bots.setEffort";
+ pub const BOTS_COMPACT: &str = "bots.compact";
pub const BOTS_ARCHIVE: &str = "bots.archive";
pub const BOTS_DELETE: &str = "bots.delete";
pub const BOTS_RESTART: &str = "bots.restart";
@@ -138,7 +143,9 @@ pub mod notification {
pub const CREW_DELETED: &str = "crew.deleted";
pub const BOT_CHANGED: &str = "bot.changed";
pub const BOT_DELETED: &str = "bot.deleted";
+ pub const FOLDER_RECYCLED: &str = "folder.recycled";
pub const BOT_STATE: &str = "bot.state";
+ pub const BOT_CONTEXT: &str = "bot.context";
pub const CHAT_ITEM: &str = "chat.item";
pub const CHAT_DELTA: &str = "chat.delta";
pub const MESSAGE_CREATED: &str = "message.created";
diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs
index 169ca45f..873ad028 100644
--- a/crates/botloft-core/src/protocol/model.rs
+++ b/crates/botloft-core/src/protocol/model.rs
@@ -2,7 +2,7 @@
use serde::{Deserialize, Serialize};
-use super::Activity;
+use super::{Activity, BotEffort, ContextUsage, ModelEffort};
use crate::ids::{BotId, CrewId};
/// A group of bots that can message each other and share a folder.
@@ -137,6 +137,12 @@ pub struct Bot {
/// The model id Claude Code reported when the bot last started a turn
/// (`claude-opus-5-5`); `null` before its first turn.
pub model_in_use: Option,
+ pub effort: BotEffort,
+ /// The effort the bot's model uses by itself, as Claude Code last
+ /// reported it; `null` until it is known.
+ pub effort_default: Option,
+ /// How full the conversation is; `null` until Claude Code said so.
+ pub context: Option,
pub state: BotState,
/// Current process generation; `null` if the bot has not started since
/// the daemon did. Changes on every (re)start.
@@ -179,3 +185,52 @@ pub struct BotDeleted {
pub struct CrewDeleted {
pub crew_id: CrewId,
}
+
+/// What the owner archived (`archive.list`): out of the app, kept in the
+/// database, and still there to delete (spec 7.6). `bots` has every
+/// archived bot, those of archived crews too.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct Archive {
+ pub crews: Vec,
+ pub bots: Vec,
+}
+
+/// Params of `bots.delete` (spec 7.6).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct BotsDeleteParams {
+ pub bot_id: BotId,
+ /// Also move the bot's folder to the Recycle Bin; it stays when absent.
+ #[serde(default)]
+ #[cfg_attr(test, ts(optional))]
+ pub recycle_folder: Option,
+}
+
+/// Params of `crews.delete` (spec 7.6).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct CrewsDeleteParams {
+ pub crew_id: CrewId,
+ /// Also move the crew's own folder, with each bot's folder and
+ /// `shared`, to the Recycle Bin; it stays when absent. A work folder
+ /// the owner chose is never moved.
+ #[serde(default)]
+ #[cfg_attr(test, ts(optional))]
+ pub recycle_folder: Option,
+}
+
+/// Params of the `folder.recycled` notification: how the move of a deleted
+/// bot's or crew's folder to the Recycle Bin ended (spec 7.6).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct FolderRecycled {
+ /// Absolute path the folder had.
+ pub path: String,
+ /// Why the folder is still there; `null` when it is in the bin.
+ pub error: Option,
+}
diff --git a/crates/botloft-core/src/protocol/session.rs b/crates/botloft-core/src/protocol/session.rs
new file mode 100644
index 00000000..4c703190
--- /dev/null
+++ b/crates/botloft-core/src/protocol/session.rs
@@ -0,0 +1,102 @@
+//! What a bot's Claude Code session runs with and how full it is: the
+//! effort level (spec 7.4) and the context in use (spec 8.6).
+
+use serde::{Deserialize, Serialize};
+
+use crate::ids::BotId;
+
+text_enum!(
+ /// How much a bot thinks before it answers (spec 7.4): Claude Code's
+ /// `--effort` levels, or the level its model uses by itself.
+ BotEffort, "effort" {
+ /// No `--effort`: the level Claude Code uses for the bot's model.
+ Default => "default",
+ Low => "low",
+ Medium => "medium",
+ High => "high",
+ Xhigh => "xhigh",
+ Max => "max",
+ }
+);
+
+impl BotEffort {
+ /// The value of Claude Code's `--effort`; `None` leaves the flag out.
+ pub fn cli_value(self) -> Option<&'static str> {
+ match self {
+ Self::Default => None,
+ level => Some(level.as_str()),
+ }
+ }
+}
+
+text_enum!(
+ /// The effort a model runs at when the owner picks none, as Claude Code
+ /// reports it (spec 7.4).
+ ModelEffort, "model effort" {
+ /// The model takes no effort level.
+ None => "none",
+ Low => "low",
+ Medium => "medium",
+ High => "high",
+ Xhigh => "xhigh",
+ Max => "max",
+ }
+);
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct BotsSetEffortParams {
+ pub bot_id: BotId,
+ pub effort: BotEffort,
+}
+
+/// How full a bot's conversation is, in tokens, as Claude Code counts it
+/// (spec 8.6).
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct ContextUsage {
+ /// What the conversation holds now.
+ pub used_tokens: u64,
+ /// The most it can hold.
+ pub window_tokens: u64,
+ /// Where Claude Code compacts the conversation by itself; `null` when it
+ /// does not.
+ pub auto_compact_tokens: Option,
+ /// The conversation is being compacted right now.
+ pub compacting: bool,
+ /// Unix time in milliseconds.
+ pub updated_at: i64,
+}
+
+/// Params of the `bot.context` notification.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+#[cfg_attr(test, derive(ts_rs::TS))]
+pub struct BotContextChanged {
+ pub bot_id: BotId,
+ /// `null` when the bot began a new conversation and its size is not
+ /// known yet.
+ pub context: Option,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn efforts_read_as_claude_code_names_them() {
+ assert_eq!(BotEffort::Default.cli_value(), None);
+ assert_eq!(BotEffort::Xhigh.cli_value(), Some("xhigh"));
+ assert_eq!(
+ "xhigh".parse::().ok(),
+ Some(ModelEffort::Xhigh)
+ );
+ assert_eq!(
+ serde_json::to_value(ModelEffort::None).expect("serialize"),
+ serde_json::json!("none")
+ );
+ assert!("ultra".parse::().is_err());
+ }
+}
diff --git a/crates/botloft-store/migrations/0010_bot_effort.sql b/crates/botloft-store/migrations/0010_bot_effort.sql
new file mode 100644
index 00000000..a1577e51
--- /dev/null
+++ b/crates/botloft-store/migrations/0010_bot_effort.sql
@@ -0,0 +1,5 @@
+-- How much each bot thinks before it answers (spec 7.4): the effort level the
+-- owner picked, and the level its model uses by itself, as Claude Code last
+-- reported it. Existing bots keep their model's own level, as they did.
+ALTER TABLE bots ADD COLUMN effort TEXT NOT NULL DEFAULT 'default';
+ALTER TABLE bots ADD COLUMN effort_default TEXT;
diff --git a/crates/botloft-store/src/approvals.rs b/crates/botloft-store/src/approvals.rs
index 8a5c1568..b0c150c8 100644
--- a/crates/botloft-store/src/approvals.rs
+++ b/crates/botloft-store/src/approvals.rs
@@ -144,6 +144,7 @@ mod tests {
approval_id: approval.id.clone(),
tool_name: approval.tool_name.clone(),
summary: approval.summary.clone(),
+ explanation: None,
input: approval.input.clone(),
status: ApprovalStatus::Pending,
note: None,
diff --git a/crates/botloft-store/src/bots.rs b/crates/botloft-store/src/bots.rs
index 59e5e5ac..53ffb9f3 100644
--- a/crates/botloft-store/src/bots.rs
+++ b/crates/botloft-store/src/bots.rs
@@ -1,7 +1,7 @@
//! `bots` table.
use botloft_core::ids::{BotId, CrewId};
-use botloft_core::protocol::{BotModel, PermissionMode};
+use botloft_core::protocol::{BotEffort, BotModel, ModelEffort, PermissionMode};
use rusqlite::{OptionalExtension, Row, params};
use crate::{Result, Store, parse_column, unique_as_duplicate};
@@ -24,12 +24,16 @@ pub struct BotRecord {
/// What Claude Code last reported; written by [`Store::set_model_in_use`]
/// and left alone by [`Store::update_bot`].
pub model_in_use: Option,
+ pub effort: BotEffort,
+ /// What Claude Code last reported; written by
+ /// [`Store::set_effort_default`] and left alone by [`Store::update_bot`].
+ pub effort_default: Option,
pub created_at: i64,
pub archived_at: Option,
}
const COLUMNS: &str = "id, crew_id, name, handle, slug, role, instructions, color, paused, created_at, archived_at, \
- permission_mode, model, model_in_use";
+ permission_mode, model, model_in_use, effort, effort_default";
fn from_row(row: &Row<'_>) -> rusqlite::Result {
Ok(BotRecord {
@@ -47,6 +51,11 @@ fn from_row(row: &Row<'_>) -> rusqlite::Result {
permission_mode: parse_column(row, 11)?,
model: parse_column(row, 12)?,
model_in_use: row.get(13)?,
+ effort: parse_column(row, 14)?,
+ // A level this build does not know reads as not known.
+ effort_default: row
+ .get::<_, Option>(15)?
+ .and_then(|level| level.parse().ok()),
})
}
@@ -56,7 +65,7 @@ impl Store {
.execute(
&format!(
"INSERT INTO bots ({COLUMNS}) \
- VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)"
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)"
),
params![
bot.id.as_str(),
@@ -72,7 +81,9 @@ impl Store {
bot.archived_at,
bot.permission_mode.as_str(),
bot.model.as_str(),
- bot.model_in_use
+ bot.model_in_use,
+ bot.effort.as_str(),
+ bot.effort_default.map(ModelEffort::as_str)
],
)
.map_err(|err| unique_as_duplicate(err, "bot handle or slug"))?;
@@ -140,7 +151,8 @@ impl Store {
self.conn
.execute(
"UPDATE bots SET name = ?2, handle = ?3, role = ?4, instructions = ?5, \
- color = ?6, paused = ?7, archived_at = ?8, permission_mode = ?9, model = ?10 WHERE id = ?1",
+ color = ?6, paused = ?7, archived_at = ?8, permission_mode = ?9, model = ?10, \
+ effort = ?11 WHERE id = ?1",
params![
bot.id.as_str(),
bot.name,
@@ -151,14 +163,15 @@ impl Store {
bot.paused,
bot.archived_at,
bot.permission_mode.as_str(),
- bot.model.as_str()
+ bot.model.as_str(),
+ bot.effort.as_str()
],
)
.map_err(|err| unique_as_duplicate(err, "bot handle"))?;
Ok(())
}
- /// The model Claude Code reported at the start of a turn (spec 7.4).
+ /// The model Claude Code reported (spec 7.4).
pub fn set_model_in_use(&self, id: &BotId, model: &str) -> Result<()> {
self.conn.execute(
"UPDATE bots SET model_in_use = ?2 WHERE id = ?1",
@@ -167,6 +180,16 @@ impl Store {
Ok(())
}
+ /// The effort the bot's model uses by itself, as Claude Code reported it
+ /// (spec 7.4); `None` when it is not known.
+ pub fn set_effort_default(&self, id: &BotId, effort: Option) -> Result<()> {
+ self.conn.execute(
+ "UPDATE bots SET effort_default = ?2 WHERE id = ?1",
+ params![id.as_str(), effort.map(ModelEffort::as_str)],
+ )?;
+ Ok(())
+ }
+
/// The Claude Code conversation the bot resumes (spec 7.3).
pub fn session_id(&self, id: &BotId) -> Result> {
Ok(self
@@ -191,123 +214,4 @@ impl Store {
}
#[cfg(test)]
-mod tests {
- use botloft_core::protocol::Crew;
-
- use super::*;
- use crate::StoreError;
-
- fn setup() -> (Store, CrewId) {
- let store = Store::open_in_memory().expect("store");
- let crew = Crew {
- id: CrewId::generate(),
- name: "Docs".to_owned(),
- slug: "docs".to_owned(),
- work_folder: String::new(),
- work_folder_chosen: false,
- lead_bot_id: None,
- paused: false,
- created_at: 1,
- archived_at: None,
- };
- store.insert_crew(&crew).expect("crew");
- (store, crew.id)
- }
-
- fn bot(crew: &CrewId, handle: &str, created_at: i64) -> BotRecord {
- BotRecord {
- id: BotId::generate(),
- crew_id: crew.clone(),
- name: handle.to_owned(),
- handle: handle.to_owned(),
- slug: handle.to_owned(),
- role: "writes docs".to_owned(),
- instructions: "Be brief.\nCite sources.".to_owned(),
- color: "#FF7A59".to_owned(),
- paused: false,
- permission_mode: PermissionMode::Default,
- model: BotModel::Default,
- model_in_use: None,
- created_at,
- archived_at: None,
- }
- }
-
- #[test]
- fn insert_get_and_list_by_crew() {
- let (store, crew) = setup();
- let writer = bot(&crew, "writer", 10);
- let editor = bot(&crew, "editor", 20);
- store.insert_bot(&writer).expect("insert");
- store.insert_bot(&editor).expect("insert");
-
- assert_eq!(store.bot(&writer.id).expect("get"), Some(writer.clone()));
- let expected = vec![writer, editor];
- assert_eq!(store.bots(Some(&crew), false).expect("list"), expected);
- assert_eq!(store.bots(None, false).expect("list"), expected);
- assert!(
- store
- .bots(Some(&CrewId::generate()), false)
- .expect("list")
- .is_empty()
- );
- }
-
- #[test]
- fn bot_needs_an_existing_crew() {
- let (store, _) = setup();
- let orphan = store.insert_bot(&bot(&CrewId::generate(), "orphan", 1));
- assert!(matches!(orphan, Err(StoreError::Sqlite(_))), "{orphan:?}");
- }
-
- #[test]
- fn handles_are_unique_among_active_bots_only() {
- let (store, crew) = setup();
- let mut first = bot(&crew, "writer", 1);
- store.insert_bot(&first).expect("insert");
- assert_eq!(
- store.active_bot_by_handle(&crew, "writer").expect("lookup"),
- Some(first.id.clone())
- );
-
- let mut clash = bot(&crew, "writer", 2);
- clash.slug = "writer-2".to_owned();
- assert!(matches!(
- store.insert_bot(&clash),
- Err(StoreError::Duplicate(_))
- ));
-
- first.archived_at = Some(3);
- store.update_bot(&first).expect("archive");
- assert_eq!(
- store.active_bot_by_handle(&crew, "writer").expect("lookup"),
- None
- );
- store.insert_bot(&clash).expect("handle is free again");
- assert!(store.bot_slug_exists(&crew, "writer").expect("slug"));
- assert_eq!(store.count_bots(&crew).expect("count"), 2);
- }
-
- #[test]
- fn update_saves_mutable_fields() {
- let (store, crew) = setup();
- let mut writer = bot(&crew, "writer", 1);
- store.insert_bot(&writer).expect("insert");
- writer.name = "Lead Writer".to_owned();
- writer.handle = "lead-writer".to_owned();
- writer.color = "#5EC8FF".to_owned();
- writer.paused = true;
- store.update_bot(&writer).expect("update");
- assert_eq!(store.bot(&writer.id).expect("get"), Some(writer));
- }
-
- #[test]
- fn archiving_the_crew_archives_its_bots() {
- let (store, crew) = setup();
- store.insert_bot(&bot(&crew, "writer", 1)).expect("insert");
- store.archive_crew(&crew, 9).expect("archive");
- assert!(store.bots(Some(&crew), false).expect("list").is_empty());
- let all = store.bots(Some(&crew), true).expect("list all");
- assert_eq!(all[0].archived_at, Some(9));
- }
-}
+mod tests;
diff --git a/crates/botloft-store/src/bots/tests.rs b/crates/botloft-store/src/bots/tests.rs
new file mode 100644
index 00000000..b3da9fc7
--- /dev/null
+++ b/crates/botloft-store/src/bots/tests.rs
@@ -0,0 +1,141 @@
+use botloft_core::protocol::Crew;
+
+use super::*;
+use crate::StoreError;
+
+fn setup() -> (Store, CrewId) {
+ let store = Store::open_in_memory().expect("store");
+ let crew = Crew {
+ id: CrewId::generate(),
+ name: "Docs".to_owned(),
+ slug: "docs".to_owned(),
+ work_folder: String::new(),
+ work_folder_chosen: false,
+ lead_bot_id: None,
+ paused: false,
+ created_at: 1,
+ archived_at: None,
+ };
+ store.insert_crew(&crew).expect("crew");
+ (store, crew.id)
+}
+
+fn bot(crew: &CrewId, handle: &str, created_at: i64) -> BotRecord {
+ BotRecord {
+ id: BotId::generate(),
+ crew_id: crew.clone(),
+ name: handle.to_owned(),
+ handle: handle.to_owned(),
+ slug: handle.to_owned(),
+ role: "writes docs".to_owned(),
+ instructions: "Be brief.\nCite sources.".to_owned(),
+ color: "#FF7A59".to_owned(),
+ paused: false,
+ permission_mode: PermissionMode::Default,
+ model: BotModel::Default,
+ model_in_use: None,
+ effort: BotEffort::Default,
+ effort_default: None,
+ created_at,
+ archived_at: None,
+ }
+}
+
+#[test]
+fn insert_get_and_list_by_crew() {
+ let (store, crew) = setup();
+ let writer = bot(&crew, "writer", 10);
+ let editor = bot(&crew, "editor", 20);
+ store.insert_bot(&writer).expect("insert");
+ store.insert_bot(&editor).expect("insert");
+
+ assert_eq!(store.bot(&writer.id).expect("get"), Some(writer.clone()));
+ let expected = vec![writer, editor];
+ assert_eq!(store.bots(Some(&crew), false).expect("list"), expected);
+ assert_eq!(store.bots(None, false).expect("list"), expected);
+ assert!(
+ store
+ .bots(Some(&CrewId::generate()), false)
+ .expect("list")
+ .is_empty()
+ );
+}
+
+#[test]
+fn bot_needs_an_existing_crew() {
+ let (store, _) = setup();
+ let orphan = store.insert_bot(&bot(&CrewId::generate(), "orphan", 1));
+ assert!(matches!(orphan, Err(StoreError::Sqlite(_))), "{orphan:?}");
+}
+
+#[test]
+fn handles_are_unique_among_active_bots_only() {
+ let (store, crew) = setup();
+ let mut first = bot(&crew, "writer", 1);
+ store.insert_bot(&first).expect("insert");
+ assert_eq!(
+ store.active_bot_by_handle(&crew, "writer").expect("lookup"),
+ Some(first.id.clone())
+ );
+
+ let mut clash = bot(&crew, "writer", 2);
+ clash.slug = "writer-2".to_owned();
+ assert!(matches!(
+ store.insert_bot(&clash),
+ Err(StoreError::Duplicate(_))
+ ));
+
+ first.archived_at = Some(3);
+ store.update_bot(&first).expect("archive");
+ assert_eq!(
+ store.active_bot_by_handle(&crew, "writer").expect("lookup"),
+ None
+ );
+ store.insert_bot(&clash).expect("handle is free again");
+ assert!(store.bot_slug_exists(&crew, "writer").expect("slug"));
+ assert_eq!(store.count_bots(&crew).expect("count"), 2);
+}
+
+#[test]
+fn update_saves_mutable_fields() {
+ let (store, crew) = setup();
+ let mut writer = bot(&crew, "writer", 1);
+ store.insert_bot(&writer).expect("insert");
+ writer.name = "Lead Writer".to_owned();
+ writer.handle = "lead-writer".to_owned();
+ writer.color = "#5EC8FF".to_owned();
+ writer.paused = true;
+ writer.effort = BotEffort::Low;
+ store.update_bot(&writer).expect("update");
+ assert_eq!(store.bot(&writer.id).expect("get"), Some(writer));
+}
+
+#[test]
+fn what_claude_code_reports_is_saved_apart_from_the_owners_choices() {
+ let (store, crew) = setup();
+ let mut writer = bot(&crew, "writer", 1);
+ store.insert_bot(&writer).expect("insert");
+ store
+ .set_effort_default(&writer.id, Some(ModelEffort::Medium))
+ .expect("report");
+ // Saving the bot's own fields leaves the report alone.
+ writer.role = "edits".to_owned();
+ store.update_bot(&writer).expect("update");
+ let saved = store.bot(&writer.id).expect("get").expect("bot");
+ assert_eq!(saved.effort, BotEffort::Default);
+ assert_eq!(saved.effort_default, Some(ModelEffort::Medium));
+
+ store.set_effort_default(&writer.id, None).expect("forget");
+ let saved = store.bot(&writer.id).expect("get").expect("bot");
+ assert_eq!(saved.effort_default, None);
+}
+
+#[test]
+fn archiving_the_crew_archives_its_bots() {
+ let (store, crew) = setup();
+ store.insert_bot(&bot(&crew, "writer", 1)).expect("insert");
+ store.archive_crew(&crew, 9).expect("archive");
+ assert!(store.bots(Some(&crew), false).expect("list").is_empty());
+ let all = store.bots(Some(&crew), true).expect("list all");
+ assert_eq!(all[0].archived_at, Some(9));
+}
diff --git a/crates/botloft-store/src/chat.rs b/crates/botloft-store/src/chat.rs
index 7367e805..43cb4ee9 100644
--- a/crates/botloft-store/src/chat.rs
+++ b/crates/botloft-store/src/chat.rs
@@ -214,6 +214,7 @@ mod tests {
tool_use_id: "toolu_7".into(),
name: "Bash".into(),
summary: "npm test".into(),
+ explanation: None,
input: "{}".into(),
status: ToolStatus::Running,
output: None,
@@ -254,6 +255,7 @@ mod tests {
tool_use_id: "t".into(),
name: "Write".into(),
summary: String::new(),
+ explanation: None,
input: "{}".into(),
status,
output: None,
diff --git a/crates/botloft-store/src/delete/tests.rs b/crates/botloft-store/src/delete/tests.rs
index 8a4e53c4..79569386 100644
--- a/crates/botloft-store/src/delete/tests.rs
+++ b/crates/botloft-store/src/delete/tests.rs
@@ -88,6 +88,7 @@ fn fill(fx: &Fixture) -> Owned {
approval_id: approval.id.clone(),
tool_name: approval.tool_name.clone(),
summary: approval.summary.clone(),
+ explanation: None,
input: approval.input.clone(),
status: ApprovalStatus::Pending,
note: None,
diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs
index 65238c4b..767ad9ad 100644
--- a/crates/botloft-store/src/lib.rs
+++ b/crates/botloft-store/src/lib.rs
@@ -150,6 +150,8 @@ pub(crate) mod tests {
permission_mode: botloft_core::protocol::PermissionMode::Default,
model: botloft_core::protocol::BotModel::Default,
model_in_use: None,
+ effort: botloft_core::protocol::BotEffort::Default,
+ effort_default: None,
created_at: 0,
archived_at: None,
};
diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs
index 0378da0b..561e41d0 100644
--- a/crates/botloft-store/src/migrate.rs
+++ b/crates/botloft-store/src/migrate.rs
@@ -16,6 +16,7 @@ const MIGRATIONS: &[(u32, &str)] = &[
(7, include_str!("../migrations/0007_crew_lead.sql")),
(8, include_str!("../migrations/0008_routines.sql")),
(9, include_str!("../migrations/0009_browser_sites.sql")),
+ (10, include_str!("../migrations/0010_bot_effort.sql")),
];
/// Schema version after every migration has run.
diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml
index ad01857b..055cd827 100644
--- a/crates/botloftd/Cargo.toml
+++ b/crates/botloftd/Cargo.toml
@@ -36,7 +36,9 @@ tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
workspace = true
[target."cfg(windows)".dependencies]
-windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler", "Win32_System_RemoteDesktop", "Win32_Security_Authentication_Identity"] }
+windows = { version = "0.62.2", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_System_Threading", "Win32_System_Memory", "Win32_System_JobObjects", "Win32_System_Environment", "Win32_System_Power", "Win32_System_Console", "Win32_System_Com", "Win32_System_Ole", "Win32_System_Variant", "Win32_System_TaskScheduler", "Win32_System_RemoteDesktop", "Win32_Security_Authentication_Identity", "Win32_UI_Shell", "Win32_UI_Shell_Common"] }
+# Named by the code that `windows::core::implement` writes.
+windows-core = "0.62.2"
[target."cfg(windows)".build-dependencies]
embed-resource = "3.0.11"
diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs
index bec38887..c777c755 100644
--- a/crates/botloftd/src/approvals/mod.rs
+++ b/crates/botloftd/src/approvals/mod.rs
@@ -9,6 +9,7 @@ use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard};
use botloft_core::chat::{SUGGEST_TOOL, clip, tool_input_max, tool_summary};
+use botloft_core::command::tool_explanation;
use botloft_core::ids::{ApprovalId, BotId, ChatItemId};
use botloft_core::protocol::{
Approval, ApprovalItem, ApprovalStatus, ApprovalsAnswerParams, ChatBody,
@@ -226,7 +227,8 @@ fn open(
created_at: now,
answered_at: None,
};
- let item = items::add(daemon, bot, ChatBody::Approval(item_of(&approval)))?;
+ let shown = item_of(&approval, tool_explanation(tool_name, input));
+ let item = items::add(daemon, bot, ChatBody::Approval(shown))?;
let record = ApprovalRecord {
approval,
chat_item_id: item.id,
@@ -239,11 +241,12 @@ fn open(
Some(Pending { record })
}
-fn item_of(approval: &Approval) -> ApprovalItem {
+fn item_of(approval: &Approval, explanation: Option) -> ApprovalItem {
ApprovalItem {
approval_id: approval.id.clone(),
tool_name: approval.tool_name.clone(),
summary: approval.summary.clone(),
+ explanation,
input: approval.input.clone(),
status: approval.status,
note: approval.note.clone(),
@@ -278,7 +281,14 @@ fn settle(
fn show(daemon: &Daemon, record: &ApprovalRecord) {
let item: &ChatItemId = &record.chat_item_id;
- items::update(daemon, item, ChatBody::Approval(item_of(&record.approval)));
+ // The bot's explanation is kept only in the chat item.
+ let shown = daemon.store().chat_item(item).ok().flatten();
+ let explanation = match shown.map(|shown| shown.body) {
+ Some(ChatBody::Approval(shown)) => shown.explanation,
+ _ => None,
+ };
+ let shown = item_of(&record.approval, explanation);
+ items::update(daemon, item, ChatBody::Approval(shown));
}
/// Expires the request if the MCP call goes away before it is settled,
diff --git a/crates/botloftd/src/chat/account.rs b/crates/botloftd/src/chat/account.rs
new file mode 100644
index 00000000..da71c429
--- /dev/null
+++ b/crates/botloftd/src/chat/account.rs
@@ -0,0 +1,114 @@
+//! What the account behind a bot's turns says (spec 7.2 and 8.1): an API
+//! error that ended a turn, and the usage of the owner's Claude plan.
+
+use botloft_core::ids::BotId;
+use botloft_core::protocol::{
+ AccountUsage, ChatBody, NoticeCode, NoticeItem, NoticeLevel, UsageWindow,
+};
+use serde_json::Value;
+
+use super::{events, items};
+use crate::state::Daemon;
+
+/// Errors that restarting cannot fix: Claude Code needs the owner.
+const SIGN_IN_ERRORS: &[&str] = &[
+ "authentication_failed",
+ "oauth_org_not_allowed",
+ "billing_error",
+ "account_on_hold",
+];
+/// The model does not exist or the account cannot use it (spec 7.4).
+const MODEL_NOT_FOUND: &str = "model_not_found";
+/// When a rate limit gives no reset time.
+const DEFAULT_LIMIT_MS: i64 = 5 * 60 * 1000;
+
+/// An API error ended the turn: say so, and stop or pause the bot when
+/// only the owner or time can fix it (spec 7.2).
+pub(super) fn failed_turn(
+ daemon: &Daemon,
+ bot: &BotId,
+ generation: u64,
+ error: &str,
+ event: &Value,
+) {
+ let detail = events::blocks(event)
+ .iter()
+ .filter_map(|block| block["text"].as_str())
+ .collect::>()
+ .join(" ");
+ // The code lets the app say it in the owner's language; the text stays
+ // for older apps and for the conversation list.
+ let (code, text) = if SIGN_IN_ERRORS.contains(&error) {
+ (
+ NoticeCode::SignedOut,
+ "Claude Code is not signed in or the account cannot be used. Sign in, then restart \
+ the bot."
+ .to_owned(),
+ )
+ } else if error == "rate_limit" {
+ (
+ NoticeCode::UsageLimit,
+ "The account reached its usage limit. Messages wait until it resets.".to_owned(),
+ )
+ } else if error == MODEL_NOT_FOUND {
+ (
+ NoticeCode::ModelUnavailable,
+ "Claude Code could not use this bot's model: it may not exist or not be on the account's plan. Pick another model."
+ .to_owned(),
+ )
+ } else if detail.is_empty() {
+ (NoticeCode::TurnFailed, error.to_owned())
+ } else {
+ (NoticeCode::TurnFailed, detail)
+ };
+ items::add(
+ daemon,
+ bot,
+ ChatBody::Notice(NoticeItem {
+ level: NoticeLevel::Error,
+ code: Some(code),
+ text,
+ }),
+ );
+ if SIGN_IN_ERRORS.contains(&error) {
+ daemon.supervisor.signed_out(bot, generation);
+ } else if error == "rate_limit" {
+ let until = daemon
+ .usage()
+ .and_then(|usage| usage.resets_at)
+ .unwrap_or_else(|| daemon.clock.now_ms() + DEFAULT_LIMIT_MS);
+ daemon.supervisor.rate_limited(bot, generation, until);
+ }
+}
+
+pub(super) fn rate_limit(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
+ let info = &event["rate_limit_info"];
+ let seconds = |value: &Value| value.as_i64().map(|s| s * 1000);
+ let mut windows: Vec = info["unifiedWindows"]
+ .as_object()
+ .map(|windows| {
+ windows
+ .iter()
+ .map(|(name, window)| UsageWindow {
+ name: name.clone(),
+ utilization: window["utilization"].as_f64().unwrap_or_default(),
+ resets_at: seconds(&window["resetsAt"]),
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+ windows.sort_by(|a, b| a.name.cmp(&b.name));
+ let status = info["status"].as_str().unwrap_or("unknown").to_owned();
+ let resets_at = seconds(&info["resetsAt"]);
+ let limited = !status.starts_with("allowed");
+ daemon.set_usage(AccountUsage {
+ status,
+ resets_at,
+ windows,
+ observed_at: daemon.clock.now_ms(),
+ });
+ if limited {
+ let until = resets_at.unwrap_or_else(|| daemon.clock.now_ms() + DEFAULT_LIMIT_MS);
+ daemon.supervisor.rate_limited(bot, generation, until);
+ }
+}
diff --git a/crates/botloftd/src/chat/control.rs b/crates/botloftd/src/chat/control.rs
new file mode 100644
index 00000000..2ecdb2d2
--- /dev/null
+++ b/crates/botloftd/src/chat/control.rs
@@ -0,0 +1,57 @@
+//! What the daemon asks a bot's process about its session, on stdin and
+//! outside the conversation (spec 9.2): what it runs with and how full it
+//! is. Claude Code answers each request with a `control_response` on stdout,
+//! during a turn too; none of it reaches the model.
+
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use botloft_core::ids::BotId;
+use bytes::Bytes;
+use serde_json::{Value, json};
+use tracing::debug;
+
+use crate::state::Daemon;
+use crate::{context, service};
+
+/// Request ids start with what was asked, so the answer says what it is.
+const SETTINGS: &str = "botloft-settings-";
+const CONTEXT: &str = "botloft-context-";
+
+static NEXT: AtomicU64 = AtomicU64::new(1);
+
+fn request(prefix: &str, subtype: &str) -> Bytes {
+ let id = format!("{prefix}{}", NEXT.fetch_add(1, Ordering::Relaxed));
+ let line = json!({
+ "type": "control_request",
+ "request_id": id,
+ "request": { "subtype": subtype },
+ });
+ Bytes::from(format!("{line}\n"))
+}
+
+/// Asks which model and effort the session applies (spec 7.4).
+pub(crate) fn settings_request() -> Bytes {
+ request(SETTINGS, "get_settings")
+}
+
+/// Asks how full the conversation is (spec 8.6).
+pub(crate) fn context_request() -> Bytes {
+ request(CONTEXT, "get_context_usage")
+}
+
+/// A `control_response` from the bot's process. A request this Claude Code
+/// does not know fails, and what it would have told stays unknown.
+pub(super) fn answered(daemon: &Daemon, bot: &BotId, event: &Value) {
+ let response = &event["response"];
+ let id = response["request_id"].as_str().unwrap_or_default();
+ if response["subtype"] != "success" {
+ debug!(bot = %bot, id, "control request failed");
+ return;
+ }
+ let body = &response["response"];
+ if id.starts_with(SETTINGS) {
+ service::models::applied(daemon, bot, &body["applied"]);
+ } else if id.starts_with(CONTEXT) {
+ context::answered(daemon, bot, body);
+ }
+}
diff --git a/crates/botloftd/src/chat/events.rs b/crates/botloftd/src/chat/events.rs
index c561b296..adbf7ffc 100644
--- a/crates/botloftd/src/chat/events.rs
+++ b/crates/botloftd/src/chat/events.rs
@@ -3,29 +3,15 @@
//! and grows between Claude Code versions.
use botloft_core::chat::{TOOL_OUTPUT_MAX, clip, tool_file, tool_input_max, tool_summary};
+use botloft_core::command::tool_explanation;
use botloft_core::ids::BotId;
-use botloft_core::protocol::{
- AccountUsage, ChatBody, ChatDelta, NoticeCode, NoticeItem, NoticeLevel, ReplyItem, ToolItem,
- ToolStatus, TurnItem, UsageWindow,
-};
+use botloft_core::protocol::{ChatBody, ChatDelta, ReplyItem, ToolItem, ToolStatus, TurnItem};
use serde_json::Value;
use tracing::{debug, warn};
-use super::items;
+use super::{account, control, items};
use crate::state::{Daemon, Event};
-use crate::{routines, service};
-
-/// Errors that restarting cannot fix: Claude Code needs the owner.
-const SIGN_IN_ERRORS: &[&str] = &[
- "authentication_failed",
- "oauth_org_not_allowed",
- "billing_error",
- "account_on_hold",
-];
-/// The model does not exist or the account cannot use it (spec 7.4).
-const MODEL_NOT_FOUND: &str = "model_not_found";
-/// When a rate limit gives no reset time.
-const DEFAULT_LIMIT_MS: i64 = 5 * 60 * 1000;
+use crate::{context, routines, service};
pub(super) fn apply(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
// Lines from a process that was replaced say nothing about the new one.
@@ -46,6 +32,9 @@ pub(super) fn apply(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value
.supervisor
.agents_running(bot, generation, agent_count(event));
}
+ "system" if subtype == Some("status") => context::status(daemon, bot, event),
+ "system" if subtype == Some("compact_boundary") => context::compacted(daemon, bot, event),
+ "control_response" => control::answered(daemon, bot, event),
"stream_event" if !from_subagent => {
delta(daemon, bot, event);
crate::screens::stream(daemon, bot, event);
@@ -53,7 +42,7 @@ pub(super) fn apply(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value
"assistant" if !from_subagent => assistant(daemon, bot, generation, event),
"user" if event["isReplay"].as_bool() == Some(true) => replay(daemon, bot, event),
"user" if !from_subagent => tool_results(daemon, bot, event),
- "rate_limit_event" => rate_limit(daemon, bot, generation, event),
+ "rate_limit_event" => account::rate_limit(daemon, bot, generation, event),
"result" => result(daemon, bot, generation, event),
_ => debug!(bot = %bot, kind, subtype, "stream event not used"),
}
@@ -97,7 +86,7 @@ fn delta(daemon: &Daemon, bot: &BotId, event: &Value) {
}
}
-fn blocks(event: &Value) -> &[Value] {
+pub(super) fn blocks(event: &Value) -> &[Value] {
event["message"]["content"]
.as_array()
.map_or(&[], Vec::as_slice)
@@ -105,9 +94,14 @@ fn blocks(event: &Value) -> &[Value] {
fn assistant(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
if let Some(error) = event["error"].as_str() {
- failed_turn(daemon, bot, generation, error, event);
+ account::failed_turn(daemon, bot, generation, error, event);
+ return;
+ }
+ // What `/compact` printed is the daemon's to tell, not a reply.
+ if context::command_output(daemon, bot, event) {
return;
}
+ context::used(daemon, bot, &event["message"]);
for block in blocks(event) {
match block["type"].as_str() {
Some("text") => {
@@ -132,6 +126,7 @@ fn assistant(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
tool_use_id: block["id"].as_str().unwrap_or_default().to_owned(),
name: name.to_owned(),
summary: tool_summary(name, input),
+ explanation: tool_explanation(name, input),
input: clip(&input.to_string(), tool_input_max(name)),
status: ToolStatus::Running,
output: None,
@@ -144,59 +139,6 @@ fn assistant(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
}
}
-/// An API error ended the turn: say so, and stop or pause the bot when
-/// only the owner or time can fix it (spec 7.2).
-fn failed_turn(daemon: &Daemon, bot: &BotId, generation: u64, error: &str, event: &Value) {
- let detail = blocks(event)
- .iter()
- .filter_map(|block| block["text"].as_str())
- .collect::>()
- .join(" ");
- // The code lets the app say it in the owner's language; the text stays
- // for older apps and for the conversation list.
- let (code, text) = if SIGN_IN_ERRORS.contains(&error) {
- (
- NoticeCode::SignedOut,
- "Claude Code is not signed in or the account cannot be used. Sign in, then restart \
- the bot."
- .to_owned(),
- )
- } else if error == "rate_limit" {
- (
- NoticeCode::UsageLimit,
- "The account reached its usage limit. Messages wait until it resets.".to_owned(),
- )
- } else if error == MODEL_NOT_FOUND {
- (
- NoticeCode::ModelUnavailable,
- "Claude Code could not use this bot's model: it may not exist or not be on the account's plan. Pick another model."
- .to_owned(),
- )
- } else if detail.is_empty() {
- (NoticeCode::TurnFailed, error.to_owned())
- } else {
- (NoticeCode::TurnFailed, detail)
- };
- items::add(
- daemon,
- bot,
- ChatBody::Notice(NoticeItem {
- level: NoticeLevel::Error,
- code: Some(code),
- text,
- }),
- );
- if SIGN_IN_ERRORS.contains(&error) {
- daemon.supervisor.signed_out(bot, generation);
- } else if error == "rate_limit" {
- let until = daemon
- .usage()
- .and_then(|usage| usage.resets_at)
- .unwrap_or_else(|| daemon.clock.now_ms() + DEFAULT_LIMIT_MS);
- daemon.supervisor.rate_limited(bot, generation, until);
- }
-}
-
/// The bot began the turn for a message the courier wrote (spec 9.1).
fn replay(daemon: &Daemon, bot: &BotId, event: &Value) {
super::session::began(daemon, bot, event);
@@ -259,38 +201,6 @@ fn result_text(content: &Value) -> String {
}
}
-fn rate_limit(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
- let info = &event["rate_limit_info"];
- let seconds = |value: &Value| value.as_i64().map(|s| s * 1000);
- let mut windows: Vec = info["unifiedWindows"]
- .as_object()
- .map(|windows| {
- windows
- .iter()
- .map(|(name, window)| UsageWindow {
- name: name.clone(),
- utilization: window["utilization"].as_f64().unwrap_or_default(),
- resets_at: seconds(&window["resetsAt"]),
- })
- .collect()
- })
- .unwrap_or_default();
- windows.sort_by(|a, b| a.name.cmp(&b.name));
- let status = info["status"].as_str().unwrap_or("unknown").to_owned();
- let resets_at = seconds(&info["resetsAt"]);
- let limited = !status.starts_with("allowed");
- daemon.set_usage(AccountUsage {
- status,
- resets_at,
- windows,
- observed_at: daemon.clock.now_ms(),
- });
- if limited {
- let until = resets_at.unwrap_or_else(|| daemon.clock.now_ms() + DEFAULT_LIMIT_MS);
- daemon.supervisor.rate_limited(bot, generation, until);
- }
-}
-
fn result(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
// No turn began, so none ended: nothing of this is for the chat.
if super::session::missing(event) {
@@ -306,15 +216,18 @@ fn result(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) {
.unwrap_or("error")
.to_owned()
});
- items::add(
- daemon,
- bot,
- ChatBody::Turn(TurnItem {
- duration_ms: event["duration_ms"].as_u64().unwrap_or_default(),
- cost_usd: event["total_cost_usd"].as_f64(),
- error,
- }),
- );
+ // A compaction ends like a turn; its notice already says what happened.
+ if !context::turn_ended(daemon, bot, event) {
+ items::add(
+ daemon,
+ bot,
+ ChatBody::Turn(TurnItem {
+ duration_ms: event["duration_ms"].as_u64().unwrap_or_default(),
+ cost_usd: event["total_cost_usd"].as_f64(),
+ error,
+ }),
+ );
+ }
routines::turn_ended(daemon, bot, failed);
crate::screens::turn_ended(daemon, bot);
daemon.supervisor.turn_ended(bot, generation);
diff --git a/crates/botloftd/src/chat/mod.rs b/crates/botloftd/src/chat/mod.rs
index dfe88bae..f4644316 100644
--- a/crates/botloftd/src/chat/mod.rs
+++ b/crates/botloftd/src/chat/mod.rs
@@ -2,6 +2,8 @@
//! line, and turns each event into chat items, live text, read receipts and
//! state changes.
+mod account;
+pub(crate) mod control;
mod events;
pub(crate) mod items;
mod session;
diff --git a/crates/botloftd/src/context.rs b/crates/botloftd/src/context.rs
new file mode 100644
index 00000000..db8a9724
--- /dev/null
+++ b/crates/botloftd/src/context.rs
@@ -0,0 +1,269 @@
+//! How full each bot's conversation is, and compacting it (spec 8.6).
+//! Claude Code does the counting: the daemon asks it when a process starts,
+//! when a turn ends and after a compaction, and follows the usage of each
+//! model request in between. Nothing is stored; a new daemon asks again.
+
+use std::collections::HashMap;
+use std::sync::{Mutex, MutexGuard};
+
+use botloft_core::ids::{BotId, random_uuid};
+use botloft_core::protocol::{
+ Bot, BotContextChanged, BotIdParams, BotState, ChatBody, ContextUsage, NoticeCode, NoticeItem,
+ NoticeLevel,
+};
+use bytes::Bytes;
+use serde_json::{Value, json};
+use tracing::debug;
+
+use crate::chat::{control, items};
+use crate::service::bots::{active, to_protocol};
+use crate::service::{ApiError, ApiResult};
+use crate::state::{Daemon, Event};
+
+/// Claude Code's command that compacts the conversation.
+const COMPACT: &str = "/compact";
+
+#[derive(Default)]
+pub struct Contexts {
+ bots: Mutex>,
+}
+
+#[derive(Default)]
+struct Entry {
+ /// `None` until Claude Code told the size of this conversation.
+ size: Option,
+ /// A compaction is running, or waits behind the turn in progress.
+ compacting: bool,
+}
+
+struct Size {
+ used: u64,
+ window: u64,
+ auto_compact: Option,
+ at: i64,
+}
+
+impl Entry {
+ fn view(&self) -> Option {
+ self.size.as_ref().map(|size| ContextUsage {
+ used_tokens: size.used,
+ window_tokens: size.window,
+ auto_compact_tokens: size.auto_compact,
+ compacting: self.compacting,
+ updated_at: size.at,
+ })
+ }
+}
+
+impl Contexts {
+ fn lock(&self) -> MutexGuard<'_, HashMap> {
+ self.bots
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ }
+
+ /// What the app shows for `bot`; `None` while its size is not known.
+ pub fn get(&self, bot: &BotId) -> Option {
+ self.lock().get(bot).and_then(Entry::view)
+ }
+
+ fn compacting(&self, bot: &BotId) -> bool {
+ self.lock().get(bot).is_some_and(|entry| entry.compacting)
+ }
+}
+
+/// Changes what is known about `bot` and tells the app if it shows.
+fn update(daemon: &Daemon, bot: &BotId, change: impl FnOnce(&mut Entry)) {
+ let (before, after) = {
+ let mut bots = daemon.contexts.lock();
+ let entry = bots.entry(bot.clone()).or_default();
+ let before = entry.view();
+ change(entry);
+ (before, entry.view())
+ };
+ if before != after {
+ daemon.emit(Event::BotContext(BotContextChanged {
+ bot_id: bot.clone(),
+ context: after,
+ }));
+ }
+}
+
+/// Asks the bot's process how full its conversation is. The answer comes
+/// as a `control_response` ([`answered`]).
+pub(crate) fn ask(daemon: &Daemon, bot: &BotId) {
+ let _ = daemon
+ .supervisor
+ .write_control(bot, control::context_request());
+}
+
+/// Claude Code's answer to `get_context_usage`.
+pub(crate) fn answered(daemon: &Daemon, bot: &BotId, body: &Value) {
+ let used = body["totalTokens"].as_u64();
+ let window = body["maxTokens"]
+ .as_u64()
+ .or_else(|| body["rawMaxTokens"].as_u64())
+ .filter(|window| *window > 0);
+ let (Some(used), Some(window)) = (used, window) else {
+ debug!(bot = %bot, "context usage without the sizes");
+ return;
+ };
+ let auto_compact = body["autoCompactThreshold"]
+ .as_u64()
+ .filter(|_| body["isAutoCompactEnabled"].as_bool() != Some(false))
+ .filter(|at| *at > 0 && *at <= window);
+ let at = daemon.clock.now_ms();
+ debug!(bot = %bot, used, window, "context usage");
+ update(daemon, bot, |entry| {
+ entry.size = Some(Size {
+ used,
+ window,
+ auto_compact,
+ at,
+ });
+ });
+}
+
+/// The usage of one model request in the bot's conversation: what it sent
+/// is what the conversation holds. Only moves a size that is known.
+pub(crate) fn used(daemon: &Daemon, bot: &BotId, message: &Value) {
+ if message["model"] == "" {
+ return;
+ }
+ let usage = &message["usage"];
+ let used: u64 = [
+ "input_tokens",
+ "cache_creation_input_tokens",
+ "cache_read_input_tokens",
+ ]
+ .iter()
+ .filter_map(|field| usage[*field].as_u64())
+ .sum();
+ if used == 0 {
+ return;
+ }
+ let at = daemon.clock.now_ms();
+ update(daemon, bot, |entry| {
+ if let Some(size) = &mut entry.size
+ && size.used != used
+ {
+ size.used = used;
+ size.at = at;
+ }
+ });
+}
+
+/// `system/status`: Claude Code began to compact, by itself or because it
+/// was asked, or stopped.
+pub(crate) fn status(daemon: &Daemon, bot: &BotId, event: &Value) {
+ if event["status"] == "compacting" {
+ update(daemon, bot, |entry| entry.compacting = true);
+ } else if !event["compact_result"].is_null() {
+ update(daemon, bot, |entry| entry.compacting = false);
+ }
+}
+
+/// `system/compact_boundary`: the conversation is now a summary. The chat
+/// says so, and Claude Code is asked for the new size.
+pub(crate) fn compacted(daemon: &Daemon, bot: &BotId, event: &Value) {
+ update(daemon, bot, |entry| entry.compacting = false);
+ let (code, text) = if event["compact_metadata"]["trigger"] == "auto" {
+ (
+ NoticeCode::AutoCompacted,
+ "The conversation was full, so it was compacted: earlier messages are now a summary.",
+ )
+ } else {
+ (
+ NoticeCode::Compacted,
+ "The conversation was compacted: earlier messages are now a summary.",
+ )
+ };
+ items::add(
+ daemon,
+ bot,
+ ChatBody::Notice(NoticeItem {
+ level: NoticeLevel::Info,
+ code: Some(code),
+ text: text.to_owned(),
+ }),
+ );
+ ask(daemon, bot);
+}
+
+/// Whether `event` is what `/compact` printed instead of compacting, such
+/// as "No messages to compact". The chat gets a notice, not a reply.
+pub(crate) fn command_output(daemon: &Daemon, bot: &BotId, event: &Value) -> bool {
+ if event["local_command_run"]["command"] != "compact" {
+ return false;
+ }
+ update(daemon, bot, |entry| entry.compacting = false);
+ if event["local_command_outcome"]["kind"] == "failed" {
+ let detail = event["message"]["content"][0]["text"]
+ .as_str()
+ .unwrap_or_default();
+ let detail = detail.strip_prefix("Error: ").unwrap_or(detail);
+ items::add(
+ daemon,
+ bot,
+ ChatBody::Notice(NoticeItem {
+ level: NoticeLevel::Warning,
+ code: Some(NoticeCode::CompactFailed),
+ text: detail.to_owned(),
+ }),
+ );
+ }
+ true
+}
+
+/// A `result` closed a turn; returns whether it was a compaction, which
+/// ends like a turn without being the bot's work.
+pub(crate) fn turn_ended(daemon: &Daemon, bot: &BotId, event: &Value) -> bool {
+ let compaction = event["local_command"] == "compact";
+ if compaction {
+ update(daemon, bot, |entry| entry.compacting = false);
+ }
+ ask(daemon, bot);
+ compaction
+}
+
+/// A process started. A new conversation has a size of its own, not known
+/// until the process tells.
+pub(crate) fn process_started(daemon: &Daemon, bot: &BotId, resumed: bool) {
+ if !resumed {
+ update(daemon, bot, |entry| entry.size = None);
+ }
+}
+
+/// The process is gone, and a compaction it had not finished with it.
+pub(crate) fn process_ended(daemon: &Daemon, bot: &BotId) {
+ update(daemon, bot, |entry| entry.compacting = false);
+}
+
+/// `bots.compact`: writes `/compact` to the bot's process like a message,
+/// so it waits its turn behind what the bot is doing. Asking again while
+/// one is on its way changes nothing.
+pub fn compact(daemon: &Daemon, params: BotIdParams) -> ApiResult {
+ let store = daemon.store();
+ let (crew, record) = active(&store, ¶ms.bot_id)?;
+ if !daemon.contexts.compacting(&record.id) {
+ let not_running = || ApiError::Conflict(format!("bot {} is not running", record.id));
+ let ready = matches!(
+ daemon.supervisor.status(&record.id),
+ Some((BotState::Idle | BotState::Busy | BotState::NeedsApproval, _))
+ );
+ if !ready {
+ return Err(not_running());
+ }
+ let line = json!({
+ "type": "user",
+ "uuid": random_uuid(),
+ "message": { "role": "user", "content": [{ "type": "text", "text": COMPACT }] },
+ });
+ daemon
+ .supervisor
+ .write_message(&record.id, Bytes::from(format!("{line}\n")))
+ .map_err(|_| not_running())?;
+ update(daemon, &record.id, |entry| entry.compacting = true);
+ }
+ Ok(to_protocol(daemon, &store, &crew, record))
+}
diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs
index 20e3ac95..8b720a76 100644
--- a/crates/botloftd/src/lib.rs
+++ b/crates/botloftd/src/lib.rs
@@ -8,6 +8,7 @@ pub mod browser;
pub mod chat;
pub mod clock;
pub mod config;
+pub mod context;
pub mod courier;
pub mod keep_awake;
pub mod logging;
@@ -24,4 +25,5 @@ pub mod settings;
pub mod state;
pub mod supervisor;
pub mod tools;
+pub mod trash;
pub mod workspace;
diff --git a/crates/botloftd/src/main.rs b/crates/botloftd/src/main.rs
index b7c14979..b840d96f 100644
--- a/crates/botloftd/src/main.rs
+++ b/crates/botloftd/src/main.rs
@@ -18,6 +18,7 @@ use botloftd::service::tasks::TaskSettings;
use botloftd::settings::LiveSettings;
use botloftd::state::{BotSettings, Daemon, DaemonOptions};
use botloftd::supervisor::{self, SupervisorSettings};
+use botloftd::trash::RecycleBin;
use botloftd::{approvals, autostart, keep_awake, logging, routines, secrets, server};
use clap::{Parser, Subcommand};
use tokio::net::TcpListener;
@@ -129,6 +130,7 @@ fn run(paths: Paths, config: Config, config_path: PathBuf, scheduled: bool) -> a
bots: BotSettings::from_config(&config),
browser: BrowserSettings::from_config(&config),
settings: LiveSettings::new(Some(config_path), &config),
+ trash: Arc::new(RecycleBin),
});
// No bot process survived the last run, so nobody waits for these.
approvals::expire_all(&daemon);
diff --git a/crates/botloftd/src/platform/mod.rs b/crates/botloftd/src/platform/mod.rs
index 752556ac..ebc2f2c9 100644
--- a/crates/botloftd/src/platform/mod.rs
+++ b/crates/botloftd/src/platform/mod.rs
@@ -12,13 +12,13 @@ use std::path::{Path, PathBuf};
#[cfg(unix)]
pub use unix::{
- KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, register_task,
- restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment,
+ KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, recycle,
+ register_task, restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment,
};
#[cfg(windows)]
pub use windows::{
- KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, register_task,
- restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment,
+ KeepAwake, ProcessJob, delete_task, find_task, leave_own_console, owner_name, recycle,
+ register_task, restrict_to_current_user, run_task, sign_in_id, stop_task, user_environment,
};
/// A program the system starts for the owner (spec 14).
diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs
index 1b8e49ac..fe7bd35b 100644
--- a/crates/botloftd/src/platform/unix.rs
+++ b/crates/botloftd/src/platform/unix.rs
@@ -103,6 +103,14 @@ pub fn delete_task(_name: &str) -> io::Result {
Err(no_tasks())
}
+/// There is no Recycle Bin off Windows: the folder stays.
+pub fn recycle(_path: &Path) -> io::Result<()> {
+ Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "the Recycle Bin is only supported on Windows",
+ ))
+}
+
/// Unix has no Windows sign-in to tell apart.
pub fn sign_in_id() -> Option {
None
diff --git a/crates/botloftd/src/platform/windows.rs b/crates/botloftd/src/platform/windows.rs
index e2645193..dddad3fc 100644
--- a/crates/botloftd/src/platform/windows.rs
+++ b/crates/botloftd/src/platform/windows.rs
@@ -6,6 +6,7 @@ mod env;
mod job;
mod owner;
mod power;
+mod recycle;
mod sign_in;
mod task;
mod task_xml;
@@ -15,6 +16,7 @@ pub use env::user_environment;
pub use job::ProcessJob;
pub use owner::owner_name;
pub use power::KeepAwake;
+pub use recycle::recycle;
pub use sign_in::sign_in_id;
pub use task::{delete_task, find_task, register_task, run_task, stop_task};
diff --git a/crates/botloftd/src/platform/windows/recycle.rs b/crates/botloftd/src/platform/windows/recycle.rs
new file mode 100644
index 00000000..3027404c
--- /dev/null
+++ b/crates/botloftd/src/platform/windows/recycle.rs
@@ -0,0 +1,218 @@
+//! Moving a folder to the Recycle Bin (spec 7.6), with the shell's
+//! `IFileOperation`.
+//!
+//! When the bin cannot take something (too big for it, a path too long, a
+//! drive without a bin) and the shell may not ask, it deletes for good. The
+//! sink below refuses exactly that, so the folder stays where it is.
+
+use std::cell::Cell;
+use std::io;
+use std::os::windows::ffi::OsStrExt;
+use std::path::Path;
+use std::rc::Rc;
+
+use windows::Win32::Foundation::E_ABORT;
+use windows::Win32::System::Com::{
+ CLSCTX_ALL, COINIT_APARTMENTTHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize,
+};
+use windows::Win32::UI::Shell::{
+ FOF_ALLOWUNDO, FOF_NO_UI, FOFX_EARLYFAILURE, FOFX_RECYCLEONDELETE, FileOperation,
+ IFileOperation, IFileOperationProgressSink, IFileOperationProgressSink_Impl, IShellItem,
+ SHCreateItemFromParsingName, TSF_DELETE_RECYCLE_IF_POSSIBLE,
+};
+use windows::core::{HRESULT, PCWSTR, Ref, Result as ComResult, implement};
+
+/// Moves `path` to the current user's Recycle Bin, or fails and leaves it
+/// where it is: nothing is ever deleted for good. `Unsupported` means the
+/// bin cannot take it; another error may pass, like a file still in use.
+pub fn recycle(path: &Path) -> io::Result<()> {
+ let path = path.to_owned();
+ // The shell's file operations need a single-threaded apartment, which
+ // a thread of the daemon's pool may not be: they get one of their own.
+ std::thread::spawn(move || on_this_thread(&path))
+ .join()
+ .map_err(|_| io::Error::other("the move to the Recycle Bin crashed"))?
+}
+
+fn on_this_thread(path: &Path) -> io::Result<()> {
+ if !path.exists() {
+ return Err(io::Error::new(
+ io::ErrorKind::NotFound,
+ "the folder is not there",
+ ));
+ }
+ // SAFETY: plain COM initialization for this thread, which is ours.
+ unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) }.ok()?;
+ let moved = delete_to_bin(path);
+ // SAFETY: pairs with the successful CoInitializeEx above; every COM
+ // object of `delete_to_bin` is released by now.
+ unsafe { CoUninitialize() };
+ moved
+}
+
+fn delete_to_bin(path: &Path) -> io::Result<()> {
+ let wide: Vec = path.as_os_str().encode_wide().chain(Some(0)).collect();
+ let refused = Rc::new(Cell::new(false));
+ let sink: IFileOperationProgressSink = Sink {
+ refused: Rc::clone(&refused),
+ }
+ .into();
+ // SAFETY: COM is initialized on this thread; `wide` is NUL-terminated
+ // and outlives the calls; the interfaces are used on this thread only.
+ let (performed, aborted) = unsafe {
+ let operation: IFileOperation = CoCreateInstance(&FileOperation, None, CLSCTX_ALL)?;
+ operation.SetOperationFlags(
+ FOF_NO_UI | FOF_ALLOWUNDO | FOFX_RECYCLEONDELETE | FOFX_EARLYFAILURE,
+ )?;
+ let item: IShellItem = SHCreateItemFromParsingName(PCWSTR(wide.as_ptr()), None)?;
+ operation.DeleteItem(&item, &sink)?;
+ let performed = operation.PerformOperations();
+ let aborted = operation.GetAnyOperationsAborted();
+ (performed, aborted)
+ };
+ if refused.get() {
+ return Err(io::Error::new(
+ io::ErrorKind::Unsupported,
+ "Windows cannot put this folder in the Recycle Bin",
+ ));
+ }
+ performed?;
+ if aborted.is_ok_and(|aborted| aborted.as_bool()) || path.exists() {
+ return Err(io::Error::other(
+ "Windows did not move the folder to the Recycle Bin",
+ ));
+ }
+ Ok(())
+}
+
+/// Stops the operation when an item would be deleted for good.
+#[implement(IFileOperationProgressSink)]
+struct Sink {
+ refused: Rc>,
+}
+
+#[allow(non_snake_case)]
+impl IFileOperationProgressSink_Impl for Sink_Impl {
+ fn PreDeleteItem(&self, dwflags: u32, _item: Ref) -> ComResult<()> {
+ // The shell sets this flag on what goes to the bin, and leaves it
+ // out on what it is about to destroy.
+ if dwflags & TSF_DELETE_RECYCLE_IF_POSSIBLE.0 as u32 == 0 {
+ self.refused.set(true);
+ return Err(E_ABORT.into());
+ }
+ Ok(())
+ }
+
+ fn StartOperations(&self) -> ComResult<()> {
+ Ok(())
+ }
+
+ fn FinishOperations(&self, _result: HRESULT) -> ComResult<()> {
+ Ok(())
+ }
+
+ fn PreRenameItem(&self, _: u32, _: Ref, _: &PCWSTR) -> ComResult<()> {
+ Ok(())
+ }
+
+ fn PostRenameItem(
+ &self,
+ _: u32,
+ _: Ref,
+ _: &PCWSTR,
+ _: HRESULT,
+ _: Ref,
+ ) -> ComResult<()> {
+ Ok(())
+ }
+
+ fn PreMoveItem(
+ &self,
+ _: u32,
+ _: Ref,
+ _: Ref,
+ _: &PCWSTR,
+ ) -> ComResult<()> {
+ Ok(())
+ }
+
+ fn PostMoveItem(
+ &self,
+ _: u32,
+ _: Ref,
+ _: Ref,
+ _: &PCWSTR,
+ _: HRESULT,
+ _: Ref,
+ ) -> ComResult<()> {
+ Ok(())
+ }
+
+ fn PreCopyItem(
+ &self,
+ _: u32,
+ _: Ref,
+ _: Ref,
+ _: &PCWSTR,
+ ) -> ComResult<()> {
+ Ok(())
+ }
+
+ fn PostCopyItem(
+ &self,
+ _: u32,
+ _: Ref,
+ _: Ref,
+ _: &PCWSTR,
+ _: HRESULT,
+ _: Ref,
+ ) -> ComResult<()> {
+ Ok(())
+ }
+
+ fn PostDeleteItem(
+ &self,
+ _: u32,
+ _: Ref,
+ _: HRESULT,
+ _: Ref,
+ ) -> ComResult<()> {
+ Ok(())
+ }
+
+ fn PreNewItem(&self, _: u32, _: Ref, _: &PCWSTR) -> ComResult<()> {
+ Ok(())
+ }
+
+ fn PostNewItem(
+ &self,
+ _: u32,
+ _: Ref,
+ _: &PCWSTR,
+ _: &PCWSTR,
+ _: u32,
+ _: HRESULT,
+ _: Ref,
+ ) -> ComResult<()> {
+ Ok(())
+ }
+
+ fn UpdateProgress(&self, _total: u32, _so_far: u32) -> ComResult<()> {
+ Ok(())
+ }
+
+ fn ResetTimer(&self) -> ComResult<()> {
+ Ok(())
+ }
+
+ fn PauseTimer(&self) -> ComResult<()> {
+ Ok(())
+ }
+
+ fn ResumeTimer(&self) -> ComResult<()> {
+ Ok(())
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/botloftd/src/platform/windows/recycle/tests.rs b/crates/botloftd/src/platform/windows/recycle/tests.rs
new file mode 100644
index 00000000..3e794e29
--- /dev/null
+++ b/crates/botloftd/src/platform/windows/recycle/tests.rs
@@ -0,0 +1,148 @@
+//! The real Recycle Bin: each test checks its folder is in it, then takes
+//! it out again, so a run leaves nothing behind.
+
+use std::path::PathBuf;
+
+use super::*;
+use crate::platform::windows::CurrentUser;
+
+/// What `canonicalize` puts before a path, which may then pass 260
+/// characters.
+const VERBATIM: &str = r"\\?\";
+
+/// Where the bin keeps what came from `original`: its `$I` record and
+/// the `$R` item itself. `None` if the bin has no such thing.
+fn in_bin(original: &Path) -> Option<(PathBuf, PathBuf)> {
+ let sid = CurrentUser::query().ok()?.sid_string().ok()?;
+ let drive = original.components().next()?.as_os_str().to_string_lossy();
+ let bin = PathBuf::from(format!(r"{drive}\$Recycle.Bin\{sid}"));
+ let wanted = original.to_string_lossy().to_lowercase();
+ for entry in std::fs::read_dir(bin).ok()?.flatten() {
+ let name = entry.file_name().to_string_lossy().into_owned();
+ let Some(rest) = name.strip_prefix("$I") else {
+ continue;
+ };
+ // Version, size, time, then the length of the path and the
+ // path itself in UTF-16 (the record of Windows 10 and later).
+ let Ok(record) = std::fs::read(entry.path()) else {
+ continue;
+ };
+ let units: Vec = record
+ .get(28..)
+ .unwrap_or_default()
+ .as_chunks::<2>()
+ .0
+ .iter()
+ .map(|pair| u16::from_le_bytes(*pair))
+ .take_while(|unit| *unit != 0)
+ .collect();
+ if String::from_utf16_lossy(&units).to_lowercase() == wanted {
+ return Some((
+ entry.path(),
+ entry.path().with_file_name(format!("$R{rest}")),
+ ));
+ }
+ }
+ None
+}
+
+/// Where a test's folder `name` goes, under `dir`, in its long form: the
+/// bin records the long path, and the temporary folder may be given in
+/// the short one (`RUNNER~1`).
+fn place(dir: &tempfile::TempDir, name: &str) -> PathBuf {
+ let long = std::fs::canonicalize(dir.path()).expect("long path");
+ let plain = long.to_string_lossy();
+ PathBuf::from(plain.strip_prefix(VERBATIM).unwrap_or(&plain)).join(name)
+}
+
+/// Takes the test's own folder out of the bin, so a test run leaves
+/// nothing behind.
+fn purge(record: &Path, item: &Path) {
+ let _ = std::fs::remove_dir_all(item);
+ let _ = std::fs::remove_file(record);
+}
+
+#[test]
+fn a_folder_goes_to_the_bin_whole_and_can_be_found_there() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let folder = place(&dir, "botloft-recycle-test");
+ std::fs::create_dir_all(folder.join("attachments")).expect("folders");
+ std::fs::write(folder.join("CLAUDE.md"), "what the bot learned").expect("file");
+ std::fs::write(folder.join("attachments").join("plan.txt"), "plan").expect("file");
+
+ recycle(&folder).expect("recycle");
+
+ assert!(!folder.exists(), "the folder left its place");
+ let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin");
+ assert_eq!(
+ std::fs::read_to_string(item.join("CLAUDE.md")).expect("kept"),
+ "what the bot learned"
+ );
+ assert!(item.join("attachments").join("plan.txt").is_file());
+ purge(&record, &item);
+}
+
+#[test]
+fn a_folder_with_a_file_in_use_stays_until_the_file_is_let_go() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let folder = place(&dir, "botloft-recycle-busy");
+ std::fs::create_dir(&folder).expect("folder");
+ let log = folder.join("session.log");
+ std::fs::write(&log, "still writing").expect("file");
+ // As a process that has not ended yet holds its files.
+ let held = std::fs::File::open(&log).expect("open");
+
+ let err = recycle(&folder).expect_err("a file is in use");
+ assert_ne!(err.kind(), io::ErrorKind::Unsupported, "worth trying again");
+ assert_eq!(
+ std::fs::read_to_string(&log).expect("kept"),
+ "still writing"
+ );
+
+ drop(held);
+ recycle(&folder).expect("recycle");
+ let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin");
+ purge(&record, &item);
+}
+
+#[test]
+fn a_missing_folder_is_an_error() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let err = recycle(&dir.path().join("nothing-here")).expect_err("nothing to move");
+ assert_eq!(err.kind(), io::ErrorKind::NotFound);
+}
+
+#[test]
+fn a_folder_with_very_long_paths_is_moved_whole_or_not_at_all() {
+ // Paths past 260 characters, as a bot's `node_modules` has. Windows
+ // 11 moves the folder whole; whatever a Windows does, nothing of it
+ // may be lost.
+ let dir = tempfile::tempdir().expect("tempdir");
+ let folder = place(&dir, "botloft-recycle-long");
+ std::fs::create_dir(&folder).expect("folder");
+ let top = PathBuf::from(format!("{VERBATIM}{}", folder.display()));
+ let mut deep = top.clone();
+ for _ in 0..8 {
+ deep.push("a-folder-name-that-is-forty-characters--");
+ }
+ std::fs::create_dir_all(&deep).expect("deep folders");
+ let file = deep.join("notes.txt");
+ std::fs::write(&file, "deep").expect("deep file");
+
+ match recycle(&folder) {
+ // Moved whole: it must be in the bin, with the deep file.
+ Ok(()) => {
+ assert!(!folder.exists());
+ let (record, item) = in_bin(&folder).expect("the folder is in the Recycle Bin");
+ let mut kept = std::fs::canonicalize(&item).expect("verbatim path");
+ kept.extend(deep.strip_prefix(&top).expect("under the folder"));
+ let kept = std::fs::read_to_string(kept.join("notes.txt")).expect("deep file");
+ assert_eq!(kept, "deep");
+ purge(&record, &item);
+ }
+ // Refused: nothing was touched.
+ Err(_) => {
+ assert_eq!(std::fs::read_to_string(&file).expect("kept"), "deep");
+ }
+ }
+}
diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs
index 9d37c7e0..b5bbc6b7 100644
--- a/crates/botloftd/src/rpc/dispatch.rs
+++ b/crates/botloftd/src/rpc/dispatch.rs
@@ -8,8 +8,8 @@ use serde_json::Value;
use super::jsonrpc::{RpcError, empty_params};
use crate::approvals;
use crate::service::{
- self, ApiResult, attachments, bots, chat, crews, delete, deliveries, files, lead, messages,
- models, modes, routines, screens, settings, tasks,
+ self, ApiResult, archive, attachments, bots, chat, crews, delete, deliveries, files, lead,
+ messages, models, modes, routines, screens, settings, tasks,
};
use crate::state::Daemon;
@@ -29,6 +29,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(lead::set_lead(daemon, parse(params)?)),
method::CREWS_ARCHIVE => reply(crews::archive(daemon, parse(params)?)),
method::CREWS_DELETE => reply(delete::crew(daemon, parse(params)?)),
+ method::ARCHIVE_LIST => reply(archive::list(daemon)),
method::BOTS_LIST => reply(bots::list(daemon, parse(params)?)),
method::BOTS_CREATE => reply(bots::create(daemon, parse(params)?)),
method::BOTS_UPDATE => reply(bots::update(daemon, parse(params)?)),
@@ -37,6 +38,8 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(models::set_model(daemon, parse(params)?)),
+ method::BOTS_SET_EFFORT => reply(models::set_effort(daemon, parse(params)?)),
+ method::BOTS_COMPACT => reply(crate::context::compact(daemon, parse(params)?)),
method::BOTS_ARCHIVE => reply(bots::archive(daemon, parse(params)?)),
method::BOTS_DELETE => reply(delete::bot(daemon, parse(params)?)),
method::BOTS_RESTART => reply(bots::restart(daemon, parse(params)?)),
diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs
index 9b1c2605..22c8f441 100644
--- a/crates/botloftd/src/rpc/mod.rs
+++ b/crates/botloftd/src/rpc/mod.rs
@@ -208,7 +208,11 @@ fn to_notification(event: &Event) -> String {
Event::CrewDeleted(crew) => (notification::CREW_DELETED, serde_json::to_value(crew)),
Event::BotChanged(bot) => (notification::BOT_CHANGED, serde_json::to_value(bot)),
Event::BotDeleted(bot) => (notification::BOT_DELETED, serde_json::to_value(bot)),
+ Event::FolderRecycled(folder) => {
+ (notification::FOLDER_RECYCLED, serde_json::to_value(folder))
+ }
Event::BotState(state) => (notification::BOT_STATE, serde_json::to_value(state)),
+ Event::BotContext(context) => (notification::BOT_CONTEXT, serde_json::to_value(context)),
Event::ChatItem(item) => (notification::CHAT_ITEM, serde_json::to_value(item)),
Event::ChatDelta(delta) => (notification::CHAT_DELTA, serde_json::to_value(delta)),
Event::MessageCreated(message) => {
diff --git a/crates/botloftd/src/runtime/fake.rs b/crates/botloftd/src/runtime/fake.rs
index c7f95b4e..48b4e3f9 100644
--- a/crates/botloftd/src/runtime/fake.rs
+++ b/crates/botloftd/src/runtime/fake.rs
@@ -168,8 +168,46 @@ impl FakeProcess {
lock(&self.input).clone()
}
- /// Every complete line written to stdin, parsed as JSON.
+ /// Every message written to stdin, parsed as JSON. What the daemon
+ /// asks the process itself (spec 9.2) is in [`Self::control_requests`].
pub fn input_lines(&self) -> Vec {
+ self.stdin_lines()
+ .into_iter()
+ .filter(|line| line["type"] != "control_request")
+ .collect()
+ }
+
+ /// Every control request written to stdin, by its `subtype`.
+ pub fn control_requests(&self) -> Vec {
+ self.stdin_lines()
+ .iter()
+ .filter(|line| line["type"] == "control_request")
+ .map(|line| {
+ line["request"]["subtype"]
+ .as_str()
+ .unwrap_or_default()
+ .to_owned()
+ })
+ .collect()
+ }
+
+ /// Answers the last control request of `subtype`, as Claude Code would.
+ pub async fn answer_control(&self, subtype: &str, response: Value) {
+ let id = self
+ .stdin_lines()
+ .iter()
+ .rev()
+ .find(|line| line["type"] == "control_request" && line["request"]["subtype"] == subtype)
+ .map(|line| line["request_id"].clone())
+ .unwrap_or_else(|| panic!("no {subtype} request on stdin"));
+ self.emit(serde_json::json!({
+ "type": "control_response",
+ "response": { "subtype": "success", "request_id": id, "response": response },
+ }))
+ .await;
+ }
+
+ fn stdin_lines(&self) -> Vec {
self.input()
.split(|byte| *byte == b'\n')
.filter(|line| !line.is_empty())
diff --git a/crates/botloftd/src/service/archive.rs b/crates/botloftd/src/service/archive.rs
new file mode 100644
index 00000000..f130158e
--- /dev/null
+++ b/crates/botloftd/src/service/archive.rs
@@ -0,0 +1,38 @@
+//! `archive.list`: what the owner archived, for the app to show and delete
+//! (spec 7.6). The other lists leave archived crews and bots out.
+
+use std::cmp::Reverse;
+use std::collections::HashMap;
+
+use botloft_core::protocol::Archive;
+
+use super::{ApiResult, bots, crews};
+use crate::state::Daemon;
+
+/// Archived crews and bots, the most recently archived first. A bot of an
+/// archived crew is listed too, so the app can count them.
+pub fn list(daemon: &Daemon) -> ApiResult {
+ let store = daemon.store();
+ let crews: HashMap<_, _> = store
+ .crews(true)?
+ .into_iter()
+ .map(|crew| (crew.id.clone(), crews::present(daemon, crew)))
+ .collect();
+ let mut bots: Vec<_> = store
+ .bots(None, true)?
+ .into_iter()
+ .filter(|bot| bot.archived_at.is_some())
+ .filter_map(|bot| {
+ let crew = crews.get(&bot.crew_id)?;
+ Some(bots::to_protocol(daemon, &store, crew, bot))
+ })
+ .collect();
+ // Ids grow with time, so they settle what was archived together.
+ bots.sort_by_key(|bot| Reverse((bot.archived_at, bot.id.clone())));
+ let mut crews: Vec<_> = crews
+ .into_values()
+ .filter(|crew| crew.archived_at.is_some())
+ .collect();
+ crews.sort_by_key(|crew| Reverse((crew.archived_at, crew.id.clone())));
+ Ok(Archive { crews, bots })
+}
diff --git a/crates/botloftd/src/service/bots.rs b/crates/botloftd/src/service/bots.rs
index 56df4fb8..f1708382 100644
--- a/crates/botloftd/src/service/bots.rs
+++ b/crates/botloftd/src/service/bots.rs
@@ -2,8 +2,8 @@
use botloft_core::ids::{BotId, CrewId};
use botloft_core::protocol::{
- Bot, BotIdParams, BotModel, BotState, BotsCreateParams, BotsListParams, BotsRestartParams,
- BotsSetPausedParams, BotsUpdateParams, Crew, PermissionMode,
+ Bot, BotEffort, BotIdParams, BotModel, BotState, BotsCreateParams, BotsListParams,
+ BotsRestartParams, BotsSetPausedParams, BotsUpdateParams, Crew, PermissionMode,
};
use botloft_core::{avatar, now_ms, slug, validate};
use botloft_store::{BotRecord, Store};
@@ -109,6 +109,8 @@ pub(crate) fn insert(
permission_mode: PermissionMode::Default,
model: new.model,
model_in_use: None,
+ effort: BotEffort::Default,
+ effort_default: None,
created_at: now_ms(),
archived_at: None,
};
@@ -209,6 +211,7 @@ pub(crate) fn to_protocol(daemon: &Daemon, store: &Store, crew: &Crew, record: B
tracing::warn!(bot = %record.id, "could not read the last activity: {err}");
None
});
+ let context = daemon.contexts.get(&record.id);
Bot {
id: record.id,
crew_id: record.crew_id,
@@ -222,6 +225,9 @@ pub(crate) fn to_protocol(daemon: &Daemon, store: &Store, crew: &Crew, record: B
permission_mode: record.permission_mode,
model: record.model,
model_in_use: record.model_in_use,
+ effort: record.effort,
+ effort_default: record.effort_default,
+ context,
state,
generation,
workspace: workspace.to_string_lossy().into_owned(),
diff --git a/crates/botloftd/src/service/delete.rs b/crates/botloftd/src/service/delete.rs
index 1efdad1e..a62a54ac 100644
--- a/crates/botloftd/src/service/delete.rs
+++ b/crates/botloftd/src/service/delete.rs
@@ -1,10 +1,15 @@
//! `bots.delete` and `crews.delete` (spec 7.6): removing a bot or a crew
//! for good, active or archived. Archiving keeps everything in the
-//! database; deleting keeps only the folders on disk (spec 5).
+//! database; deleting keeps only the folders on disk (spec 5), or sends
+//! them to the Recycle Bin when the owner asks.
use botloft_core::ids::{BotId, MessageId};
+use std::path::{Path, PathBuf};
+use std::sync::Arc;
+
use botloft_core::protocol::{
- BotDeleted, BotIdParams, Crew, CrewDeleted, CrewIdParams, Message, MessageKind, SenderKind,
+ BotDeleted, BotsDeleteParams, Crew, CrewDeleted, CrewsDeleteParams, FolderRecycled, Message,
+ MessageKind, SenderKind,
};
use botloft_store::{BotRecord, Store, TaskFilter};
use tracing::{info, warn};
@@ -13,15 +18,18 @@ use super::messages::post;
use super::tasks::UNFINISHED;
use super::{ApiResult, bots, crews, lead};
use crate::state::{Daemon, Event};
+use crate::{trash, workspace};
/// Deletes the bot: its process stops at once and its conversation,
-/// routines and tasks leave the database. Its folder stays.
-pub fn bot(daemon: &Daemon, params: BotIdParams) -> ApiResult {
+/// routines and tasks leave the database. Its folder stays, unless the
+/// owner asked for it to go to the Recycle Bin.
+pub fn bot(daemon: &Daemon, params: BotsDeleteParams) -> ApiResult {
let store = daemon.store();
let record = bots::find(&store, ¶ms.bot_id)?;
let crew = crews::find(&store, &record.crew_id)?;
let notices = task_notices(&store, &crew, &record)?;
let waiting = store.pending_approvals(&record.id)?;
+ let folder = daemon.paths.bot_workspace(&crew.slug, &record.slug);
store.delete_bot(&record.id)?;
stop(daemon, &record.id);
@@ -41,12 +49,16 @@ pub fn bot(daemon: &Daemon, params: BotIdParams) -> ApiResult {
daemon.approvals.forget(&waiting);
release(daemon, &deleted.bot_id);
info!(bot = %deleted.bot_id, "bot deleted");
+ if params.recycle_folder == Some(true) {
+ recycle(daemon, &crew, folder);
+ }
Ok(deleted)
}
/// Deletes the crew with every bot in it, archived ones too. Its folders
-/// stay.
-pub fn crew(daemon: &Daemon, params: CrewIdParams) -> ApiResult {
+/// stay, unless the owner asked for the crew's own folder to go to the
+/// Recycle Bin.
+pub fn crew(daemon: &Daemon, params: CrewsDeleteParams) -> ApiResult {
let store = daemon.store();
let crew = crews::find(&store, ¶ms.crew_id)?;
let bots = store.bots(Some(&crew.id), true)?;
@@ -58,7 +70,9 @@ pub fn crew(daemon: &Daemon, params: CrewIdParams) -> ApiResult {
for bot in &bots {
stop(daemon, &bot.id);
}
- let deleted = CrewDeleted { crew_id: crew.id };
+ let deleted = CrewDeleted {
+ crew_id: crew.id.clone(),
+ };
daemon.emit(Event::CrewDeleted(deleted.clone()));
drop(store);
daemon.approvals.forget(&waiting);
@@ -66,9 +80,39 @@ pub fn crew(daemon: &Daemon, params: CrewIdParams) -> ApiResult {
release(daemon, &bot.id);
}
info!(crew = %deleted.crew_id, bots = bots.len(), "crew deleted");
+ if params.recycle_folder == Some(true) {
+ recycle(daemon, &crew, daemon.paths.crew_dir(&crew.slug));
+ }
Ok(deleted)
}
+/// Sends `folder`, which Botloft made for a deleted bot or crew, to the
+/// Recycle Bin, and tells the apps how it went (spec 7.6). A work folder
+/// the owner chose is theirs: a folder that holds it stays.
+fn recycle(daemon: &Daemon, crew: &Crew, folder: PathBuf) {
+ let chosen = crew
+ .work_folder_chosen
+ .then(|| Path::new(&crew.work_folder));
+ // Only ever a folder under the workspaces, never the root of them.
+ let refused = if !workspace::folder::contains(&daemon.paths.workspaces_root, &folder)
+ || workspace::folder::contains(&folder, &daemon.paths.workspaces_root)
+ {
+ Some("it is not one of Botloft's folders")
+ } else if chosen.is_some_and(|chosen| workspace::folder::contains(&folder, chosen)) {
+ Some("the work folder you chose for the crew is inside it")
+ } else {
+ None
+ };
+ if let Some(reason) = refused {
+ daemon.emit(Event::FolderRecycled(FolderRecycled {
+ path: folder.to_string_lossy().into_owned(),
+ error: Some(reason.to_owned()),
+ }));
+ return;
+ }
+ trash::move_away(Arc::clone(&daemon.trash), daemon.events(), folder);
+}
+
/// Kills the bot's process and closes its browser, profile included.
fn stop(daemon: &Daemon, bot: &BotId) {
daemon.supervisor.forget(bot);
diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs
index a3fc2343..7fde3679 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 archive;
pub mod attachments;
pub mod bots;
pub mod chat;
diff --git a/crates/botloftd/src/service/models.rs b/crates/botloftd/src/service/models.rs
index 3a1b76a0..f40a3941 100644
--- a/crates/botloftd/src/service/models.rs
+++ b/crates/botloftd/src/service/models.rs
@@ -1,8 +1,11 @@
-//! A bot's model (spec 7.4): what the owner picks, and the model Claude
-//! Code reports at the start of each turn.
+//! A bot's model and effort (spec 7.4): what the owner picks, and what
+//! Claude Code reports it applies.
use botloft_core::ids::BotId;
-use botloft_core::protocol::{Bot, BotsSetModelParams};
+use botloft_core::protocol::{
+ Bot, BotEffort, BotsSetEffortParams, BotsSetModelParams, ModelEffort,
+};
+use serde_json::Value;
use tracing::{info, warn};
use super::ApiResult;
@@ -18,6 +21,10 @@ pub fn set_model(daemon: &Daemon, params: BotsSetModelParams) -> ApiResult
}
record.model = params.model;
store.update_bot(&record)?;
+ // The effort a model uses by itself is that model's: not known again
+ // until the bot runs on the new one.
+ store.set_effort_default(&record.id, None)?;
+ record.effort_default = None;
let bot = changed(daemon, &store, &crew, record);
drop(store);
info!(bot = %params.bot_id, model = params.model.as_str(), "model changed");
@@ -25,7 +32,23 @@ pub fn set_model(daemon: &Daemon, params: BotsSetModelParams) -> ApiResult
Ok(bot)
}
-/// The model id in `system/init`, so the app can name the model a bot
+/// Saves the effort; the bot restarts on it when nothing is in progress.
+pub fn set_effort(daemon: &Daemon, params: BotsSetEffortParams) -> ApiResult {
+ let store = daemon.store();
+ let (crew, mut record) = active(&store, ¶ms.bot_id)?;
+ if record.effort == params.effort {
+ return Ok(to_protocol(daemon, &store, &crew, record));
+ }
+ record.effort = params.effort;
+ store.update_bot(&record)?;
+ let bot = changed(daemon, &store, &crew, record);
+ drop(store);
+ info!(bot = %params.bot_id, effort = params.effort.as_str(), "effort changed");
+ daemon.supervisor.launch_settings_changed(¶ms.bot_id);
+ Ok(bot)
+}
+
+/// The model id Claude Code reports, so the app can name the model a bot
/// really runs on, the plan's default included.
pub(crate) fn reported(daemon: &Daemon, bot: &BotId, model: &str) {
let store = daemon.store();
@@ -42,3 +65,43 @@ pub(crate) fn reported(daemon: &Daemon, bot: &BotId, model: &str) {
record.model_in_use = Some(model.to_owned());
changed(daemon, &store, &crew, record);
}
+
+/// What the session applies, from Claude Code's answer to `get_settings`
+/// when the process starts: the model, before any turn, and the effort.
+pub(crate) fn applied(daemon: &Daemon, bot: &BotId, applied: &Value) {
+ if let Some(model) = applied["model"].as_str() {
+ reported(daemon, bot, model);
+ }
+ let level = match applied.get("effort") {
+ Some(Value::Null) => Some(ModelEffort::None),
+ Some(Value::String(level)) => level.parse().ok(),
+ _ => None,
+ };
+ let Some(level) = level else {
+ return;
+ };
+ let launched = daemon.supervisor.launched_effort(bot);
+ let store = daemon.store();
+ let Ok((crew, mut record)) = active(&store, bot) else {
+ return;
+ };
+ let default = match (level, launched) {
+ // No level applies even when one is asked for: the model takes none.
+ (ModelEffort::None, _) => Some(ModelEffort::None),
+ // Started without `--effort`: this is the model's own level.
+ (level, Some(BotEffort::Default)) => Some(level),
+ // The owner's level says nothing about the model's own, only that
+ // the model does take one.
+ _ if record.effort_default == Some(ModelEffort::None) => None,
+ _ => return,
+ };
+ if record.effort_default == default {
+ return;
+ }
+ if let Err(err) = store.set_effort_default(bot, default) {
+ warn!(bot = %bot, "could not save the model's effort: {err}");
+ return;
+ }
+ record.effort_default = default;
+ changed(daemon, &store, &crew, record);
+}
diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs
index 73742cfc..2c2e1420 100644
--- a/crates/botloftd/src/state.rs
+++ b/crates/botloftd/src/state.rs
@@ -5,8 +5,9 @@ use std::sync::{Arc, Mutex, MutexGuard};
use std::time::Instant;
use botloft_core::protocol::{
- AccountUsage, Bot, BotDeleted, BotStateChanged, BrowserAction, BrowserState, ChatDelta,
- ChatItemChanged, Crew, CrewDeleted, Delivery, Message, Routine, RoutineRun, ScreenDraft, Task,
+ AccountUsage, Bot, BotContextChanged, BotDeleted, BotStateChanged, BrowserAction, BrowserState,
+ ChatDelta, ChatItemChanged, Crew, CrewDeleted, Delivery, FolderRecycled, Message, Routine,
+ RoutineRun, ScreenDraft, Task,
};
use botloft_store::Store;
use tokio::sync::broadcast;
@@ -15,6 +16,7 @@ use crate::approvals::Approvals;
use crate::browser::{BrowserSettings, Browsers};
use crate::clock::Clock;
use crate::config::Config;
+use crate::context::Contexts;
use crate::courier::{Courier, CourierSettings};
use crate::paths::Paths;
use crate::routines::Routines;
@@ -24,6 +26,7 @@ use crate::secrets::TokenHash;
use crate::service::tasks::TaskSettings;
use crate::settings::LiveSettings;
use crate::supervisor::{Supervisor, SupervisorSettings};
+use crate::trash::Trash;
use crate::workspace::WorkspaceEnv;
/// Something that changed and every connected app should hear about.
@@ -33,7 +36,9 @@ pub enum Event {
CrewDeleted(CrewDeleted),
BotChanged(Bot),
BotDeleted(BotDeleted),
+ FolderRecycled(FolderRecycled),
BotState(BotStateChanged),
+ BotContext(BotContextChanged),
ChatItem(ChatItemChanged),
ChatDelta(ChatDelta),
MessageCreated(Message),
@@ -83,6 +88,8 @@ pub struct DaemonOptions {
pub browser: BrowserSettings,
/// What the owner changes in the app's Settings.
pub settings: LiveSettings,
+ /// Where a deleted bot's folder goes when the owner asks (spec 7.6).
+ pub trash: Arc,
}
pub struct Daemon {
@@ -97,6 +104,8 @@ pub struct Daemon {
pub routines: Routines,
pub browsers: Browsers,
pub screens: Screens,
+ pub trash: Arc,
+ pub contexts: Contexts,
/// Time for everything stored or compared with stored times.
pub clock: Arc,
store: Mutex,
@@ -132,6 +141,8 @@ impl Daemon {
Arc::clone(&options.clock),
),
screens: Screens::default(),
+ trash: options.trash,
+ contexts: Contexts::default(),
clock: options.clock,
paths: options.paths,
port: options.port,
@@ -169,6 +180,11 @@ impl Daemon {
self.events.subscribe()
}
+ /// A sender for work that outlives the call that started it.
+ pub(crate) fn events(&self) -> broadcast::Sender {
+ self.events.clone()
+ }
+
pub(crate) fn emit(&self, event: Event) {
// No receivers just means no app is connected.
let _ = self.events.send(event);
diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs
index e7dffaeb..456d03a1 100644
--- a/crates/botloftd/src/supervisor/mod.rs
+++ b/crates/botloftd/src/supervisor/mod.rs
@@ -176,6 +176,19 @@ impl Supervisor {
Ok(generation)
}
+ /// Writes a control request to the bot's stdin (spec 9.2): a question
+ /// to Claude Code itself, which starts no turn.
+ pub fn write_control(&self, bot: &BotId, line: Bytes) -> Result<(), NotRunning> {
+ let inner = self.lock();
+ let running = inner
+ .slots
+ .get(bot)
+ .filter(|slot| slot.stop.is_none())
+ .and_then(|slot| slot.running.as_ref())
+ .ok_or(NotRunning)?;
+ running.control.write(line).map_err(|_| NotRunning)
+ }
+
/// Whether `generation` is the bot's running process.
pub fn is_current(&self, bot: &BotId, generation: u64) -> bool {
self.lock()
diff --git a/crates/botloftd/src/supervisor/relaunch.rs b/crates/botloftd/src/supervisor/relaunch.rs
index f7bc29fe..a4a3d9e7 100644
--- a/crates/botloftd/src/supervisor/relaunch.rs
+++ b/crates/botloftd/src/supervisor/relaunch.rs
@@ -1,11 +1,11 @@
-//! A new launch setting reaches the bot (spec 7.4): its permission mode or
-//! its model. Claude Code only reads `--permission-mode` and `--model` when
-//! it starts, so the process restarts, resuming the conversation, as soon
-//! as nothing is in progress: at once when idle, or when the turn that is
-//! running (and its approvals) ends.
+//! A new launch setting reaches the bot (spec 7.4): its permission mode,
+//! its model or its effort. Claude Code only reads `--permission-mode`,
+//! `--model` and `--effort` when it starts, so the process restarts,
+//! resuming the conversation, as soon as nothing is in progress: at once
+//! when idle, or when the turn that is running (and its approvals) ends.
use botloft_core::ids::BotId;
-use botloft_core::protocol::PermissionMode;
+use botloft_core::protocol::{BotEffort, PermissionMode};
use tracing::warn;
use super::Supervisor;
@@ -55,6 +55,15 @@ impl Supervisor {
.map(|running| running.permission_mode)
}
+ /// The effort the running process started with.
+ pub fn launched_effort(&self, bot: &BotId) -> Option {
+ self.lock()
+ .slots
+ .get(bot)
+ .and_then(|slot| slot.running.as_ref())
+ .map(|running| running.effort)
+ }
+
/// Called when a turn or an approval ends.
pub(super) fn relaunch_if_idle(&self, bot: &BotId, slot: &mut Slot) {
if slot.restart_when_idle && !slot.has_work() && slot.stop.is_none() {
diff --git a/crates/botloftd/src/supervisor/slot.rs b/crates/botloftd/src/supervisor/slot.rs
index 78d6584f..3b947f88 100644
--- a/crates/botloftd/src/supervisor/slot.rs
+++ b/crates/botloftd/src/supervisor/slot.rs
@@ -2,7 +2,7 @@
use std::time::Duration;
-use botloft_core::protocol::{BotState, PermissionMode};
+use botloft_core::protocol::{BotEffort, BotState, PermissionMode};
use tokio::time::Instant;
use crate::runtime::ProcessControl;
@@ -29,8 +29,8 @@ pub(super) struct Slot {
pub approvals: u32,
/// While `rate_limited`: Unix ms when the limit resets.
pub limited_until: Option,
- /// A new permission mode or model waits for the running turn to end
- /// (spec 7.4).
+ /// A new permission mode, model or effort waits for the running turn
+ /// to end (spec 7.4).
pub restart_when_idle: bool,
}
@@ -95,6 +95,8 @@ pub(super) struct Running {
pub token_hash: String,
/// The `--permission-mode` it started with.
pub permission_mode: PermissionMode,
+ /// The effort it started with; `Default` is no `--effort` at all.
+ pub effort: BotEffort,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs
index 442e5cd4..f39b5d6d 100644
--- a/crates/botloftd/src/supervisor/spawn.rs
+++ b/crates/botloftd/src/supervisor/spawn.rs
@@ -13,13 +13,13 @@ use tracing::{debug, warn};
use super::slot::{Running, StopIntent};
use super::{ClaudeStatus, Inner, STABLE_AFTER, Supervisor};
-use crate::chat::StreamReader;
+use crate::chat::{StreamReader, control};
use crate::platform;
use crate::runtime::claude::Claude;
use crate::runtime::{ProcessEvent, SpawnSpec};
use crate::secrets::{self, TokenHash};
use crate::state::Daemon;
-use crate::{approvals, courier, routines, workspace};
+use crate::{approvals, context, courier, routines, workspace};
/// Tools the bot uses without asking: its crew tools (spec 7.4).
const ALLOWED_TOOLS: &str = "mcp__botloft";
@@ -63,6 +63,12 @@ impl Supervisor {
.and_then(|(spec, launch)| Ok((self.runtime.spawn(spec)?, launch)));
match launched {
Ok((process, launch)) => {
+ // What the session applies and how full it is (spec 9.2);
+ // Claude Code answers as soon as it is up.
+ for ask in [control::settings_request(), control::context_request()] {
+ let _ = process.control.write(ask);
+ }
+ context::process_started(daemon, &bot.id, launch.resumed);
// A new conversation is on disk only once it has a turn
// (spec 7.3); the one it takes the place of is left behind.
if !launch.resumed
@@ -83,6 +89,7 @@ impl Supervisor {
resumed: launch.resumed,
token_hash: launch.token_hash,
permission_mode: bot.permission_mode,
+ effort: bot.effort,
});
self.count_busy(slot.state, BotState::Launching);
slot.state = BotState::Launching;
@@ -159,6 +166,7 @@ impl Supervisor {
approvals::expire_for_bot(daemon, bot);
courier::requeue_unread(daemon, bot, generation);
routines::process_ended(daemon, bot);
+ context::process_ended(daemon, bot);
crate::screens::turn_ended(daemon, bot);
self.wake();
}
@@ -225,6 +233,11 @@ fn launch_spec(
args.push("--model".into());
args.push(model.into());
}
+ // Without the flag, Claude Code uses the level it sets for the model.
+ if let Some(effort) = bot.effort.cli_value() {
+ args.push("--effort".into());
+ args.push(effort.into());
+ }
let mut env = platform::user_environment()?;
env.retain(|(name, _)| {
diff --git a/crates/botloftd/src/trash.rs b/crates/botloftd/src/trash.rs
new file mode 100644
index 00000000..93585d6f
--- /dev/null
+++ b/crates/botloftd/src/trash.rs
@@ -0,0 +1,136 @@
+//! Where the folder of a deleted bot or crew goes when the owner asks
+//! (spec 7.6): the Recycle Bin, behind a trait so tests move no folder
+//! into a real one.
+
+use std::io;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, Mutex, PoisonError};
+use std::time::Duration;
+
+use botloft_core::protocol::FolderRecycled;
+use tokio::sync::broadcast;
+use tracing::{info, warn};
+
+use crate::platform;
+use crate::state::Event;
+
+/// How many times a folder that cannot move yet is tried, and how long
+/// between tries: a process that was just killed still holds its files.
+const TRIES: u32 = 20;
+const RETRY_EVERY: Duration = Duration::from_millis(500);
+
+pub trait Trash: Send + Sync {
+ /// Moves the folder away, where the owner can get it back, or fails
+ /// and leaves it as it is. `Unsupported` means it can never go there.
+ fn put(&self, folder: &Path) -> io::Result<()>;
+}
+
+/// The Windows Recycle Bin.
+pub struct RecycleBin;
+
+impl Trash for RecycleBin {
+ fn put(&self, folder: &Path) -> io::Result<()> {
+ platform::recycle(folder)
+ }
+}
+
+/// Moves `folder` to the trash off the async workers, trying again while
+/// something still holds it, and tells the apps how it ended
+/// (`folder.recycled`).
+pub(crate) fn move_away(trash: Arc, events: broadcast::Sender, folder: PathBuf) {
+ let work = move || {
+ let error = put_with_retries(trash.as_ref(), &folder)
+ .err()
+ .map(|err| err.to_string());
+ match &error {
+ None => info!("a deleted bot's folder went to the Recycle Bin"),
+ Some(error) => warn!("a folder did not go to the Recycle Bin: {error}"),
+ }
+ // No receivers just means no app is connected.
+ let _ = events.send(Event::FolderRecycled(FolderRecycled {
+ path: folder.to_string_lossy().into_owned(),
+ error,
+ }));
+ };
+ match tokio::runtime::Handle::try_current() {
+ Ok(runtime) => drop(runtime.spawn_blocking(work)),
+ Err(_) => work(),
+ }
+}
+
+fn put_with_retries(trash: &dyn Trash, folder: &Path) -> io::Result<()> {
+ let mut tries = 0;
+ loop {
+ tries += 1;
+ match trash.put(folder) {
+ Ok(()) => return Ok(()),
+ // Already gone: there is nothing left to move.
+ Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
+ Err(err) if err.kind() == io::ErrorKind::Unsupported || tries >= TRIES => {
+ return Err(err);
+ }
+ Err(_) => std::thread::sleep(RETRY_EVERY),
+ }
+ }
+}
+
+/// A trash for tests: folders move into `bin`, and the next tries can be
+/// made to fail.
+#[derive(Clone)]
+pub struct FakeTrash {
+ bin: PathBuf,
+ state: Arc>,
+}
+
+#[derive(Default)]
+struct FakeState {
+ moved: Vec,
+ /// Errors the next tries answer with, first to last.
+ failures: Vec,
+ tries: usize,
+}
+
+impl FakeTrash {
+ pub fn new(bin: PathBuf) -> Self {
+ Self {
+ bin,
+ state: Arc::default(),
+ }
+ }
+
+ /// The folders that were moved, in order, as they were named.
+ pub fn moved(&self) -> Vec {
+ self.lock().moved.clone()
+ }
+
+ /// How many times a move was tried.
+ pub fn tries(&self) -> usize {
+ self.lock().tries
+ }
+
+ /// The next tries fail with these, in order, before one may work.
+ pub fn fail_next(&self, failures: &[io::ErrorKind]) {
+ self.lock().failures = failures.to_vec();
+ }
+
+ fn lock(&self) -> std::sync::MutexGuard<'_, FakeState> {
+ self.state.lock().unwrap_or_else(PoisonError::into_inner)
+ }
+}
+
+impl Trash for FakeTrash {
+ fn put(&self, folder: &Path) -> io::Result<()> {
+ let mut state = self.lock();
+ state.tries += 1;
+ if !state.failures.is_empty() {
+ return Err(io::Error::new(state.failures.remove(0), "fake failure"));
+ }
+ if !folder.exists() {
+ return Err(io::Error::new(io::ErrorKind::NotFound, "no such folder"));
+ }
+ std::fs::create_dir_all(&self.bin)?;
+ std::fs::rename(folder, self.bin.join(state.moved.len().to_string()))?;
+ state.moved.push(folder.to_owned());
+ Ok(())
+ }
+}
diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs
index bbb5e4d3..7e77f149 100644
--- a/crates/botloftd/src/workspace/files.rs
+++ b/crates/botloftd/src/workspace/files.rs
@@ -7,6 +7,7 @@ use botloft_core::protocol::Crew;
use botloft_store::BotRecord;
use serde_json::{Value, json};
+use super::memory;
use crate::paths::permission_rule_path;
/// Longer than the approval timeout, so Claude Code never gives up on a
@@ -14,13 +15,19 @@ use crate::paths::permission_rule_path;
const MCP_TIMEOUT_MARGIN: Duration = Duration::from_secs(120);
/// `.claude/settings.json` (spec 7.5): keeps the bot out of the daemon's
-/// secrets. Deny rules apply even in a folder nobody trusted.
-pub fn settings_json(botloft_home: &Path) -> Value {
+/// secrets, and the owner's own Claude Code memory out of the bot. Deny
+/// rules apply even in a folder nobody trusted.
+pub fn settings_json(botloft_home: &Path, crew_dir: &Path) -> Value {
let secrets = permission_rule_path(&botloft_home.join("secrets"));
json!({
"permissions": {
"deny": [format!("Read({secrets}/**)")],
},
+ // Instruction files above the crew's folder are the owner's.
+ "claudeMdExcludes": memory::excludes_above(crew_dir),
+ // The bot's memory is the `CLAUDE.md` in its folder. Claude Code's
+ // own notes live in the owner's profile, outside that folder.
+ "autoMemoryEnabled": false,
})
}
@@ -116,6 +123,11 @@ app; the other bots of your crew send you messages too.
message lists them. Images also come inline.
- When you need a permission, the owner gets a request in the chat with
Allow and Deny. If they deny it, find another way or ask them.
+- The owner may not be technical. Every time you run a command, fill in its
+ `description` for them: one short sentence, in the language the owner
+ writes to you in, that says what the command does and why you need it,
+ in everyday words, with no code, file paths or jargon. They read it to
+ decide whether to allow the command.
## Working with your crew
@@ -175,10 +187,28 @@ mod tests {
#[test]
fn settings_deny_the_secrets_folder_and_nothing_else() {
let home = Path::new(r"C:\Users\ana\AppData\Local\Botloft");
- let settings = settings_json(home);
+ let settings = settings_json(home, Path::new("/ws/site"));
assert_eq!(
- settings,
- json!({ "permissions": { "deny": ["Read(//c/Users/ana/AppData/Local/Botloft/secrets/**)"] } })
+ settings["permissions"],
+ json!({ "deny": ["Read(//c/Users/ana/AppData/Local/Botloft/secrets/**)"] })
+ );
+ }
+
+ #[test]
+ fn settings_keep_the_owners_memory_out_of_the_bot() {
+ let settings = settings_json(Path::new("/data"), Path::new("/ws/site"));
+ assert_eq!(
+ settings["claudeMdExcludes"],
+ json!(memory::excludes_above(Path::new("/ws/site")))
+ );
+ assert_eq!(settings["claudeMdExcludes"][0], "/ws/CLAUDE.md");
+ assert_eq!(settings["autoMemoryEnabled"], false);
+ let mut keys: Vec<&String> = settings.as_object().expect("object").keys().collect();
+ keys.sort();
+ assert_eq!(
+ keys,
+ ["autoMemoryEnabled", "claudeMdExcludes", "permissions"],
+ "a new setting needs a line in spec 7.5"
);
}
@@ -220,6 +250,8 @@ mod tests {
permission_mode: botloft_core::protocol::PermissionMode::Default,
model: botloft_core::protocol::BotModel::Default,
model_in_use: None,
+ effort: botloft_core::protocol::BotEffort::Default,
+ effort_default: None,
created_at: 0,
archived_at: None,
};
@@ -230,6 +262,7 @@ mod tests {
assert!(rules.contains("Ask the owner"));
assert!(rules.contains("/ws/site/shared"));
assert!(rules.contains("attachments/"));
+ assert!(rules.contains("fill in its\n `description` for them"));
assert!(rules.contains("`browser_*` tools"));
assert!(rules.contains(r#" "#));
assert!(!rules.contains("You lead this crew"));
diff --git a/crates/botloftd/src/workspace/folder.rs b/crates/botloftd/src/workspace/folder.rs
index 6271246f..03dffbbe 100644
--- a/crates/botloftd/src/workspace/folder.rs
+++ b/crates/botloftd/src/workspace/folder.rs
@@ -62,7 +62,7 @@ fn parts(path: &Path) -> Vec {
}
/// Whether `outer` is `inner` or one of its parents.
-fn contains(outer: &Path, inner: &Path) -> bool {
+pub(crate) fn contains(outer: &Path, inner: &Path) -> bool {
let (outer, inner) = (parts(outer), parts(inner));
inner.len() >= outer.len() && inner[..outer.len()] == outer[..]
}
diff --git a/crates/botloftd/src/workspace/memory.rs b/crates/botloftd/src/workspace/memory.rs
new file mode 100644
index 00000000..2349c493
--- /dev/null
+++ b/crates/botloftd/src/workspace/memory.rs
@@ -0,0 +1,124 @@
+//! Which instruction files Claude Code loads into a bot (spec 7.5).
+//!
+//! Claude Code reads `CLAUDE.md` and its relatives from the working directory
+//! and from every directory above it. A bot's folder sits inside the owner's
+//! profile by default, so the owner's personal `.claude\CLAUDE.md` would reach
+//! every bot. `claudeMdExcludes` skips what is above the crew's folder.
+
+use std::path::Path;
+
+/// What Claude Code looks for in each directory above the working directory.
+const INSTRUCTION_FILES: [&str; 6] = [
+ "CLAUDE.md",
+ "CLAUDE.local.md",
+ "AGENTS.md",
+ ".claude/CLAUDE.md",
+ ".claude/AGENTS.md",
+ ".claude/rules/**",
+];
+
+/// `claudeMdExcludes` patterns for every instruction file in the folders
+/// above `crew_dir`. The crew's folder, the bots' folders in it and the work
+/// folder are left alone: what is there is for the bots.
+pub fn excludes_above(crew_dir: &Path) -> Vec {
+ crew_dir
+ .ancestors()
+ .skip(1)
+ .flat_map(|dir| {
+ let dir = glob_dir(dir);
+ INSTRUCTION_FILES
+ .iter()
+ .map(move |file| format!("{dir}/{file}"))
+ })
+ .collect()
+}
+
+/// A directory in the form Claude Code matches the patterns against on
+/// Windows: the path it was started with, letter case included, with either
+/// separator (`C:/Users/ana`). The `//c/...` form of permission rules does
+/// not match here (spec 19). Brackets, braces and parentheses in a folder
+/// name would be read as glob syntax, so they become `?`, any one character.
+fn glob_dir(dir: &Path) -> String {
+ let text = dir.to_string_lossy().replace('\\', "/");
+ let text = text.strip_prefix("//?/").unwrap_or(&text);
+ text.trim_end_matches('/')
+ .chars()
+ .map(|c| match c {
+ '[' | ']' | '{' | '}' | '(' | ')' | '*' | '?' => '?',
+ other => other,
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn every_folder_above_the_crew_is_covered_and_nothing_below() {
+ let patterns = excludes_above(Path::new("/ws/site"));
+ assert_eq!(
+ patterns,
+ [
+ "/ws/CLAUDE.md",
+ "/ws/CLAUDE.local.md",
+ "/ws/AGENTS.md",
+ "/ws/.claude/CLAUDE.md",
+ "/ws/.claude/AGENTS.md",
+ "/ws/.claude/rules/**",
+ "/CLAUDE.md",
+ "/CLAUDE.local.md",
+ "/AGENTS.md",
+ "/.claude/CLAUDE.md",
+ "/.claude/AGENTS.md",
+ "/.claude/rules/**",
+ ]
+ );
+ }
+
+ #[cfg(windows)]
+ #[test]
+ fn windows_paths_keep_the_drive_and_case_and_use_forward_slashes() {
+ let patterns = excludes_above(Path::new(r"C:\Users\Ana\Botloft\site"));
+ assert_eq!(patterns.len(), 4 * INSTRUCTION_FILES.len());
+ for expected in [
+ "C:/Users/Ana/Botloft/CLAUDE.md",
+ "C:/Users/Ana/.claude/CLAUDE.md",
+ "C:/Users/Ana/.claude/rules/**",
+ "C:/Users/CLAUDE.local.md",
+ "C:/AGENTS.md",
+ ] {
+ assert!(patterns.iter().any(|p| p == expected), "{expected} missing");
+ }
+ // The bot's own memory and rules, the crew's folder and its work
+ // folder must keep loading.
+ assert!(
+ patterns
+ .iter()
+ .all(|p| !p.starts_with("C:/Users/Ana/Botloft/site"))
+ );
+
+ let verbatim = excludes_above(Path::new(r"\\?\D:\bots\site"));
+ assert_eq!(verbatim[0], "D:/bots/CLAUDE.md");
+ assert_eq!(
+ verbatim.last().map(String::as_str),
+ Some("D:/.claude/rules/**")
+ );
+ }
+
+ #[test]
+ fn glob_syntax_in_a_folder_name_matches_as_any_character() {
+ let patterns = excludes_above(Path::new("/home/ana (work) [x] {y}/Botloft/site"));
+ assert!(
+ patterns
+ .iter()
+ .any(|p| p == "/home/ana ?work? ?x? ?y?/.claude/rules/**"),
+ "{patterns:?}"
+ );
+ // Characters a glob reads literally stay as they are.
+ assert_eq!(
+ glob_dir(Path::new("/a!b+c@d#e$f, g;h=i~j é")),
+ "/a!b+c@d#e$f, g;h=i~j é"
+ );
+ }
+}
diff --git a/crates/botloftd/src/workspace/mod.rs b/crates/botloftd/src/workspace/mod.rs
index 12bab010..b667eef7 100644
--- a/crates/botloftd/src/workspace/mod.rs
+++ b/crates/botloftd/src/workspace/mod.rs
@@ -4,6 +4,7 @@
mod files;
pub mod folder;
+mod memory;
use std::io;
use std::path::{Path, PathBuf};
@@ -43,7 +44,7 @@ pub fn prepare_bot(env: WorkspaceEnv<'_>, crew: &Crew, bot: &BotRecord) -> io::R
}
write_json(
&dir.join(".claude").join("settings.json"),
- &files::settings_json(&env.paths.home),
+ &files::settings_json(&env.paths.home, &env.paths.crew_dir(&crew.slug)),
)?;
write_json(
&dir.join(".botloft").join("mcp.json"),
@@ -111,6 +112,8 @@ mod tests {
permission_mode: botloft_core::protocol::PermissionMode::Default,
model: botloft_core::protocol::BotModel::Default,
model_in_use: None,
+ effort: botloft_core::protocol::BotEffort::Default,
+ effort_default: None,
created_at: 0,
archived_at: None,
};
@@ -141,6 +144,17 @@ mod tests {
serde_json::from_slice(&std::fs::read(ws.join(".claude/settings.json")).expect("read"))
.expect("valid json");
assert!(settings["permissions"]["deny"].is_array());
+ // Instruction files are skipped from the folder of every crew up,
+ // never in the crew's own folder.
+ assert_eq!(
+ settings["claudeMdExcludes"],
+ serde_json::json!(memory::excludes_above(&paths.crew_dir("site")))
+ );
+ let root = paths.workspaces_root.to_string_lossy().replace('\\', "/");
+ assert_eq!(
+ settings["claudeMdExcludes"][0],
+ format!("{root}/CLAUDE.md").as_str()
+ );
}
#[test]
diff --git a/crates/botloftd/tests/archive.rs b/crates/botloftd/tests/archive.rs
new file mode 100644
index 00000000..c062ecfc
--- /dev/null
+++ b/crates/botloftd/tests/archive.rs
@@ -0,0 +1,75 @@
+//! `archive.list` (spec 7.6): what the owner archived, which the other
+//! lists leave out, until it is deleted.
+
+mod common;
+
+use common::TestDaemon;
+use serde_json::{Value, json};
+
+async fn archive(app: &mut common::Client) -> Value {
+ app.call("archive.list", json!(null))
+ .await
+ .expect("archive")
+}
+
+fn names(list: &Value) -> Vec<&str> {
+ list.as_array()
+ .expect("list")
+ .iter()
+ .map(|item| item["name"].as_str().expect("name"))
+ .collect()
+}
+
+#[tokio::test]
+async fn the_archive_lists_what_was_archived_until_it_is_deleted() {
+ let t = TestDaemon::start().await;
+ let mut app = t.session().await;
+ let ops = app
+ .call("crews.create", json!({ "name": "Ops" }))
+ .await
+ .expect("crew");
+ let docs = app
+ .call("crews.create", json!({ "name": "Docs" }))
+ .await
+ .expect("crew");
+ let mut bots = Vec::new();
+ for (crew, name) in [(&ops, "Scout"), (&ops, "Writer"), (&docs, "Editor")] {
+ let bot = json!({ "crewId": crew["id"], "name": name, "role": "", "instructions": "" });
+ bots.push(app.call("bots.create", bot).await.expect("bot"));
+ }
+ let empty = archive(&mut app).await;
+ assert_eq!(empty, json!({ "crews": [], "bots": [] }));
+
+ app.call("bots.archive", json!({ "botId": bots[0]["id"] }))
+ .await
+ .expect("archive a bot");
+ app.call("crews.archive", json!({ "crewId": docs["id"] }))
+ .await
+ .expect("archive a crew");
+
+ let listed = archive(&mut app).await;
+ assert_eq!(names(&listed["crews"]), ["Docs"]);
+ // The archived crew's bot comes too, the latest archived first.
+ assert_eq!(names(&listed["bots"]), ["Editor", "Scout"]);
+ let scout = &listed["bots"][1];
+ assert_eq!(scout["state"], "archived");
+ assert!(scout["archivedAt"].as_i64().is_some());
+ let folder = t.paths.bot_workspace("ops", "scout");
+ assert_eq!(scout["workspace"], folder.to_string_lossy().as_ref());
+ let shared = t.paths.shared_dir("docs");
+ assert_eq!(
+ listed["crews"][0]["workFolder"],
+ shared.to_string_lossy().as_ref()
+ );
+
+ app.call("crews.delete", json!({ "crewId": docs["id"] }))
+ .await
+ .expect("delete the crew");
+ app.call("bots.delete", json!({ "botId": bots[0]["id"] }))
+ .await
+ .expect("delete the bot");
+ assert_eq!(archive(&mut app).await, empty);
+ // What was never archived is where it was.
+ let active = app.call("bots.list", json!({})).await.expect("bots");
+ assert_eq!(names(&active), ["Writer"]);
+}
diff --git a/crates/botloftd/tests/bot_effort.rs b/crates/botloftd/tests/bot_effort.rs
new file mode 100644
index 00000000..55aef595
--- /dev/null
+++ b/crates/botloftd/tests/bot_effort.rs
@@ -0,0 +1,172 @@
+//! A bot's effort (spec 7.4): the flag it starts with, the restart onto a
+//! new level once nothing is in progress, and what Claude Code reports about
+//! the level the bot's model uses by itself.
+
+mod common;
+
+use std::time::Duration;
+
+use botloft_core::protocol::{
+ Bot, BotEffort, BotModel, BotState, BotsSetEffortParams, BotsSetModelParams, ModelEffort,
+};
+use botloftd::runtime::fake::FakeProcess;
+use botloftd::service::{bots, models};
+use common::stream;
+use common::supervised::{Setup, arg_after, setup};
+use serde_json::{Value, json};
+
+fn set_effort(s: &Setup, effort: BotEffort) {
+ let params = BotsSetEffortParams {
+ bot_id: s.bot.clone(),
+ effort,
+ };
+ models::set_effort(&s.daemon, params).expect("set effort");
+}
+
+fn bot(s: &Setup) -> Bot {
+ bots::list(&s.daemon, Default::default())
+ .expect("list")
+ .into_iter()
+ .find(|bot| bot.id == s.bot)
+ .expect("bot")
+}
+
+/// Claude Code answering `get_settings` for a session on `model`.
+async fn applies(process: &FakeProcess, model: &str, effort: Value) {
+ process
+ .answer_control(
+ "get_settings",
+ json!({ "effective": {}, "applied": { "model": model, "effort": effort } }),
+ )
+ .await;
+ tokio::time::sleep(Duration::from_millis(50)).await;
+}
+
+#[tokio::test(start_paused = true)]
+async fn a_new_bot_runs_at_the_effort_of_its_model() {
+ let s = setup().await;
+ let process = s.runtime.process(1).await;
+ assert!(!process.args().iter().any(|arg| arg == "--effort"));
+ assert_eq!(bot(&s).effort, BotEffort::Default);
+ assert_eq!(bot(&s).effort_default, None);
+}
+
+#[tokio::test(start_paused = true)]
+async fn an_idle_bot_restarts_on_its_new_effort_in_the_same_conversation() {
+ let s = setup().await;
+ let first = s.runtime.process(1).await;
+ s.until(BotState::Idle).await;
+ let session = s.turn(&first).await;
+
+ set_effort(&s, BotEffort::Low);
+ let second = s.runtime.process(2).await;
+ assert!(first.killed());
+ assert_eq!(arg_after(&second, "--effort").as_deref(), Some("low"));
+ assert_eq!(arg_after(&second, "--resume"), Some(session));
+ assert_eq!(bot(&s).effort, BotEffort::Low);
+
+ // Back to the model's own level: no flag at all.
+ s.until(BotState::Idle).await;
+ set_effort(&s, BotEffort::Default);
+ let third = s.runtime.process(3).await;
+ assert!(!third.args().iter().any(|arg| arg == "--effort"));
+}
+
+#[tokio::test(start_paused = true)]
+async fn a_busy_bot_finishes_its_turn_before_changing_effort() {
+ let s = setup().await;
+ let first = s.runtime.process(1).await;
+ s.until(BotState::Idle).await;
+ s.message("work");
+ set_effort(&s, BotEffort::Max);
+ tokio::time::sleep(Duration::from_secs(30)).await;
+ assert_eq!(s.runtime.processes().len(), 1, "the turn is not cut short");
+ assert!(s.daemon.supervisor.relaunch_pending(&s.bot));
+
+ first.emit(stream::result(false)).await;
+ let second = s.runtime.process(2).await;
+ assert_eq!(arg_after(&second, "--effort").as_deref(), Some("max"));
+}
+
+#[tokio::test(start_paused = true)]
+async fn a_new_process_tells_its_model_and_the_effort_the_model_uses() {
+ let s = setup().await;
+ let process = s.runtime.process(1).await;
+ assert!(
+ process
+ .control_requests()
+ .contains(&"get_settings".to_owned())
+ );
+ applies(&process, "claude-sonnet-5-5", json!("medium")).await;
+ let seen = bot(&s);
+ assert_eq!(
+ seen.model_in_use.as_deref(),
+ Some("claude-sonnet-5-5"),
+ "known before the first turn"
+ );
+ assert_eq!(seen.effort_default, Some(ModelEffort::Medium));
+ assert_eq!(
+ seen.effort,
+ BotEffort::Default,
+ "the choice stays the owner's"
+ );
+}
+
+#[tokio::test(start_paused = true)]
+async fn the_owners_level_is_not_taken_for_the_models_own() {
+ let s = setup().await;
+ let first = s.runtime.process(1).await;
+ applies(&first, "claude-sonnet-5-5", json!("medium")).await;
+ s.until(BotState::Idle).await;
+
+ set_effort(&s, BotEffort::High);
+ let second = s.runtime.process(2).await;
+ applies(&second, "claude-sonnet-5-5", json!("high")).await;
+ assert_eq!(bot(&s).effort_default, Some(ModelEffort::Medium));
+}
+
+#[tokio::test(start_paused = true)]
+async fn a_model_without_effort_levels_is_told_and_a_new_model_is_asked_again() {
+ let s = setup().await;
+ let first = s.runtime.process(1).await;
+ s.until(BotState::Idle).await;
+ set_effort(&s, BotEffort::Low);
+ let second = s.runtime.process(2).await;
+ assert!(first.killed());
+ // Haiku applies no level, whatever the flag says.
+ applies(&second, "claude-haiku-4-5-20251001", Value::Null).await;
+ assert_eq!(bot(&s).effort_default, Some(ModelEffort::None));
+ // The same name later answers with a level: it takes one after all,
+ // and which one it would pick is not known.
+ applies(&second, "claude-haiku-5", json!("low")).await;
+ assert_eq!(bot(&s).effort_default, None);
+
+ s.until(BotState::Idle).await;
+ let params = BotsSetModelParams {
+ bot_id: s.bot.clone(),
+ model: BotModel::Opus,
+ };
+ models::set_model(&s.daemon, params).expect("set model");
+ assert_eq!(bot(&s).effort_default, None, "another model, another level");
+ let third = s.runtime.process(3).await;
+ assert_eq!(arg_after(&third, "--effort").as_deref(), Some("low"));
+ // It runs at the owner's level: the model's own stays unknown.
+ applies(&third, "claude-opus-5-5", json!("low")).await;
+ assert_eq!(bot(&s).effort_default, None);
+}
+
+#[tokio::test(start_paused = true)]
+async fn an_answer_without_the_effort_changes_nothing() {
+ let s = setup().await;
+ let process = s.runtime.process(1).await;
+ applies(&process, "claude-opus-5-5", json!("medium")).await;
+ // An older Claude Code, or a level this build does not know.
+ process
+ .answer_control(
+ "get_settings",
+ json!({ "applied": { "model": "claude-opus-5-5" } }),
+ )
+ .await;
+ applies(&process, "claude-opus-5-5", json!("ultra")).await;
+ assert_eq!(bot(&s).effort_default, Some(ModelEffort::Medium));
+}
diff --git a/crates/botloftd/tests/chat.rs b/crates/botloftd/tests/chat.rs
index e1bccd8a..62c49e41 100644
--- a/crates/botloftd/tests/chat.rs
+++ b/crates/botloftd/tests/chat.rs
@@ -80,7 +80,8 @@ async fn what_the_bot_prints_becomes_the_chat() {
assert_eq!(reply["activity"]["text"], "Running.");
let running = kind(&mut c.app, "tool").await;
assert_eq!(running["item"]["body"]["status"], "running");
- assert_eq!(running["item"]["body"]["summary"], "Run the tests");
+ assert_eq!(running["item"]["body"]["summary"], "npm test");
+ assert_eq!(running["item"]["body"]["explanation"], "Run the tests");
// The app names the tool in the owner's language.
assert_eq!(running["activity"]["text"], "Run the tests");
assert_eq!(running["activity"]["tool"], "Bash");
diff --git a/crates/botloftd/tests/command_requests.rs b/crates/botloftd/tests/command_requests.rs
new file mode 100644
index 00000000..780ebbf6
--- /dev/null
+++ b/crates/botloftd/tests/command_requests.rs
@@ -0,0 +1,146 @@
+//! Requests to run a command (spec 10.1): what the bot says the command is
+//! for goes to the owner next to the command, never in its place, and the
+//! command is kept whole.
+
+mod common;
+
+use common::bots::{Crew, two_bots};
+use common::mcp::Mcp;
+use common::stream;
+use serde_json::{Value, json};
+
+/// The next `chat.item` of `kind`.
+async fn kind(app: &mut common::Client, kind: &str) -> Value {
+ loop {
+ let changed = app.notification("chat.item").await;
+ if changed["item"]["body"]["kind"] == kind {
+ return changed;
+ }
+ }
+}
+
+/// A crew whose @lead is in a turn, ready to use tools.
+async fn working() -> Crew {
+ let mut c = two_bots().await;
+ let lead = c.app.call("bots.list", json!({})).await.expect("bots")[0].clone();
+ c.app
+ .call(
+ "messages.send",
+ json!({ "botId": lead["id"], "body": "Go" }),
+ )
+ .await
+ .expect("send");
+ let lines = c.lead_process.wait_lines(1).await;
+ let line = lines.last().expect("line");
+ c.lead_process.emit(stream::replay(line)).await;
+ c
+}
+
+/// @lead calls `tool` with `input` and Claude Code asks for permission; the
+/// decision comes out of the handle.
+async fn ask(c: &Crew, id: &str, tool: &str, input: &Value) -> tokio::task::JoinHandle {
+ c.lead_process
+ .emit(stream::tool_use(id, tool, input.clone()))
+ .await;
+ let mut mcp = Mcp::new(c.lead.addr, c.lead.token.clone());
+ let args = json!({ "tool_name": tool, "input": input, "tool_use_id": id });
+ tokio::spawn(async move { mcp.tool("permission_prompt", args).await.expect("decision") })
+}
+
+#[tokio::test]
+async fn the_bots_explanation_goes_with_the_command_and_what_runs_is_the_command() {
+ let mut c = working().await;
+ let input = json!({
+ "command": "pip install requests",
+ "description": "Instala o pacote requests, para o script acessar páginas da internet",
+ });
+ let decision = ask(&c, "toolu_1", "Bash", &input).await;
+
+ let tool = kind(&mut c.app, "tool").await;
+ assert_eq!(tool["item"]["body"]["summary"], "pip install requests");
+ assert_eq!(tool["item"]["body"]["explanation"], input["description"]);
+ let pending = kind(&mut c.app, "approval").await;
+ let body = &pending["item"]["body"];
+ assert_eq!(body["summary"], "pip install requests");
+ assert_eq!(body["explanation"], input["description"]);
+ assert_eq!(pending["activity"]["text"], input["description"]);
+
+ c.app
+ .call(
+ "approvals.answer",
+ json!({ "approvalId": body["approvalId"], "allow": true }),
+ )
+ .await
+ .expect("answer");
+ // Claude Code gets back the input it asked about, untouched.
+ let outcome = decision.await.expect("task");
+ assert_eq!(
+ outcome,
+ json!({ "behavior": "allow", "updatedInput": input })
+ );
+ // The answered request still says what the bot said.
+ let answered = kind(&mut c.app, "approval").await;
+ assert_eq!(answered["item"]["body"]["status"], "allowed");
+ assert_eq!(
+ answered["item"]["body"]["explanation"],
+ input["description"]
+ );
+}
+
+#[tokio::test]
+async fn a_command_without_an_explanation_has_none() {
+ let mut c = working().await;
+ let decision = ask(
+ &c,
+ "toolu_2",
+ "PowerShell",
+ &json!({ "command": "Get-Date" }),
+ )
+ .await;
+ let pending = kind(&mut c.app, "approval").await;
+ let body = &pending["item"]["body"];
+ assert_eq!(body["summary"], "Get-Date");
+ assert_eq!(body["explanation"], Value::Null);
+ assert_eq!(pending["activity"]["text"], "Get-Date");
+ c.app
+ .call(
+ "approvals.answer",
+ json!({ "approvalId": body["approvalId"], "allow": false }),
+ )
+ .await
+ .expect("answer");
+ assert_eq!(decision.await.expect("task")["behavior"], "deny");
+}
+
+#[tokio::test]
+async fn a_long_command_reaches_the_owner_whole() {
+ let mut c = working().await;
+ // Longer than other tools' input is kept (4 KB), as a script in a
+ // heredoc is.
+ let script = format!("python - <<'EOF'\n{}EOF", "print('hello')\n".repeat(600));
+ let input = json!({ "command": script, "description": "Gera a planilha" });
+ let decision = ask(&c, "toolu_3", "Bash", &input).await;
+ for shown in ["tool", "approval"] {
+ let item = kind(&mut c.app, shown).await["item"]["body"].clone();
+ let kept: Value = serde_json::from_str(item["input"].as_str().expect("input"))
+ .unwrap_or_else(|_| panic!("the {shown} item has the whole input"));
+ assert_eq!(kept["command"], script);
+ }
+ c.lead_process.exit(1).await;
+ decision.await.expect("task");
+}
+
+#[tokio::test]
+async fn a_command_too_long_to_keep_is_cut_and_still_explained() {
+ let mut c = working().await;
+ let script = "echo hello\n".repeat(4000);
+ let input = json!({ "command": script, "description": "Gera a planilha" });
+ let decision = ask(&c, "toolu_4", "Bash", &input).await;
+ let pending = kind(&mut c.app, "approval").await;
+ let body = &pending["item"]["body"];
+ let kept = body["input"].as_str().expect("input");
+ assert!(kept.len() <= 32 * 1024 && kept.ends_with('…'));
+ assert_eq!(body["explanation"], "Gera a planilha");
+ c.lead_process.exit(1).await;
+ decision.await.expect("task");
+}
diff --git a/crates/botloftd/tests/common/context.rs b/crates/botloftd/tests/common/context.rs
new file mode 100644
index 00000000..2e77351d
--- /dev/null
+++ b/crates/botloftd/tests/common/context.rs
@@ -0,0 +1,99 @@
+//! What the tests of spec 8.6 share: a bot's context as the app sees it,
+//! Claude Code's answers about it and the chat it leaves behind.
+
+use std::time::Duration;
+
+use botloft_core::protocol::{Bot, ChatBody, ContextUsage, NoticeCode};
+use botloftd::runtime::fake::FakeProcess;
+use botloftd::service::bots;
+use botloftd::state::Event;
+use serde_json::json;
+use tokio::sync::broadcast::Receiver;
+
+use super::supervised::Setup;
+
+pub fn bot(s: &Setup) -> Bot {
+ bots::list(&s.daemon, Default::default())
+ .expect("list")
+ .into_iter()
+ .find(|bot| bot.id == s.bot)
+ .expect("bot")
+}
+
+pub fn shown(s: &Setup) -> ContextUsage {
+ bot(s).context.expect("a known context")
+}
+
+pub async fn settle() {
+ tokio::time::sleep(Duration::from_millis(50)).await;
+}
+
+/// Claude Code answering `get_context_usage`, as seen with 2.1.284.
+pub async fn holds(process: &FakeProcess, used: u64, window: u64) {
+ process
+ .answer_control(
+ "get_context_usage",
+ json!({
+ "totalTokens": used, "maxTokens": window, "rawMaxTokens": window,
+ "autoCompactThreshold": window - 33_000, "isAutoCompactEnabled": true,
+ "percentage": used * 100 / window, "model": "claude-sonnet-5-5",
+ }),
+ )
+ .await;
+ settle().await;
+}
+
+pub fn asks(process: &FakeProcess) -> usize {
+ process
+ .control_requests()
+ .iter()
+ .filter(|subtype| *subtype == "get_context_usage")
+ .count()
+}
+
+/// The `bot.context` notifications sent so far, oldest first.
+pub fn sent(events: &mut Receiver) -> Vec> {
+ let mut contexts = Vec::new();
+ while let Ok(event) = events.try_recv() {
+ if let Event::BotContext(changed) = event {
+ contexts.push(changed.context);
+ }
+ }
+ contexts
+}
+
+pub fn notices(s: &Setup) -> Vec<(NoticeCode, String)> {
+ let items = s
+ .daemon
+ .store()
+ .chat_history(&s.bot, None, 50)
+ .expect("history");
+ items
+ .into_iter()
+ .rev()
+ .filter_map(|item| match item.body {
+ ChatBody::Notice(notice) => Some((notice.code.expect("a code"), notice.text)),
+ _ => None,
+ })
+ .collect()
+}
+
+pub fn kinds(s: &Setup) -> Vec<&'static str> {
+ let items = s
+ .daemon
+ .store()
+ .chat_history(&s.bot, None, 50)
+ .expect("history");
+ items
+ .iter()
+ .rev()
+ .map(|item| match item.body {
+ ChatBody::Inbound(_) => "inbound",
+ ChatBody::Reply(_) => "reply",
+ ChatBody::Tool(_) => "tool",
+ ChatBody::Approval(_) => "approval",
+ ChatBody::Turn(_) => "turn",
+ ChatBody::Notice(_) => "notice",
+ })
+ .collect()
+}
diff --git a/crates/botloftd/tests/common/mod.rs b/crates/botloftd/tests/common/mod.rs
index b6da920b..edd7da5c 100644
--- a/crates/botloftd/tests/common/mod.rs
+++ b/crates/botloftd/tests/common/mod.rs
@@ -5,6 +5,7 @@
pub mod bots;
pub mod browsing;
+pub mod context;
pub mod mcp;
pub mod routines;
pub mod site;
@@ -31,6 +32,7 @@ use botloftd::service::tasks::TaskSettings;
use botloftd::settings::LiveSettings;
use botloftd::state::{BotSettings, Daemon, DaemonOptions};
use botloftd::supervisor::{self, ClaudeSource, SupervisorSettings};
+use botloftd::trash::FakeTrash;
use futures_util::{SinkExt, StreamExt};
use serde_json::{Value, json};
use tempfile::TempDir;
@@ -48,6 +50,8 @@ pub struct TestDaemon {
pub daemon: Arc,
pub runtime: FakeRuntime,
pub clock: Arc,
+ /// Where folders go instead of the Recycle Bin.
+ pub trash: FakeTrash,
_dir: TempDir,
}
@@ -89,6 +93,7 @@ pub struct Parts {
pub runtime: FakeRuntime,
pub clock: Arc,
pub paths: Paths,
+ pub trash: FakeTrash,
pub dir: TempDir,
}
@@ -97,6 +102,7 @@ pub fn new_daemon(settings: SupervisorSettings) -> Parts {
let paths = Paths::new(dir.path().join("home"), dir.path().join("workspaces"));
let runtime = FakeRuntime::new();
let clock = Arc::new(ManualClock::new());
+ let trash = FakeTrash::new(dir.path().join("bin"));
let daemon = Daemon::new(DaemonOptions {
paths: paths.clone(),
port: 45710,
@@ -115,12 +121,14 @@ pub fn new_daemon(settings: SupervisorSettings) -> Parts {
// Approvals time out fast enough for a test to wait for it.
settings: LiveSettings::new(None, &Config::default())
.with_approval_wait(Duration::from_secs(3)),
+ trash: Arc::new(trash.clone()),
});
Parts {
daemon,
runtime,
clock,
paths,
+ trash,
dir,
}
}
@@ -156,6 +164,7 @@ impl TestDaemon {
daemon: parts.daemon,
runtime: parts.runtime,
clock: parts.clock,
+ trash: parts.trash,
_dir: parts.dir,
}
}
diff --git a/crates/botloftd/tests/compaction.rs b/crates/botloftd/tests/compaction.rs
new file mode 100644
index 00000000..c4b80530
--- /dev/null
+++ b/crates/botloftd/tests/compaction.rs
@@ -0,0 +1,241 @@
+//! Compacting a bot's conversation (spec 8.6): `/compact` asked for by the
+//! owner, and Claude Code compacting by itself.
+
+mod common;
+
+use botloft_core::protocol::{
+ Bot, BotIdParams, BotState, BotsRestartParams, BotsSetPausedParams, NoticeCode,
+};
+use botloftd::context;
+use botloftd::runtime::fake::FakeProcess;
+use botloftd::service::{ApiError, bots};
+use common::context::{asks, bot, holds, kinds, notices, sent, settle, shown};
+use common::stream;
+use common::supervised::{Setup, setup};
+use serde_json::{Value, json};
+
+fn compact(s: &Setup) -> Result {
+ let params = BotIdParams {
+ bot_id: s.bot.clone(),
+ };
+ context::compact(&s.daemon, params)
+}
+
+fn boundary(trigger: &str) -> Value {
+ json!({
+ "type": "system", "subtype": "compact_boundary",
+ "compact_metadata": { "trigger": trigger, "pre_tokens": 85_688, "post_tokens": 20_792 },
+ })
+}
+
+fn status(status: Value, result: Value) -> Value {
+ json!({ "type": "system", "subtype": "status", "status": status, "compact_result": result })
+}
+
+/// What Claude Code prints when `/compact` has run.
+async fn compacted(process: &FakeProcess) {
+ process.emit(status(json!("compacting"), Value::Null)).await;
+ process.emit(status(Value::Null, json!("success"))).await;
+ process.emit(stream::init("session-1")).await;
+ process.emit(boundary("manual")).await;
+ process
+ .emit(json!({
+ "type": "result", "subtype": "success", "is_error": false, "num_turns": 0,
+ "duration_ms": 12_375, "total_cost_usd": 0.03, "local_command": "compact",
+ }))
+ .await;
+}
+
+#[tokio::test(start_paused = true)]
+async fn the_owner_compacts_the_conversation() {
+ let s = setup().await;
+ let process = s.runtime.process(1).await;
+ s.until(BotState::Idle).await;
+ holds(&process, 556_000, 1_000_000).await;
+ let mut events = s.daemon.subscribe();
+
+ assert!(
+ compact(&s)
+ .expect("compact")
+ .context
+ .expect("context")
+ .compacting
+ );
+ let lines = process.input_lines();
+ assert_eq!(lines.len(), 1);
+ assert_eq!(lines[0]["type"], "user");
+ assert_eq!(lines[0]["message"]["content"][0]["text"], "/compact");
+ assert_eq!(s.state(), BotState::Busy);
+ // Asking again while it is on its way writes nothing more.
+ compact(&s).expect("compact again");
+ assert_eq!(process.input_lines().len(), 1);
+
+ compacted(&process).await;
+ s.until(BotState::Idle).await;
+ assert!(!shown(&s).compacting);
+ assert_eq!(asks(&process), 3, "after the summary and at the end");
+ holds(&process, 24_034, 1_000_000).await;
+ assert_eq!(shown(&s).used_tokens, 24_034);
+
+ let found = notices(&s);
+ assert_eq!(found.len(), 1);
+ assert_eq!(found[0].0, NoticeCode::Compacted);
+ assert_eq!(
+ kinds(&s),
+ ["notice"],
+ "a compaction is not a turn of the bot"
+ );
+ let sent = sent(&mut events);
+ assert!(
+ sent.first()
+ .expect("sent")
+ .as_ref()
+ .expect("context")
+ .compacting
+ );
+ assert!(
+ !sent
+ .last()
+ .expect("sent")
+ .as_ref()
+ .expect("context")
+ .compacting
+ );
+}
+
+#[tokio::test(start_paused = true)]
+async fn a_compaction_waits_behind_the_turn_in_progress() {
+ let s = setup().await;
+ let process = s.runtime.process(1).await;
+ s.until(BotState::Idle).await;
+ holds(&process, 300_000, 1_000_000).await;
+ s.message("work");
+ compact(&s).expect("compact");
+ assert_eq!(process.input_lines().len(), 2);
+
+ process.emit(stream::result(false)).await;
+ settle().await;
+ assert_eq!(
+ s.state(),
+ BotState::Busy,
+ "the compaction still has its turn"
+ );
+ assert!(shown(&s).compacting);
+ compacted(&process).await;
+ s.until(BotState::Idle).await;
+ assert_eq!(kinds(&s), ["turn", "notice"]);
+}
+
+#[tokio::test(start_paused = true)]
+async fn claude_code_compacting_by_itself_is_told_in_the_chat() {
+ let s = setup().await;
+ let process = s.runtime.process(1).await;
+ s.until(BotState::Idle).await;
+ holds(&process, 160_000, 200_000).await;
+ s.message("read more");
+
+ process.emit(status(json!("requesting"), Value::Null)).await;
+ settle().await;
+ assert!(!shown(&s).compacting);
+ process.emit(status(json!("compacting"), Value::Null)).await;
+ settle().await;
+ assert!(shown(&s).compacting);
+ process.emit(status(Value::Null, json!("success"))).await;
+ process.emit(boundary("auto")).await;
+ // The summary comes back as a message of its own; it is not a reply.
+ process
+ .emit(json!({ "type": "user", "isSynthetic": true, "parent_tool_use_id": null,
+ "message": { "role": "user", "content": [{ "type": "text", "text": "Summary: ..." }] } }))
+ .await;
+ settle().await;
+ assert!(!shown(&s).compacting);
+ assert_eq!(notices(&s)[0].0, NoticeCode::AutoCompacted);
+ assert_eq!(s.state(), BotState::Busy, "the turn goes on");
+
+ process.emit(stream::result(false)).await;
+ s.until(BotState::Idle).await;
+ assert_eq!(kinds(&s), ["notice", "turn"]);
+}
+
+#[tokio::test(start_paused = true)]
+async fn nothing_to_compact_is_a_notice_not_a_reply() {
+ let s = setup().await;
+ let process = s.runtime.process(1).await;
+ s.until(BotState::Idle).await;
+ holds(&process, 22_000, 200_000).await;
+ compact(&s).expect("compact");
+
+ process.emit(stream::init("session-1")).await;
+ process
+ .emit(json!({
+ "type": "assistant", "parent_tool_use_id": null,
+ "message": { "role": "assistant", "model": "",
+ "content": [{ "type": "text", "text": "Error: No messages to compact" }] },
+ "local_command_run": { "command": "compact", "args": "" },
+ "local_command_outcome": { "kind": "failed" },
+ }))
+ .await;
+ process
+ .emit(
+ json!({ "type": "result", "subtype": "success", "is_error": false,
+ "duration_ms": 129, "total_cost_usd": 0, "local_command": "compact" }),
+ )
+ .await;
+ s.until(BotState::Idle).await;
+
+ assert_eq!(
+ notices(&s),
+ [(
+ NoticeCode::CompactFailed,
+ "No messages to compact".to_owned()
+ )]
+ );
+ assert_eq!(kinds(&s), ["notice"]);
+ assert!(!shown(&s).compacting);
+}
+
+#[tokio::test(start_paused = true)]
+async fn a_bot_that_is_not_running_cannot_compact() {
+ let s = setup().await;
+ s.runtime.process(1).await;
+ s.until(BotState::Idle).await;
+ let pause = BotsSetPausedParams {
+ bot_id: s.bot.clone(),
+ paused: true,
+ };
+ bots::set_paused(&s.daemon, pause).expect("pause");
+ s.until(BotState::Offline).await;
+ assert!(matches!(compact(&s), Err(ApiError::Conflict(_))));
+}
+
+#[tokio::test(start_paused = true)]
+async fn a_process_that_ends_takes_its_compaction_and_a_new_conversation_its_size() {
+ let s = setup().await;
+ let first = s.runtime.process(1).await;
+ s.until(BotState::Idle).await;
+ s.turn(&first).await;
+ holds(&first, 556_000, 1_000_000).await;
+ compact(&s).expect("compact");
+ first.exit(1).await;
+ let second = s.runtime.process(2).await;
+ settle().await;
+ let context = shown(&s);
+ assert!(!context.compacting);
+ assert_eq!(
+ context.used_tokens, 556_000,
+ "the same conversation, resumed"
+ );
+ assert_eq!(asks(&second), 1);
+
+ s.until(BotState::Idle).await;
+ let restart = BotsRestartParams {
+ bot_id: s.bot.clone(),
+ fresh: Some(true),
+ };
+ bots::restart(&s.daemon, restart).expect("restart");
+ let third = s.runtime.process(3).await;
+ settle().await;
+ assert_eq!(bot(&s).context, None, "a new conversation");
+ holds(&third, 22_205, 1_000_000).await;
+ assert_eq!(shown(&s).used_tokens, 22_205);
+}
diff --git a/crates/botloftd/tests/context.rs b/crates/botloftd/tests/context.rs
new file mode 100644
index 00000000..7d5474fb
--- /dev/null
+++ b/crates/botloftd/tests/context.rs
@@ -0,0 +1,97 @@
+//! How full a bot's conversation is (spec 8.6): what Claude Code answers
+//! when asked, and the usage it prints while it works.
+
+mod common;
+
+use botloft_core::protocol::BotState;
+use common::context::{asks, bot, holds, sent, settle, shown};
+use common::stream;
+use common::supervised::setup;
+use serde_json::json;
+
+#[tokio::test(start_paused = true)]
+async fn a_new_process_tells_how_full_the_conversation_is() {
+ let s = setup().await;
+ let mut events = s.daemon.subscribe();
+ let process = s.runtime.process(1).await;
+ assert_eq!(asks(&process), 1, "asked as it starts");
+ assert_eq!(bot(&s).context, None, "not known until it answers");
+
+ holds(&process, 556_000, 1_000_000).await;
+ let context = shown(&s);
+ assert_eq!(
+ (context.used_tokens, context.window_tokens),
+ (556_000, 1_000_000)
+ );
+ assert_eq!(context.auto_compact_tokens, Some(967_000));
+ assert!(!context.compacting);
+ assert_eq!(sent(&mut events), vec![Some(context)]);
+}
+
+#[tokio::test(start_paused = true)]
+async fn the_size_follows_each_model_request_and_is_asked_again_when_a_turn_ends() {
+ let s = setup().await;
+ let process = s.runtime.process(1).await;
+ s.until(BotState::Idle).await;
+ holds(&process, 22_000, 200_000).await;
+ s.message("read the files");
+
+ let request = |cached: u64| {
+ json!({
+ "type": "assistant", "parent_tool_use_id": null,
+ "message": { "role": "assistant", "model": "claude-haiku-4-5-20251001", "content": [],
+ "usage": { "input_tokens": 10, "cache_creation_input_tokens": 763,
+ "cache_read_input_tokens": cached, "output_tokens": 4 } },
+ })
+ };
+ process.emit(request(45_245)).await;
+ settle().await;
+ assert_eq!(shown(&s).used_tokens, 46_018);
+ // A subagent's requests fill its own conversation, not the bot's.
+ let mut inside = request(90_000);
+ inside["parent_tool_use_id"] = json!("toolu_1");
+ process.emit(inside).await;
+ // What a command printed was not a model request.
+ let mut printed = request(0);
+ printed["message"]["model"] = json!("");
+ process.emit(printed).await;
+ settle().await;
+ assert_eq!(shown(&s).used_tokens, 46_018);
+
+ process.emit(stream::result(false)).await;
+ settle().await;
+ assert_eq!(asks(&process), 2, "asked again at the end of the turn");
+ holds(&process, 46_700, 200_000).await;
+ assert_eq!(shown(&s).used_tokens, 46_700);
+ assert_eq!(shown(&s).auto_compact_tokens, Some(167_000));
+}
+
+#[tokio::test(start_paused = true)]
+async fn an_answer_without_sizes_or_with_auto_compact_off_is_read_carefully() {
+ let s = setup().await;
+ let process = s.runtime.process(1).await;
+ process
+ .answer_control("get_context_usage", json!({ "categories": [] }))
+ .await;
+ settle().await;
+ assert_eq!(bot(&s).context, None);
+
+ process
+ .answer_control(
+ "get_context_usage",
+ json!({ "totalTokens": 1000, "maxTokens": 200_000,
+ "autoCompactThreshold": 167_000, "isAutoCompactEnabled": false }),
+ )
+ .await;
+ settle().await;
+ assert_eq!(shown(&s).auto_compact_tokens, None);
+
+ // A request this Claude Code does not know fails; nothing changes.
+ let id = "botloft-context-0";
+ process
+ .emit(json!({ "type": "control_response",
+ "response": { "subtype": "error", "request_id": id, "error": "Unsupported" } }))
+ .await;
+ settle().await;
+ assert_eq!(shown(&s).used_tokens, 1000);
+}
diff --git a/crates/botloftd/tests/delete_folders.rs b/crates/botloftd/tests/delete_folders.rs
new file mode 100644
index 00000000..6837e88b
--- /dev/null
+++ b/crates/botloftd/tests/delete_folders.rs
@@ -0,0 +1,172 @@
+//! Deleting a bot or a crew and sending its folder to the Recycle Bin
+//! (spec 7.6). The test daemon's trash is a fake: folders move into a
+//! folder of the test, never into a real bin.
+
+mod common;
+
+use std::io::ErrorKind;
+use std::path::Path;
+
+use common::{Client, TestDaemon};
+use serde_json::{Value, json};
+
+async fn crew_with_scout(app: &mut Client, work_folder: Option<&Path>) -> (Value, Value) {
+ let mut crew = json!({ "name": "Ops" });
+ if let Some(folder) = work_folder {
+ crew["workFolder"] = json!(folder.to_string_lossy());
+ }
+ let crew = app.call("crews.create", crew).await.expect("crew");
+ let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "", "instructions": "" });
+ let scout = app.call("bots.create", bot).await.expect("bot");
+ (crew, scout)
+}
+
+fn text(path: &Path) -> Value {
+ json!(path.to_string_lossy())
+}
+
+#[tokio::test]
+async fn a_deleted_bots_folder_goes_to_the_bin_when_asked() {
+ let t = TestDaemon::start().await;
+ let mut app = t.session().await;
+ let (_, scout) = crew_with_scout(&mut app, None).await;
+ let workspace = t.paths.bot_workspace("ops", "scout");
+ std::fs::write(workspace.join("CLAUDE.md"), "what the bot learned").expect("memory");
+
+ app.call(
+ "bots.delete",
+ json!({ "botId": scout["id"], "recycleFolder": true }),
+ )
+ .await
+ .expect("delete");
+ let moved = app.notification("folder.recycled").await;
+ assert_eq!(
+ moved,
+ json!({ "path": text(&workspace), "error": Value::Null })
+ );
+ assert!(!workspace.exists());
+ assert_eq!(t.trash.moved(), [workspace]);
+ // Only the bot's own folder went.
+ assert!(t.paths.shared_dir("ops").is_dir());
+}
+
+#[tokio::test]
+async fn without_asking_the_folder_stays_and_nothing_is_said() {
+ let t = TestDaemon::start().await;
+ let mut app = t.session().await;
+ let (crew, scout) = crew_with_scout(&mut app, None).await;
+ for params in [
+ json!({ "botId": scout["id"] }),
+ json!({ "botId": scout["id"], "recycleFolder": false }),
+ ] {
+ // The second call finds no bot: the first one deleted it.
+ let _ = app.call("bots.delete", params).await;
+ }
+ app.call("crews.delete", json!({ "crewId": crew["id"] }))
+ .await
+ .expect("delete the crew");
+ assert_eq!(t.trash.tries(), 0);
+ assert!(t.paths.bot_workspace("ops", "scout").is_dir());
+ assert!(t.paths.shared_dir("ops").is_dir());
+}
+
+#[tokio::test]
+async fn a_folder_still_in_use_is_tried_again() {
+ let t = TestDaemon::start().await;
+ let mut app = t.session().await;
+ let (_, scout) = crew_with_scout(&mut app, None).await;
+ t.trash.fail_next(&[ErrorKind::PermissionDenied]);
+
+ app.call(
+ "bots.delete",
+ json!({ "botId": scout["id"], "recycleFolder": true }),
+ )
+ .await
+ .expect("delete");
+ let moved = app.notification("folder.recycled").await;
+ assert_eq!(moved["error"], Value::Null);
+ assert_eq!(t.trash.tries(), 2);
+ assert!(!t.paths.bot_workspace("ops", "scout").exists());
+}
+
+#[tokio::test]
+async fn a_folder_the_bin_cannot_take_stays_and_the_app_is_told() {
+ let t = TestDaemon::start().await;
+ let mut app = t.session().await;
+ let (_, scout) = crew_with_scout(&mut app, None).await;
+ t.trash.fail_next(&[ErrorKind::Unsupported]);
+ let workspace = t.paths.bot_workspace("ops", "scout");
+
+ // The bot is deleted all the same.
+ app.call(
+ "bots.delete",
+ json!({ "botId": scout["id"], "recycleFolder": true }),
+ )
+ .await
+ .expect("delete");
+ let kept = app.notification("folder.recycled").await;
+ assert_eq!(kept["path"], text(&workspace));
+ assert_eq!(kept["error"], "fake failure");
+ assert_eq!(t.trash.tries(), 1, "no use trying again");
+ assert!(workspace.is_dir());
+ assert_eq!(
+ app.call("bots.list", json!({})).await.expect("bots"),
+ json!([])
+ );
+}
+
+#[tokio::test]
+async fn a_deleted_crews_own_folder_goes_whole() {
+ let t = TestDaemon::start().await;
+ let mut app = t.session().await;
+ // The owner's own folder, outside Botloft's: it is never moved.
+ let project = t.paths.workspaces_root.with_file_name("project");
+ let (crew, scout) = crew_with_scout(&mut app, Some(&project)).await;
+ app.call("bots.archive", json!({ "botId": scout["id"] }))
+ .await
+ .expect("archive");
+ std::fs::write(project.join("site.html"), "the owner's work").expect("work");
+ let folder = t.paths.crew_dir("ops");
+
+ app.call(
+ "crews.delete",
+ json!({ "crewId": crew["id"], "recycleFolder": true }),
+ )
+ .await
+ .expect("delete");
+ let moved = app.notification("folder.recycled").await;
+ assert_eq!(
+ moved,
+ json!({ "path": text(&folder), "error": Value::Null })
+ );
+ assert!(!folder.exists(), "bots' folders and shared went with it");
+ assert_eq!(
+ std::fs::read_to_string(project.join("site.html")).expect("kept"),
+ "the owner's work"
+ );
+}
+
+#[tokio::test]
+async fn a_folder_that_holds_the_owners_work_folder_stays() {
+ let t = TestDaemon::start().await;
+ let mut app = t.session().await;
+ // The owner chose a work folder inside the crew's own folder.
+ let inside = t.paths.crew_dir("ops").join("project");
+ let (crew, _) = crew_with_scout(&mut app, Some(&inside)).await;
+ let folder = t.paths.crew_dir("ops");
+
+ app.call(
+ "crews.delete",
+ json!({ "crewId": crew["id"], "recycleFolder": true }),
+ )
+ .await
+ .expect("delete");
+ let kept = app.notification("folder.recycled").await;
+ assert_eq!(kept["path"], text(&folder));
+ assert_eq!(
+ kept["error"],
+ "the work folder you chose for the crew is inside it"
+ );
+ assert_eq!(t.trash.tries(), 0);
+ assert!(inside.is_dir());
+}
diff --git a/crates/botloftd/tests/rpc_crews_bots.rs b/crates/botloftd/tests/rpc_crews_bots.rs
index 44e5ea4d..53cd5985 100644
--- a/crates/botloftd/tests/rpc_crews_bots.rs
+++ b/crates/botloftd/tests/rpc_crews_bots.rs
@@ -63,6 +63,16 @@ async fn create_a_crew_and_a_bot_and_get_a_ready_workspace() {
deny.starts_with("Read(//") && deny.ends_with("/secrets/**)"),
"{deny}"
);
+ // The owner's own Claude Code memory stays out of the bot (spec 7.5).
+ let crew_dir = daemon.paths.crew_dir("site-da-loja");
+ let crew_dir = crew_dir.to_string_lossy().replace('\\', "/");
+ let excludes = settings["claudeMdExcludes"].as_array().expect("excludes");
+ assert!(excludes.len() >= 6, "{excludes:?}");
+ assert!(excludes.iter().all(|pattern| {
+ let pattern = pattern.as_str().expect("pattern");
+ !pattern.contains('\\') && !pattern.starts_with(crew_dir.as_str())
+ }));
+ assert_eq!(settings["autoMemoryEnabled"], false);
let mcp: Value = serde_json::from_str(&read(ws.join(".botloft/mcp.json"))).expect("json");
assert_eq!(
mcp["mcpServers"]["botloft"]["url"],
diff --git a/crates/botloftd/tests/rpc_effort_context.rs b/crates/botloftd/tests/rpc_effort_context.rs
new file mode 100644
index 00000000..37d37e07
--- /dev/null
+++ b/crates/botloftd/tests/rpc_effort_context.rs
@@ -0,0 +1,84 @@
+//! The effort and the context over the app's protocol (spec 11.2, 11.3):
+//! `bots.setEffort`, `bots.compact` and the `bot.context` notification.
+
+mod common;
+
+use botloft_core::protocol::BotState;
+use common::TestDaemon;
+use common::bots::ready_bot;
+use serde_json::json;
+
+const CONFLICT: i64 = -32003;
+const INVALID_PARAMS: i64 = -32602;
+
+#[tokio::test]
+async fn the_app_sets_the_effort_and_compacts_the_conversation() {
+ let t = TestDaemon::start_supervised().await;
+ let mut app = t.session().await;
+ let crew = app
+ .call("crews.create", json!({ "name": "Ops" }))
+ .await
+ .expect("crew");
+ let (bot, process, _) = ready_bot(&t, &mut app, &crew, "Scout").await;
+ assert_eq!(bot["effort"], "default");
+ assert_eq!(bot["effortDefault"], json!(null));
+ assert_eq!(bot["context"], json!(null));
+
+ // Claude Code tells how full the conversation is; the app hears it.
+ process
+ .answer_control(
+ "get_context_usage",
+ json!({ "totalTokens": 556_000, "maxTokens": 1_000_000,
+ "autoCompactThreshold": 967_000, "isAutoCompactEnabled": true }),
+ )
+ .await;
+ let told = app.notification("bot.context").await;
+ assert_eq!(told["botId"], bot["id"]);
+ assert_eq!(
+ told["context"],
+ json!({ "usedTokens": 556_000, "windowTokens": 1_000_000, "autoCompactTokens": 967_000,
+ "compacting": false, "updatedAt": told["context"]["updatedAt"] })
+ );
+ let listed = app.call("bots.list", json!({})).await.expect("bots");
+ assert_eq!(listed[0]["context"]["usedTokens"], 556_000);
+
+ let compacting = app
+ .call("bots.compact", json!({ "botId": bot["id"] }))
+ .await
+ .expect("compact");
+ assert_eq!(compacting["context"]["compacting"], true);
+ assert_eq!(
+ app.notification("bot.context").await["context"]["compacting"],
+ true
+ );
+
+ let failure = app
+ .call(
+ "bots.setEffort",
+ json!({ "botId": bot["id"], "effort": "turbo" }),
+ )
+ .await
+ .expect_err("an unknown level");
+ assert_eq!(failure.code, INVALID_PARAMS);
+ let changed = app
+ .call(
+ "bots.setEffort",
+ json!({ "botId": bot["id"], "effort": "xhigh" }),
+ )
+ .await
+ .expect("set effort");
+ assert_eq!(changed["effort"], "xhigh");
+
+ app.call(
+ "bots.setPaused",
+ json!({ "botId": bot["id"], "paused": true }),
+ )
+ .await
+ .expect("pause");
+ t.until_state(&bot, BotState::Offline).await;
+ let failure = app
+ .call("bots.compact", json!({ "botId": bot["id"] }))
+ .await
+ .expect_err("a paused bot");
+ assert_eq!(failure.code, CONFLICT);
+}
diff --git a/docs/spec.md b/docs/spec.md
index 4b03193b..49ea1266 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -115,9 +115,9 @@ O instalador por usuário do Tauri põe o app em `%LOCALAPPDATA%\`, a m
Workspaces ficam num caminho curto e visível para o usuário abrir no Explorer e para reduzir estouro do limite de 260 caracteres (bots rodam `npm install`).
-**Pasta de trabalho da crew.** É onde os bots põem o que fazem para o dono e para os outros bots. Por padrão é a `shared\` da crew; o dono pode escolher outra ao criar a crew ou depois (`crews.setWorkFolder`), como uma pasta de projeto que ele já usa. O workspace de cada bot continua onde está, com a memória, as regras e os anexos dele: o daemon nunca grava nada na pasta escolhida. Cada bot recebe a pasta com `--add-dir` (7.4), e o Claude Code a trata como a pasta do bot: lê e, em "Aceitar edições", edita sem perguntar, e carrega o `CLAUDE.md` dela junto com a memória do bot. A pasta escolhida precisa ser um caminho completo e não pode ser um disco inteiro, ficar dentro da pasta de dados do Botloft ou contê-la, nem conter `workspaces_root` (com os workspaces de todos os bots). Se não existe, é criada. Trocar a pasta regrava as regras e reinicia cada bot da crew quando nada estiver em andamento, como a troca de modo (7.4). Arquivar a crew não apaga pasta nenhuma.
+**Pasta de trabalho da crew.** É onde os bots põem o que fazem para o dono e para os outros bots. Por padrão é a `shared\` da crew; o dono pode escolher outra ao criar a crew ou depois (`crews.setWorkFolder`), como uma pasta de projeto que ele já usa. O workspace de cada bot continua onde está, com a memória, as regras e os anexos dele: o daemon nunca grava nada na pasta escolhida. Cada bot recebe a pasta com `--add-dir` (7.4), e o Claude Code a trata como a pasta do bot: lê e, em "Aceitar edições", edita sem perguntar, e carrega o `CLAUDE.md` dela junto com a memória do bot. Só o da própria pasta (`CLAUDE.md`, `.claude\CLAUDE.md`, `.claude\rules\` e `CLAUDE.local.md`): numa pasta escolhida dentro de um projeto, os `CLAUDE.md` das pastas acima dela não são carregados pelo Claude Code (19). A pasta escolhida precisa ser um caminho completo e não pode ser um disco inteiro, ficar dentro da pasta de dados do Botloft ou contê-la, nem conter `workspaces_root` (com os workspaces de todos os bots). Se não existe, é criada. Trocar a pasta regrava as regras e reinicia cada bot da crew quando nada estiver em andamento, como a troca de modo (7.4). Arquivar a crew não apaga pasta nenhuma.
-**Excluir não apaga pastas.** Excluir um bot ou uma crew (7.6) tira do Botloft e deixa no disco o workspace de cada bot (memória, regras, anexos e o que ele fez), a `shared\` e a pasta que o dono escolheu: o dono as apaga no Explorer se quiser. Do disco só sai o perfil do navegador do bot (21.2), que fica na pasta de dados e não tem nada que o dono tenha feito. Como a pasta fica, o slug dela continua ocupado, e um bot novo com o mesmo nome ganha sufixo (`revisor-2`). A conversa que o Claude Code guarda por conta própria (`%USERPROFILE%\.claude\projects\`) é dele e também fica.
+**Excluir não apaga pastas.** Excluir um bot ou uma crew (7.6) tira do Botloft e, a menos que o dono peça a Lixeira na confirmação (abaixo), deixa no disco o workspace de cada bot (memória, regras, anexos e o que ele fez), a `shared\` e a pasta que o dono escolheu: a confirmação do app diz que elas ficam e mostra o caminho (15.1), e o dono as apaga no Explorer se quiser. Do disco só sai o perfil do navegador do bot (21.2), que fica na pasta de dados e não tem nada que o dono tenha feito. Como a pasta fica, o slug dela continua ocupado, e um bot novo com o mesmo nome ganha sufixo (`revisor-2`). A conversa que o Claude Code guarda por conta própria (`%USERPROFILE%\.claude\projects\`) é dele e também fica. Se o dono marca a caixa da confirmação, a pasta que o Botloft criou vai para a Lixeira do Windows, de onde ainda dá para recuperar: o workspace do bot, ou a pasta inteira da crew (`\\`, com a `shared\` e os workspaces). A pasta de trabalho que o dono escolheu nunca é movida.
Nomes de pasta de crew e bot são slugs gerados na criação e **não mudam** quando o nome de exibição muda. Slug: ASCII minúsculo com hífens, acentos transliterados (`Revisão` -> `revisao`), no máximo 32 caracteres, nunca um nome de dispositivo do Windows (`con`, `lpt1`...) e nunca `shared` para bot. Colisão ganha sufixo (`docs-2`), inclusive com slug de item arquivado ou pasta que já exista no disco.
@@ -130,8 +130,8 @@ O **handle** do bot (`@revisao`) é derivado do nome pela mesma regra, acompanha
CLAUDE.md memória viva do bot (o bot edita; o daemon só cria se não existir)
attachments\\ arquivos que o dono mandou no chat (9.5)
.claude\
- settings.json regra de negação para os segredos (gerado pelo daemon, sobrescrito a cada start)
- rules\botloft.md identidade, papel, crew, pasta de trabalho, guia de uso das tools e, no chefe, como liderar (gerado pelo daemon)
+ settings.json regra de negação para os segredos e o que o Claude Code não carrega no bot, 7.5 (gerado pelo daemon, sobrescrito a cada start)
+ rules\botloft.md identidade, papel, crew, pasta de trabalho, guia de uso das tools, como explicar cada comando ao dono (10.1) e, no chefe, como liderar (gerado pelo daemon)
.botloft\
mcp.json config MCP do bot (gerado pelo daemon)
```
@@ -241,17 +241,20 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s
--add-dir
--permission-mode
[--model ]
+ [--effort ]
--permission-prompt-tool mcp__botloft__permission_prompt
--allowedTools mcp__botloft
```
- - `--setting-sources project,local` e `--strict-mcp-config` deixam de fora hooks, skills, agents, modo de permissão e servidores MCP pessoais do dono: o bot vê o que o Botloft gera. O login da conta não é uma fonte de settings e continua valendo.
+ - `--setting-sources project,local` e `--strict-mcp-config` deixam de fora hooks, skills, agents, modo de permissão e servidores MCP pessoais do dono: o bot vê o que o Botloft gera. O login da conta não é uma fonte de settings e continua valendo. As instruções pessoais do dono (`%USERPROFILE%\.claude\CLAUDE.md`) não saem por essa flag: o Claude Code as acha subindo as pastas a partir do cwd. Quem as deixa de fora, junto com a memória automática, é o `settings.json` gerado (7.5).
- `--allowedTools mcp__botloft` libera as tools da crew sem aprovação. Uma regra `allow` no `settings.json` do projeto não bastaria: em `-p`, numa pasta que nunca passou pelo diálogo de confiança, o Claude Code não aplica as regras `allow` do projeto (documentado em `permissions`).
- Qualquer outra ferramenta que peça permissão passa pela tool de aprovação (10.1) e vira um pedido no chat.
- `--add-dir` põe a pasta de trabalho da crew (5) entre as pastas do bot. Sem ela, gravar na `shared\`, que fica fora do workspace, pedia aprovação até em "Aceitar edições" (19).
- `--permission-mode` vem do modo do bot (`Bot.permissionMode`), que o dono escolhe no chat (15.1): `default` (Manual: pergunta antes de editar, rodar comandos e usar a rede), `accept_edits` (`acceptEdits`: edita arquivos sem perguntar), `plan` (planeja e pede para seguir, 10.1), `auto` (um classificador libera o que é seguro e o resto vira pedido) e `bypass_permissions` (`bypassPermissions`: faz tudo sem perguntar, ver 13). Bot novo começa em `default`. `dontAsk` não é oferecido: nega o que não estiver liberado, e o bot não teria como pedir. Regras `deny` valem em todos os modos.
- O Claude Code só lê a flag ao iniciar e não entra em `bypassPermissions` no meio da sessão. Por isso mudar o modo reinicia o processo com `--resume`, e a conversa continua: na hora se o bot está parado, ou quando o turno em andamento e as aprovações dele terminam, sem cortar o trabalho. Um bot que ainda não teve turno não tem conversa a retomar e reinicia com `--session-id` (7.3).
- `--model` vem do modelo do bot (`Bot.model`), que o dono escolhe no chat ou ao criar o bot (15.1): `fable`, `opus`, `sonnet` ou `haiku`, os apelidos do Claude Code, que apontam sempre para a versão mais nova de cada família. `default` deixa a flag de fora, e o Claude Code usa o padrão da conta, que depende do plano (19). Bot novo começa em `default`. O Claude Code não troca de modelo sozinho conforme a tarefa. Mudar o modelo reinicia o processo como a troca de modo, e `--resume` com `--model` passa a usar o modelo novo. O `system/init` de cada turno traz o modelo em uso (`claude-opus-5-5`); o daemon o guarda em `Bot.modelInUse` para o app dizer qual é o padrão do plano. Um modelo que a conta não pode usar falha o turno com o erro `model_not_found` (8.1), e o processo continua vivo.
+ - `--effort` vem do esforço do bot (`Bot.effort`), que o dono escolhe no chat (15.1): `low`, `medium`, `high`, `xhigh` ou `max`, os níveis do Claude Code, do mais rápido ao que mais pensa. Quanto mais alto, mais o bot pensa antes de responder e mais gasta do plano. `default` deixa a flag de fora, e o Claude Code usa o nível que ele mesmo define para o modelo (19); é o recomendado. Bot novo começa em `default`. A flag vale só para a sessão e não é guardada pelo Claude Code: o daemon a passa de novo a cada start. Mudar o esforço reinicia o processo como a troca de modo e de modelo, com `--resume`, quando nada estiver em andamento. O Claude Code também aceita a troca no meio da sessão (`/effort ` como mensagem, ou um pedido de controle), mas o daemon reinicia, como faz com o modelo: a linha de comando continua sendo a única fonte do que o bot roda (19).
+ - **O que a sessão aplica.** Assim que o processo sobe, o daemon pergunta ao Claude Code, por um pedido de controle (9.2), qual modelo e qual esforço a sessão aplica. A resposta traz o modelo antes do primeiro turno (`Bot.modelInUse`, que antes só vinha no `system/init`) e o nível de esforço. Num processo que subiu sem `--effort`, esse nível é o do próprio modelo: o daemon o guarda em `Bot.effortDefault` (`low` a `max`), e o app marca esse ponto como o recomendado. Um modelo que não tem níveis de esforço (Haiku 4.5) responde sem nível, mesmo com a flag: `effortDefault` vira `none`, e o app desliga o controle. Num processo com `--effort`, a resposta só repete a escolha do dono, e o que já se sabia do modelo fica. Trocar o modelo apaga `effortDefault` até o processo novo responder. Sem resposta (um Claude Code que não conheça o pedido), fica `null` e o app só não marca o recomendado.
- O Claude Code sai do modo `plan` sozinho quando o dono aprova o plano. Cada turno começa com um `system/init` que traz `permissionMode` (19); se o processo começou em `plan`, o modo gravado ainda é `plan` e o init diz outro, o daemon grava o novo, sem reiniciar, para o app mostrar o que o bot faz e um reinício manter. Qualquer outra diferença vem de um processo que está para reiniciar no modo que o dono acabou de escolher, e é ignorada.
5. Ambiente: o bloco padrão do usuário (`CreateEnvironmentBlock`, o mesmo de um logon novo), **não** o ambiente do daemon. Um daemon iniciado de dentro de uma sessão do Claude Code herda `CLAUDECODE`, `CLAUDE_CODE_MESSAGING_SOCKET`, `ANTHROPIC_BASE_URL` e outras variáveis da sessão, que fariam o bot se achar filho dela. Por cima vão `BOTLOFT_BOT_ID`, `BOTLOFT_BOT_TOKEN`, `BOTLOFT_PORT` e `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1`, que faz o Claude Code carregar o `CLAUDE.md` da pasta de trabalho (5).
6. stdin, stdout e stderr em pipes, sem console (`CREATE_NO_WINDOW`). O stdin fica aberto enquanto o processo vive; fechá-lo encerra o Claude Code com código 0. O stderr vai para o log em nível `debug`, sem conteúdo de mensagem.
@@ -262,13 +265,30 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s
{
"permissions": {
"deny": ["Read(//c/Users//AppData/Local/Botloft/secrets/**)"]
- }
+ },
+ "claudeMdExcludes": [
+ "C:/Users//Botloft/CLAUDE.md",
+ "C:/Users//Botloft/CLAUDE.local.md",
+ "C:/Users//Botloft/AGENTS.md",
+ "C:/Users//Botloft/.claude/CLAUDE.md",
+ "C:/Users//Botloft/.claude/AGENTS.md",
+ "C:/Users//Botloft/.claude/rules/**",
+ "C:/Users//CLAUDE.md",
+ "C:/Users//.claude/CLAUDE.md"
+ ],
+ "autoMemoryEnabled": false
}
```
+(A lista de verdade repete os seis nomes para cada pasta acima da pasta da crew: `C:/Users//Botloft`, `C:/Users/`, `C:/Users` e `C:/`.)
+
- Regras `deny` valem mesmo sem confiança na pasta: elas só restringem.
- Caminho absoluto em permission rule usa o prefixo `//` e a forma POSIX que o Claude Code aplica no Windows: `C:\Users\ana\...` vira `//c/Users/ana/...` (letra do drive em minúscula). Uma barra só (`/caminho`) é relativa à origem do settings, não à raiz, e não protegeria nada. O daemon converte `BOTLOFT_HOME` para essa forma ao gerar o arquivo.
- Em `-p` o diálogo de confiança da pasta nunca aparece (documentado), então bot novo começa a trabalhar sem passo manual.
+- **Instruções de fora ficam de fora (`claudeMdExcludes`).** O Claude Code carrega `CLAUDE.md`, `CLAUDE.local.md`, `.claude/CLAUDE.md` e `.claude/rules/` do cwd e de toda pasta acima dele, como instruções de projeto, e `--setting-sources` não muda isso. O workspace fica dentro de `%USERPROFILE%` por padrão, então o `%USERPROFILE%\.claude\CLAUDE.md` do dono, que são as instruções pessoais dele para o Claude Code, chegava a todo bot; com `workspaces_root` dentro de um projeto (um daemon de dev), chegava o `CLAUDE.md` do projeto. O daemon lista, para cada pasta acima da pasta da crew, de `workspaces_root` até a raiz do disco, os seis nomes que o Claude Code procura: `CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, `.claude/CLAUDE.md`, `.claude/AGENTS.md` e `.claude/rules/**`. São caminhos exatos, sem `**` no começo: nenhum padrão alcança outra pasta.
+- **O que continua carregando:** o `CLAUDE.md` e as regras do bot (5.1); o `CLAUDE.md` e as regras da pasta de trabalho da crew, a `shared\` ou a que o dono escolheu, que chegam pelo `--add-dir` (7.4); e um `CLAUDE.md` que o dono ponha na pasta da crew (`\CLAUDE.md`), que vale para todos os bots dela. O `CLAUDE.md` de política gerenciada (`C:\Program Files\ClaudeCode\CLAUDE.md`) não pode ser excluído (documentado) e continua valendo.
+- **Forma do caminho em `claudeMdExcludes`:** o padrão é comparado com o caminho absoluto como o Claude Code o monta a partir do cwd: `C:/Users/ana/...` (ou com `\`), com a letra do drive e as maiúsculas iguais às do cwd, porque a comparação diferencia maiúsculas. A forma `//c/...` das permission rules **não** casa aqui. O daemon monta os padrões com o mesmo caminho que passa como cwd. Colchetes, chaves e parênteses num nome de pasta são sintaxe de glob e viram `?` (um caractere qualquer); espaço, acento e o resto casam como estão (19).
+- **Sem memória automática (`autoMemoryEnabled: false`).** O Claude Code tem uma memória própria, ligada por padrão: grava notas sozinho em `%USERPROFILE%\.claude\projects\\memory\` e carrega o índice delas em toda sessão. Nos bots ela fica desligada, porque a memória do bot é o `CLAUDE.md` do workspace (5.1), que o dono vê, edita e apaga junto com o bot. A memória automática fica fora do workspace, no perfil do dono, é gravada sem pedido de permissão mesmo no modo Manual, não aparece no app e sobra quando o bot é apagado; a pasta é escolhida pelo caminho do workspace ou, dentro de um repositório git, pelo repositório, e aí o bot carregava as notas que o Claude Code guardou para o próprio dono naquele repositório (19). Desligada, o prompt de sistema de cada bot também fica uns 3 mil tokens menor. O daemon não apaga nada em `%USERPROFILE%\.claude`: as notas que um bot gravou antes ficam onde estão e só deixam de ser carregadas.
### 7.6 Arquivar e excluir
@@ -282,7 +302,8 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s
- **Tasks em aberto** (`open` ou `expired`) somem com o bot, e o outro lado é avisado pelo daemon (`from Botloft`), se ainda está ativo: quem pediu uma task ao excluído lê que ela não vai ser feita; quem fazia uma task para o excluído lê que ninguém mais espera o resultado. Um `complete_task` dela responde que a task não existe.
- **Excluir uma crew** exclui todos os bots dela, ativos e arquivados, com tudo acima, e a crew. Ninguém é avisado: não sobra ninguém.
- **Avisos ao app:** `bot.deleted {botId, crewId}` e `crew.deleted {crewId}` (11.3). O app tira do store o bot (ou a crew com os bots) e o que era dele: rotinas, tasks, deliveries e o navegador. As outras coisas que mudam saem pelos avisos de sempre (`crew.changed` sem chefe, `message.created` dos avisos de task).
-- **Próximos passos:** Excluir nos menus do app, com uma confirmação que diz o que sai e o que fica; nela, uma caixa para mandar a pasta para a Lixeira; e uma lista dos arquivados, que hoje o app não mostra.
+- **No app** (15.1): Excluir fica no menu do bot e no menu da crew, depois de Arquivar, com uma confirmação que diz o que sai e o que fica. Os arquivados, que o resto do app não mostra, ficam em Configurações > Arquivados, cada um com o seu Excluir; a lista vem de `archive.list` (11.2).
+- **Lixeira, se o dono pedir:** `recycleFolder: true` em `bots.delete` manda o workspace do bot para a Lixeira do Windows; em `crews.delete`, a pasta da crew inteira (`\\`: a `shared\` e o workspace de cada bot, também dos arquivados e dos excluídos antes). Só vai uma pasta que esteja dentro de `workspaces_root`, nunca a própria raiz, e nunca uma que contenha a pasta de trabalho escolhida pelo dono (5): nesse caso nada é movido. O método responde na hora, como sem a opção; a pasta vai depois, fora dos workers do servidor, porque o processo recém-morto ainda segura arquivos por um instante: até 20 tentativas, uma a cada 500 ms. Quando termina, o daemon avisa com `folder.recycled {path, error}`: `error: null` se a pasta está na Lixeira (ou já não existia), senão o motivo de ela ter ficado. Nada é apagado de vez: o que a Lixeira não pode receber fica onde está, sem nova tentativa (14).
## 8. Chat
@@ -300,11 +321,15 @@ Uma linha JSON por evento, em UTF-8. Linha que não é JSON, ou maior que 8 MiB,
| `result` com `No conversation found` em `errors` | o Claude Code não achou a conversa a retomar e vai sair: não é um turno, não vira item nem fecha turno pendente; o próximo start começa conversa nova (7.3) |
| `stream_event` com `text_delta` | texto ao vivo da resposta (`chat.delta`, 8.3); não é gravado |
| `assistant`, bloco `text` | item `reply` com o texto (markdown) |
-| `assistant`, bloco `tool_use` | item `tool` em `running`, com resumo da entrada |
+| `assistant`, bloco `tool_use` | item `tool` em `running`, com resumo da entrada e, num comando, a explicação do bot (10.1) |
| `user`, bloco `tool_result` | atualiza o item `tool` do mesmo `tool_use_id`: `done` ou `failed` e um trecho da saída |
| `assistant` com `error` | item `notice` e, conforme o erro, estado `rate_limited` ou `auth_error` (7.2); `model_not_found` vira o aviso `model_unavailable` |
| `rate_limit_event` | uso da conta (janelas de 5 h e 7 dias) em `system.status.usage`; status diferente de `allowed` leva a `rate_limited` |
-| `result` | item `turn` com duração, custo e erro (se houve); fecha um turno pendente |
+| `result` | item `turn` com duração, custo e erro (se houve); fecha um turno pendente. O daemon pergunta de novo o tamanho da conversa (8.6). O `result` de uma compactação (`local_command: "compact"`) fecha o turno sem item `turn` |
+| `assistant` com `usage` | quanto a conversa ocupa naquele pedido ao modelo (8.6) |
+| `system/status` | `status: "compacting"`: o Claude Code começou a compactar a conversa; com `compact_result`, terminou (8.6) |
+| `system/compact_boundary` | a conversa foi compactada: aviso `compacted` ou `auto_compacted` no chat (8.6) |
+| `control_response` | resposta a um pedido de controle do daemon (9.2): o que a sessão aplica (7.4) ou o tamanho da conversa (8.6) |
Blocos `thinking` não são gravados nem mostrados.
@@ -316,13 +341,13 @@ Todo item tem `id` (`cht_`), `botId`, `kind`, `createdAt` e `updatedAt`.
|---|---|---|
| `inbound` | `message` (a `Message`, com anexos) | criado junto com a message para o bot: dono, outro bot ou daemon |
| `reply` | `text` | texto do bot |
-| `tool` | `toolUseId`, `name`, `summary`, `input`, `status` (`running`, `done`, `failed`), `output`, `file` | ferramenta usada pelo bot; `file` é o caminho completo que uma chamada de `Write`, `Edit`, `MultiEdit` ou `NotebookEdit` altera (ausente nas outras e nos itens antigos) |
-| `approval` | `approvalId`, `toolName`, `summary`, `input`, `status` (`pending`, `allowed`, `denied`, `expired`), `note` | pedido de permissão (10.1) |
+| `tool` | `toolUseId`, `name`, `summary`, `explanation`, `input`, `status` (`running`, `done`, `failed`), `output`, `file` | ferramenta usada pelo bot; `explanation` é o que o bot diz que um comando faz (10.1; ausente nas outras ferramentas, quando o bot não disse nada e nos itens antigos); `file` é o caminho completo que uma chamada de `Write`, `Edit`, `MultiEdit` ou `NotebookEdit` altera (ausente nas outras e nos itens antigos) |
+| `approval` | `approvalId`, `toolName`, `summary`, `explanation`, `input`, `status` (`pending`, `allowed`, `denied`, `expired`), `note` | pedido de permissão (10.1); `explanation` como no item `tool`, guardada só no item do chat |
| `turn` | `durationMs`, `costUsd`, `error` | fim de um turno |
-| `notice` | `level` (`info`, `warning`, `error`), `code` (`signed_out`, `usage_limit`, `turn_failed`, `model_unavailable`; ausente em avisos antigos), `text` | avisos do daemon: limite de uso, login, turno com erro. O app escreve os avisos com `code` no idioma do dono; `text` fica em inglês para quem não conhece o código e, em `turn_failed`, traz o detalhe do erro |
+| `notice` | `level` (`info`, `warning`, `error`), `code` (`signed_out`, `usage_limit`, `turn_failed`, `model_unavailable`, `compacted`, `auto_compacted`, `compact_failed`; ausente em avisos antigos), `text` | avisos do daemon: limite de uso, login, turno com erro, conversa compactada (8.6). O app escreve os avisos com `code` no idioma do dono; `text` fica em inglês para quem não conhece o código e, em `turn_failed` e `compact_failed`, traz o detalhe do erro |
-- `summary` é um trecho curto tirado da entrada, sem palavras do daemon: o comando do `Bash`, o arquivo do `Read`/`Edit`/`Write`, o padrão do `Grep`/`Glob`, a URL do `WebFetch`, a busca do `WebSearch`, o destinatário do `send_message` (`@writer`), a tecla do `browser_press`. Fica vazio onde só palavras diriam algo (`TodoWrite`, `complete_task`, `ToolSearch` que carrega ferramentas, a direção do `browser_scroll`) e onde o trecho não diz nada ao dono (a `ref` de um elemento do navegador). O app escreve o nome da ferramenta como uma ação no idioma do dono ("Mandar uma mensagem", "Abrir uma página"; uma ferramenta que ele não conhece aparece pelo nome) e, nas que ficam vazias, o que der para dizer a partir da entrada (as ferramentas carregadas, a direção da rolagem).
-- `input` guarda o JSON da entrada até 4 KB; `output`, até 8 KB de texto. O resto fica só no transcript do próprio Claude Code.
+- `summary` é um trecho curto tirado da entrada, sem palavras do daemon: o comando do `Bash` e do `PowerShell` (o comando mesmo, nunca o que o bot diz dele: isso é a `explanation`), o arquivo do `Read`/`Edit`/`Write`, o padrão do `Grep`/`Glob`, a URL do `WebFetch`, a busca do `WebSearch`, o destinatário do `send_message` (`@writer`), a tecla do `browser_press`. Fica vazio onde só palavras diriam algo (`TodoWrite`, `complete_task`, `ToolSearch` que carrega ferramentas, a direção do `browser_scroll`) e onde o trecho não diz nada ao dono (a `ref` de um elemento do navegador). O app escreve o nome da ferramenta como uma ação no idioma do dono ("Mandar uma mensagem", "Abrir uma página"; uma ferramenta que ele não conhece aparece pelo nome) e, nas que ficam vazias, o que der para dizer a partir da entrada (as ferramentas carregadas, a direção da rolagem).
+- `input` guarda o JSON da entrada até 4 KB (a de um comando, até 32 KB, para o dono ler inteiro o que permite, 10.1); `output`, até 8 KB de texto. O resto fica só no transcript do próprio Claude Code.
- A resposta do bot (`reply`) é guardada inteira.
- Uma message de um bot para outro aparece duas vezes: no chat de quem mandou, como o item `tool` do `send_message`; no chat de quem recebe, como `inbound`.
@@ -344,6 +369,41 @@ O app mostra os arquivos que o bot fez num painel ao lado do chat (15.1). O daem
Itens do chat são dado pessoal como o corpo das messages: nunca vão para o log em nível `info` ou acima. O log de `debug` registra só tipos de evento e ids.
+### 8.6 Contexto da conversa
+
+A conversa de um bot tem um tamanho máximo, a janela de contexto do modelo (1 milhão de tokens em Fable, Opus e Sonnet; 200 mil em Haiku). Tudo o que o bot leu e escreveu conta, e cada pedido ao modelo manda a conversa inteira: uma conversa cheia gasta mais do plano a cada turno. Perto do fim da janela, o Claude Code **compacta** a conversa sozinho: troca o que veio antes por um resumo e segue. O dono vê quanto da janela está em uso e pode compactar antes.
+
+**Quem conta é o Claude Code.** O daemon pergunta, por um pedido de controle (9.2, `get_context_usage`), e guarda a resposta em memória, por bot:
+
+| Campo de `ContextUsage` | De onde vem |
+|---|---|
+| `usedTokens` | `totalTokens`: o que a conversa ocupa agora, com as instruções e as ferramentas do Claude Code (uns 20 mil tokens numa conversa vazia) |
+| `windowTokens` | `maxTokens`: o máximo que cabe |
+| `autoCompactTokens` | `autoCompactThreshold`: onde o Claude Code compacta sozinho (967 mil numa janela de 1 milhão, 167 mil numa de 200 mil); `null` se `isAutoCompactEnabled` vier `false` |
+| `compacting` | há uma compactação em andamento, ou pedida e esperando o turno atual terminar |
+| `updatedAt` | quando o daemon soube |
+
+- **Quando pergunta:** assim que o processo sobe (uma conversa retomada já responde com o tamanho dela, antes de qualquer turno), a cada `result` e depois de uma compactação. A resposta chega em um ou dois segundos, também no meio de um turno, e não abre turno nem entra na conversa.
+- **Durante o turno:** cada `assistant` da conversa principal traz o `usage` do pedido ao modelo; `input_tokens + cache_creation_input_tokens + cache_read_input_tokens` é o que a conversa ocupava naquele pedido, e o daemon atualiza `usedTokens` com isso. Só vale com a janela já conhecida por uma resposta. Subagentes (`parent_tool_use_id`) e respostas de comando (`model: ""`) não contam.
+- **Nada é gravado.** Um daemon novo pergunta de novo quando os bots sobem. Um bot parado mostra o último valor que o daemon viu, ou nada. Conversa nova (`bots.restart {fresh: true}`) apaga o valor até o processo responder.
+- Um Claude Code que não conheça o pedido responde com erro, e `Bot.context` fica `null`: o app não mostra o contexto.
+- O app recebe `Bot.context` na lista e `bot.context {botId, context}` a cada mudança (11.3).
+
+**Compactar agora.** `bots.compact {botId}` escreve `/compact` no stdin do processo, como uma mensagem (9.2). O Claude Code trata a linha como o comando e compacta; nada vai para o modelo como pedido do dono. Como toda mensagem, ela espera o turno em andamento terminar e conta como um turno: o bot fica `busy` até o `result`. O bot precisa estar `idle`, `busy` ou `needs_approval`; senão, `conflict`. Com uma compactação já a caminho, o pedido não escreve de novo.
+
+O que o Claude Code imprime (19), e o que o daemon faz:
+
+1. `system/status` com `status: "compacting"`: `compacting` passa a `true` (também quando é o próprio Claude Code que decide compactar, no meio de um turno).
+2. `system/status` com `compact_result` e `system/compact_boundary` (`compact_metadata.trigger`: `manual` ou `auto`): `compacting` volta a `false`, o chat ganha um aviso `info` (`compacted` para `manual`, `auto_compacted` para `auto`) e o daemon pergunta o novo tamanho.
+3. Um `user` com `isSynthetic` traz o resumo. Não é resposta do bot nem vai para o chat.
+4. No `/compact`, um `result` com `local_command: "compact"` fecha o turno. Não vira item `turn`: o aviso já diz o que houve. Na compactação automática não há `result` próprio; o turno segue.
+5. Se não havia o que compactar, em vez de 1 a 3 vem um `assistant` com `local_command_run.command: "compact"` e `local_command_outcome.kind: "failed"`. Vira um aviso `warning` com `compact_failed` e o motivo, não uma resposta.
+6. Se o processo morrer no meio, `compacting` volta a `false`.
+
+Os três avisos não mudam a linha da conversa na barra lateral (`lastActivity`): compactar é manutenção, não novidade da conversa.
+
+A compactação custa um pedido ao modelo com a conversa inteira (barato com o cache quente, 19) e depois cada turno manda só o resumo. O dono também pode escrever `/compact` no chat: a linha segue como qualquer mensagem dele (9.3), e o efeito é o mesmo.
+
## 9. Mensagens e entrega
### 9.1 Fluxo
@@ -375,6 +435,15 @@ Uma linha JSON por mensagem, terminada em `\n`, em UTF-8:
- Blocos: um `text` com o texto (9.3) e, para imagens anexadas, blocos `image` com `source: {type: "base64", media_type, data}`.
- Mensagem escrita durante um turno entra na fila do Claude Code e vira o turno seguinte (visto com 2.1.284). O courier não precisa esperar o bot ficar parado.
- O formato de entrada do `stream-json` **não é documentado**; foi verificado com teste real (seção 19).
+- Um texto que começa com `/` e o nome de um comando do Claude Code é executado como comando, não enviado ao modelo. O daemon usa isso para compactar (`/compact`, 8.6).
+
+**Pedidos de controle.** Pelo mesmo stdin, o daemon pergunta ao próprio Claude Code sobre a sessão, fora da conversa:
+
+```json
+{"type":"control_request","request_id":"botloft-context-7","request":{"subtype":"get_context_usage"}}
+```
+
+A resposta sai no stdout como `{"type":"control_response","response":{"subtype":"success","request_id":"...","response":{...}}}`, ou com `subtype: "error"` para um pedido desconhecido. Não abre turno, não muda o estado do bot, não fica no transcript e é respondido também durante um turno. É o protocolo que o Agent SDK usa por baixo: `get_context_usage` é o método `getContextUsage()` (documentado, com os campos da resposta); `get_settings` e o formato no fio não são documentados e foram vistos com teste real (19). O daemon só **pergunta** por esse caminho, e o que ele não conseguir saber fica vazio no app, sem mudar o que o bot faz: `get_settings` (7.4) e `get_context_usage` (8.6). O `request_id` começa pelo que foi perguntado e termina num contador, e é por ele que o daemon sabe de que é a resposta.
### 9.3 Texto que o bot recebe
@@ -465,6 +534,13 @@ Com `--permission-prompt-tool mcp__botloft__permission_prompt`, toda ferramenta
"Permitir sempre" (gravar uma regra para o bot) fica para depois do MVP.
+**Explicação do comando.** Um pedido para rodar um comando mostrava só o comando, e quem não lê comandos permitia sem saber o que era. A entrada do `Bash` e do `PowerShell` traz, ao lado de `command`, um campo opcional `description`, escrito pelo modelo, que chega igual à tool de aprovação (19). O daemon o passa adiante como `explanation` do item `tool` e do item `approval` (8.2): uma linha, até 300 caracteres; ausente se o campo não veio, veio vazio ou só repete o comando.
+
+- **Quem escreve é o bot.** A explicação vem antes do comando, nunca no lugar dele. O cartão (15.1) a mostra como texto puro, sem markdown nem links, diz que foi o bot que escreveu e que o que roda é o comando, e deixa o comando a um clique, inteiro e com as quebras de linha. Para isso a entrada de um comando é guardada até 32 KB; um comando maior aparece cortado, com o aviso de que não coube. O que o daemon devolve ao Claude Code é a entrada original, a mesma que o dono pôde abrir.
+- **Sem explicação**, o cartão diz que o bot não disse para que serve o comando, sugere negar e perguntar, e já vem com o comando aberto. O campo é opcional e o modelo às vezes o deixa de fora (19), então esse caso continua existindo.
+- **Regras do bot** (5.1): todo comando leva uma `description` para o dono, que pode não ser técnico: uma frase curta, na língua em que o dono escreve para o bot, dizendo o que o comando faz e por quê, em palavras do dia a dia, sem código, caminhos nem jargão. Sem a regra a descrição vinha curta e técnica, e às vezes em inglês (19). O daemon não conhece o idioma do app; quem acerta a língua é o bot, pela conversa.
+- **Outras ferramentas** não têm explicação: a entrada delas não traz um texto escrito para quem lê. O pedido mostra a ação no idioma do dono (15.6) e o `summary` (o arquivo, a URL), com a entrada inteira em "Detalhes". Um pedido para escrever ou editar um arquivo mostra o nome do arquivo, e uma tela HTML aparece em rascunho na área de design antes do OK (22.3); plano, sugestão de bot e navegador têm cartões próprios. A `description` do `Agent` é o título do trabalho do ajudante e continua sendo o `summary`.
+
**Plano.** No modo `plan`, o bot pede para seguir com a ferramenta `ExitPlanMode`, cuja entrada traz o plano em markdown (`{plan}`). O pedido passa pela mesma tool e vira no chat um cartão com o plano inteiro: a entrada dessa ferramenta é guardada até 32 KB (as outras, até 4 KB) e o resumo é a primeira linha do plano. "Aprovar plano" permite; "Pedir mudanças" nega com a nota do dono, e o bot continua planejando. Depois de aprovado, o Claude Code troca de modo sozinho (7.4). Que o pedido passa pela tool em `-p` e para qual modo o bot vai ainda precisam de teste real (19).
A aprovação só abre se `tool_use_id` for de uma ferramenta em `running` no chat daquele bot. O daemon espera até 2 s pelo evento, que às vezes chega depois da chamada. Senão nega na hora, sem incomodar o dono. Assim um bot que chame `permission_prompt` por conta própria não consegue pôr um pedido inventado na frente do dono. A descrição da tool também diz para não chamá-la.
@@ -505,16 +581,19 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co
| `crews.setLead` | `crewId, botId` (ou `null`, sem chefe) | `Crew`; o chefe antigo e o novo reiniciam quando nada estiver em andamento (10.2) |
| `crews.setWorkFolder` | `crewId, workFolder` (caminho, ou `null` para voltar à `shared\`) | `Crew`; os bots reiniciam na pasta nova quando nada estiver em andamento (5) |
| `crews.archive` | `crewId` | `Crew` |
-| `crews.delete` | `crewId` | `{crewId}`; exclui a crew, ativa ou arquivada, com todos os bots dela (7.6) |
+| `crews.delete` | `crewId, recycleFolder?` | `{crewId}`; exclui a crew, ativa ou arquivada, com todos os bots dela; com `recycleFolder`, a pasta dela vai depois para a Lixeira (7.6) |
| `bots.list` | `crewId?` | `Bot[]` |
+| `archive.list` | | `{crews, bots}`: o que está arquivado, que `crews.list` e `bots.list` deixam de fora, do arquivado mais recente ao mais antigo. `bots` traz todos os bots arquivados, também os das crews arquivadas (7.6) |
| `bots.create` | `crewId, name, role, instructions, color?, model?` | `Bot` |
| `bots.update` | `botId, name?, role?, instructions?, color?` | `Bot` |
| `bots.setPaused` | `botId, paused` | `Bot` |
| `bots.setPermissionMode` | `botId, mode` (`default`, `accept_edits`, `plan`, `auto`, `bypass_permissions`) | `Bot`; o bot reinicia no novo modo quando nada estiver em andamento (7.4) |
| `bots.setModel` | `botId, model` (`default`, `fable`, `opus`, `sonnet`, `haiku`) | `Bot`; o bot reinicia no novo modelo quando nada estiver em andamento (7.4) |
+| `bots.setEffort` | `botId, effort` (`default`, `low`, `medium`, `high`, `xhigh`, `max`) | `Bot`; o bot reinicia no novo esforço quando nada estiver em andamento (7.4) |
+| `bots.compact` | `botId` | `Bot`, com `context.compacting`; compacta a conversa depois do turno em andamento (8.6). `conflict` se o bot não está rodando |
| `bots.restart` | `botId, fresh?` | `Bot` |
| `bots.archive` | `botId` | `Bot` |
-| `bots.delete` | `botId` | `{botId, crewId}`; exclui o bot, ativo ou arquivado (7.6) |
+| `bots.delete` | `botId, recycleFolder?` | `{botId, crewId}`; exclui o bot, ativo ou arquivado; com `recycleFolder`, a pasta dele vai depois para a Lixeira (7.6) |
| `chat.history` | `botId, before?, limit?` | `ChatItem[]`, mais novo primeiro; `limit` de 1 a 200, 50 se ausente |
| `approvals.answer` | `approvalId, allow, note?, input?` (`input`: a sugestão de bot como o dono a deixou, 10.2) | `Approval` |
| `messages.send` | `botId, body, attachments?` (`[{name, mediaType, data}]`, data em base64) | `Message` |
@@ -530,11 +609,11 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co
| `settings.get` | | `Settings`: `startWithWindows`, `keepAwake` e `approvalWaitMinutes`, como estão no `config.toml` (6) |
| `settings.update` | `startWithWindows?, keepAwake?, approvalWaitMinutes?` (1 a 1440) | `Settings`; grava o que veio e aplica na hora (6, 14). Se a tarefa não pode mudar, nada é gravado e volta um erro |
-`Crew` traz `workFolder`, o caminho da pasta de trabalho (a escolhida ou a `shared\`), `workFolderChosen` e `leadBotId`, o chefe (10.2). `Bot` traz também `permissionMode`, `model` e `modelInUse` (7.4) e `lastActivity`: o último item do chat resumido em uma linha, para a lista de conversas: `kind` (`owner`, `message`, `reply`, `tool`, `approval`, `notice`), `text`, `tool` e `at`. O `text` não tem palavras do daemon: a mensagem do dono vem sem "You:", e uma ferramenta ou um pedido vêm só com o resumo, com a ferramenta à parte em `tool`; o app completa no idioma do dono ("Mandar uma mensagem · @writer", "Aguardando aprovação: rodar um comando").
+`Crew` traz `workFolder`, o caminho da pasta de trabalho (a escolhida ou a `shared\`), `workFolderChosen` e `leadBotId`, o chefe (10.2). `Bot` traz também `permissionMode`, `model`, `modelInUse`, `effort` e `effortDefault` (7.4), `context` (8.6; `null` enquanto o Claude Code não disse o tamanho da conversa) e `lastActivity`: o último item do chat resumido em uma linha, para a lista de conversas: `kind` (`owner`, `message`, `reply`, `tool`, `approval`, `notice`), `text`, `tool` e `at`. O `text` não tem palavras do daemon: a mensagem do dono vem sem "You:", e uma ferramenta ou um pedido vêm só com o resumo (num comando, com a explicação do bot, se ele deu uma, 10.1), com a ferramenta à parte em `tool`; o app completa no idioma do dono ("Mandar uma mensagem · @writer", "Aguardando aprovação: rodar um comando").
### 11.3 Notificações do servidor
-`bot.state`, `bot.changed`, `bot.deleted`, `crew.changed`, `crew.deleted` (7.6), `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`, das rotinas `routine.changed` e `routine.run` (20.8), e do navegador `browser.changed`, `browser.action` e, só para quem assiste, `browser.frame` (21.7), e das telas `screen.draft` (22.3).
+`bot.state`, `bot.changed`, `bot.context` (8.6), `bot.deleted`, `crew.changed`, `crew.deleted`, `folder.recycled` (7.6), `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`, das rotinas `routine.changed` e `routine.run` (20.8), e do navegador `browser.changed`, `browser.action` e, só para quem assiste, `browser.frame` (21.7), e das telas `screen.draft` (22.3).
### 11.4 Erros
@@ -555,7 +634,7 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations
| Tabela | Colunas principais |
|---|---|
| `crews` | `id, name, slug, paused, work_dir, lead_bot_id, created_at, archived_at` (`work_dir` é a pasta escolhida; `NULL` usa a `shared`. `lead_bot_id` é o chefe, sem chave estrangeira: ele é gravado junto com a crew) |
-| `bots` | `id, crew_id, name, handle, slug, role, instructions, color, paused, permission_mode, model, model_in_use, token_hash, session_id, created_at, archived_at` |
+| `bots` | `id, crew_id, name, handle, slug, role, instructions, color, paused, permission_mode, model, model_in_use, effort, effort_default, token_hash, session_id, created_at, archived_at` (`effort_default` é o nível do próprio modelo, como o Claude Code disse: `low` a `max`, `none` ou `NULL`, 7.4) |
| `messages` | `id, crew_id, from_kind (owner/bot/system), from_bot_id, to_bot_id, kind (note/task/result/system), body, task_id, created_at` |
| `attachments` | `id, message_id, name, media_type, size, path, created_at` |
| `deliveries` | `id, message_id, bot_id, state, attempts, next_attempt_at, lease_until, last_error, sent_generation, turn_uuid, read_at, updated_at` |
@@ -595,7 +674,8 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations
| Processos órfãos | Job Object por bot (seção 7.3) |
| Encerramento | `ctrl_c`, `ctrl_close`, `ctrl_shutdown`, `ctrl_logoff` do Tokio: parar courier, sinalizar bots, flush do banco. Esses sinais são de console: iniciado pela tarefa, sem console, o daemon não tem garantia de recebê-los, e o Windows encerra o processo no logoff e no desligamento. Isso equivale a um crash, e o sistema já é feito para ele (mensagens gravadas antes de sair, bots mortos pelo Job Object, aprovações abertas expiram no próximo start). Um erro fatal na subida vai para o log, porque ninguém vê o stderr da tarefa |
| Caminhos longos | manifesto `longPathAware` no daemon e no app; usar APIs com `\\?\` ao apagar árvores de workspace |
-| Arquivo em uso | arquivar e excluir não apagam workspace (5). O perfil do navegador do bot é apagado depois que o navegador fecha, com novas tentativas por 5 s enquanto o Edge ainda segura os arquivos |
+| Arquivo em uso | arquivar e excluir não apagam workspace (5). O perfil do navegador do bot é apagado depois que o navegador fecha, com novas tentativas por 5 s enquanto o Edge ainda segura os arquivos. A pasta que vai para a Lixeira (abaixo) é tentada de novo por 10 s enquanto o processo morto ainda a segura |
+| Lixeira (testado no Windows 11 25H2, 2026-09-30) | `IFileOperation` do shell, numa thread própria em STA (o shell exige, e uma thread do daemon pode já estar em MTA, como a do Agendador), com `FOF_NO_UI`, `FOF_ALLOWUNDO`, `FOFX_RECYCLEONDELETE` e `FOFX_EARLYFAILURE`. Sem poder perguntar, o shell apaga de vez o que a Lixeira não pode receber; por isso um `IFileOperationProgressSink` aborta em `PreDeleteItem` todo item que chega sem `TSF_DELETE_RECYCLE_IF_POSSIBLE`, e a pasta fica. Visto: a pasta vai inteira e aparece no registro `$I` da Lixeira com o caminho original; uma pasta com caminhos de mais de 260 caracteres também vai inteira, com o arquivo do fundo; com um arquivo aberto por outro processo a operação falha, a pasta fica como estava e vai na tentativa seguinte. Os testes tiram da Lixeira o que puseram |
| Instância única | lock exclusivo em `\botloftd.lock` (`File::try_lock`), solto pelo sistema quando o processo termina, mesmo em crash; se já estiver preso, sair com erro claro. É um lock por pasta de dados, e não um mutex de nome fixo, para o daemon de dev (`BOTLOFT_HOME`) rodar ao lado do instalado |
| Porta ocupada | se 45710 estiver em uso por outro processo, sair com erro (sem porta alternativa no MVP) |
@@ -642,18 +722,23 @@ O store recarrega crews, bots e `system.status` a cada (re)conexão e depois seg
Layout, como um app de mensagens:
-- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. Um clique direito numa conversa (ou a tecla de menu do teclado) abre o menu do bot onde o ponteiro está, sem abrir o bot: são as mesmas ações do menu "mais" do cabeçalho (editar, tornar chefe ou deixar de ser, reiniciar com uma conversa nova, abrir a pasta, arquivar), vindas do mesmo lugar no código (`useBotActions`), para os dois nunca divergirem. O menu fica inteiro dentro da janela, anda com as setas e fecha com Esc, com um clique fora ou quando a lista rola.
+- **Barra lateral:** crews como seções, com os bots como conversas. Cada conversa mostra avatar, nome, estado (cor, ícone e texto) e a prévia da última atividade (`lastActivity`) com a hora. Ela aparece desde a conexão, também na tela de boas-vindas antes da primeira crew. Um clique direito numa conversa (ou a tecla de menu do teclado) abre o menu do bot onde o ponteiro está, sem abrir o bot: são as mesmas ações do menu "mais" do cabeçalho (editar, tornar chefe ou deixar de ser, reiniciar com uma conversa nova, abrir a pasta, arquivar, excluir), vindas do mesmo lugar no código (`useBotActions`), para os dois nunca divergirem. O menu fica inteiro dentro da janela, anda com as setas e fecha com Esc, com um clique fora ou quando a lista rola.
- **Área da conta**, no pé da barra lateral, como nos apps de chat: um círculo com a inicial, o nome do dono (do Windows) e o plano do Claude ("Plano Max"; sem plano, a organização ou o e-mail; desconectado, "Sem conta do Claude conectada"). Um clique abre um menu para cima com o e-mail e: **Uso** (as janelas de uso do plano, 8.1, com a parte usada e quando renovam; antes da primeira resposta de um bot, um aviso de que ainda não há dados), **Configurações** (abaixo), **Idioma** (submenu ao lado, com a escolha na hora), **Novidades** (a página de releases no navegador) e **Ajuda** (o README no navegador). Tema, tamanho e idioma saíram da barra de título; ela só mostra o idioma nas telas de preparo, que ainda não têm barra lateral.
-- **Configurações**, um diálogo com as partes listadas à esquerda (abas verticais): **Geral**, **Conversa**, **Avisos**, **Aparência** e **Sobre**. As escolhas que são só do app ficam no `localStorage` (`botloft.whenClosed`, `botloft.tray`, `botloft.openAtSignIn`, `botloft.notifyNeeds`, `botloft.notifyDone`, `botloft.sound`, `botloft.enterSends`, `botloft.followBot`, `botloft.lessMotion`), como o tema. Em Geral, primeiro o **Idioma**, numa lista suspensa (um botão com a escolha que abre a lista embaixo, no visual dos menus, com as setas, Enter e Esc, que fecha só a lista); depois **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app), "Mostrar o Botloft perto do relógio" (só com a anterior ligada; abaixo), "Iniciar com o Windows" (`startWithWindows`, 14), "Abrir a janela ao entrar no Windows" (só com a anterior ligada; desligada, o Botloft começa perto do relógio, ou só abre quando o dono abrir) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. Em Conversa, "Enter envia a mensagem" (desligada, Enter quebra a linha e Ctrl+Enter envia, e a dica do compositor diz isso) e "Abrir o navegador e as telas quando um bot começar a usar" (desligada, só o ponto no botão avisa), e quanto tempo um pedido de permissão espera pelo dono (15 min, 30 min, 1 h, 2 h, 4 h ou 8 h; um valor posto à mão no `config.toml` entra na lista), com a linha de que sem resposta o pedido é negado (10.1). Em Avisos, "Quando um bot precisar de você" (ligada), "Quando um bot terminar" (desligada) e "Tocar um som" (ligada), com a linha de que, com a janela fechada, os avisos só chegam com o Botloft perto do relógio quando ele não está. Em Aparência, tema, tamanho e "Menos animações", que marca a raiz com `data-motion="less"` e para tudo como o Windows faria (15.3). Em **Sobre**: as versões do Botloft e do Claude Code, o e-mail e "Procurar atualizações", que consulta o feed na hora (15.5) e diz que esta é a versão mais recente ou mostra "Ver atualização", que abre o diálogo de atualizar. A linha do Botloft na tela de boas-vindas segue as mesmas escolhas.
+- **Configurações**, um diálogo com as partes listadas à esquerda (abas verticais): **Geral**, **Conversa**, **Avisos**, **Aparência**, **Arquivados** e **Sobre**. As escolhas que são só do app ficam no `localStorage` (`botloft.whenClosed`, `botloft.tray`, `botloft.openAtSignIn`, `botloft.notifyNeeds`, `botloft.notifyDone`, `botloft.sound`, `botloft.enterSends`, `botloft.followBot`, `botloft.lessMotion`), como o tema. Em Geral, primeiro o **Idioma**, numa lista suspensa (um botão com a escolha que abre a lista embaixo, no visual dos menus, com as setas, Enter e Esc, que fecha só a lista); depois **Em segundo plano**, três chaves, cada uma com uma linha que diz o que a escolha atual faz: "Continuar trabalhando depois de fechar o Botloft" (ligada, os bots seguem depois que a janela fecha; desligada, fechar a janela a esconde, chama `daemon_stop` e fecha, e os bots continuam de onde pararam quando o app abre de novo, com "Iniciando seus bots…" no lugar de "Preparando o Botloft…"; a escolha é do app), "Mostrar o Botloft perto do relógio" (só com a anterior ligada; abaixo), "Iniciar com o Windows" (`startWithWindows`, 14), "Abrir a janela ao entrar no Windows" (só com a anterior ligada; desligada, o Botloft começa perto do relógio, ou só abre quando o dono abrir) e "Não deixar o computador dormir enquanto os bots trabalham" (`keepAwake`, com a linha de que a tampa e o Suspender ainda põem o computador para dormir). As duas do daemon vêm de `settings.get` ao abrir, mudam na hora na tela e voltam atrás, com um aviso, se `settings.update` falhar. Em Conversa, "Enter envia a mensagem" (desligada, Enter quebra a linha e Ctrl+Enter envia, e a dica do compositor diz isso) e "Abrir o navegador e as telas quando um bot começar a usar" (desligada, só o ponto no botão avisa), e quanto tempo um pedido de permissão espera pelo dono (15 min, 30 min, 1 h, 2 h, 4 h ou 8 h; um valor posto à mão no `config.toml` entra na lista), com a linha de que sem resposta o pedido é negado (10.1). Em Avisos, "Quando um bot precisar de você" (ligada), "Quando um bot terminar" (desligada) e "Tocar um som" (ligada), com a linha de que, com a janela fechada, os avisos só chegam com o Botloft perto do relógio quando ele não está. Em Aparência, tema, tamanho e "Menos animações", que marca a raiz com `data-motion="less"` e para tudo como o Windows faria (15.3). Em **Arquivados**, uma linha que explica que os bots e as equipes arquivados estão parados e fora da vista, mas o Botloft ainda guarda as conversas deles, e a lista, lida de `archive.list` ao abrir a parte: primeiro as equipes ("Equipe · 2 bots · arquivada há 3 dias"), depois os bots arquivados de equipes ativas ("Bot de Ops · arquivado há 3 dias"); um bot de uma equipe arquivada sai junto com ela e não aparece sozinho. Cada linha tem um botão Excluir, que abre a mesma confirmação dos menus (7.6), dizendo que o bot ou a equipe "sai do Botloft de vez" em vez de "para agora", porque já estava parado. Sem nada arquivado, a parte diz isso. Não há como desarquivar. Em **Sobre**: as versões do Botloft e do Claude Code, o e-mail e "Procurar atualizações", que consulta o feed na hora (15.5) e diz que esta é a versão mais recente ou mostra "Ver atualização", que abre o diálogo de atualizar. A linha do Botloft na tela de boas-vindas segue as mesmas escolhas.
- **Perto do relógio:** com os bots continuando depois de fechar e "Mostrar o Botloft perto do relógio" ligada, o app põe um ícone na área de notificação (o ícone do app, com um ponto quando algo espera o dono, como o overlay da taskbar). A dica diz "Botloft: " e o que acontece: um bot que precisa do dono pelo nome, algo esperando, quantos bots trabalham ou nenhum. Um clique abre a janela; o menu tem essa mesma linha, "Abrir o Botloft", "Pausar todas as equipes" (ou "Retomar todas as equipes", com todas pausadas) e "Sair do Botloft", que fecha o app e deixa os bots trabalhando. Com o ícone, fechar a janela só a esconde; sem ele, o app fecha.
- **Avisos:** quando um bot passa a precisar do dono (pedido de permissão, com a ação no idioma do dono, por exemplo "Rodar um comando", sem o comando; ou entrar na conta do Claude) e, se o dono quiser, quando um bot passa de trabalhando a disponível ("Scout terminou", com a equipe). Com o Botloft fora da frente (janela escondida, minimizada ou sem foco), é um aviso do Windows, com o som do Windows se "Tocar um som" estiver ligada; com ele na frente, só um som curto feito na hora (duas notas, sem arquivo). O que já estava assim quando o app conectou não avisa. Abrir o Botloft pelo aviso (o que traz a janela aberta, 15.2) até 10 min depois abre o bot do último aviso.
- **Área principal com um bot:** cabeçalho com nome, estado e ações; o chat; o compositor embaixo. O compositor aceita texto, colar imagem e arrastar ou escolher arquivos. Enter envia e Shift+Enter quebra linha, ou, se o dono escolheu nas Configurações, Ctrl+Enter envia e Enter quebra linha (a dica aparece enquanto o dono escreve).
- **Modo do bot**, no compositor, ao lado do clipe, como no Claude Code: um botão com o modo atual abre um menu para cima, "Modo", com Automático, Manual, Aceitar edições e Plano, cada um com uma linha que fala do bot pelo nome ("Scout decide o que precisa do seu OK") e a marca no atual. Separado, "Ignorar permissões" com o botão Ativar, que abre uma confirmação dizendo que o bot não fica preso à pasta dele (13). Nesse modo o botão fica vermelho e o cabeçalho mostra "Não pergunta nada" em vermelho. Com o bot ocupado, uma linha acima do compositor avisa que ele muda de modo quando terminar o que está fazendo.
- **Modelo do bot**, no compositor, ao lado do botão de enviar: mostra o modelo em uso ("Opus 5.5"; antes do primeiro turno, o nome escolhido ou "Padrão") e abre um menu para cima, "Modelo", com Padrão do plano, Fable, Opus, Sonnet e Haiku. Cada um tem uma linha que fala do bot pelo nome ("Scout fica rápido e capaz, bom para quase tudo"; o padrão diz qual modelo é hoje), e o pé do menu lembra que modelos mais capazes gastam o limite do plano mais rápido. A janela de criar e editar bot tem a mesma escolha. Com o bot ocupado, a mesma linha acima do compositor avisa que ele troca quando terminar.
+- **Esforço do bot** (7.4), no compositor, ao lado do modelo: um botão com o nível em uso ("Médio") abre para cima "Esforço", um controle deslizante de cinco pontos, de "Mais rápido" a "Mais inteligente" (Baixo, Médio, Alto, Extra alto, Máximo), como o dos apps do Claude. O ponto **recomendado** vem marcado: é o nível que o modelo do bot usa sozinho (`effortDefault`). Embaixo, o nome do nível e uma linha que fala do bot pelo nome ("Scout equilibra rapidez e raciocínio, bom para quase tudo"); o pé lembra que mais esforço gasta o limite do plano mais rápido e, fora do recomendado, traz "Usar o recomendado". As palavras seguem o controle na hora, mas o bot só é avisado quando o controle fica 400 ms num ponto ou o menu fecha, para não reiniciar a cada passo. Parar no ponto recomendado grava `default`: o bot segue o nível do modelo, também depois de trocar de modelo. Enquanto o Claude Code não disse o nível do modelo, nada vem marcado e o botão diz só "Esforço". Num modelo sem níveis (Haiku) o botão fica apagado e o menu explica, sem o controle. Com o bot ocupado, a linha acima do compositor avisa que ele muda quando terminar.
+- **Espaço da conversa** (8.6), no compositor, antes do esforço: um anel que enche com a parte da janela em uso e passa à cor de aviso a partir de 80% do caminho até a compactação automática. Um clique abre "Espaço da conversa": o uso ("556k / 1M (56%)"), uma barra com a marca de onde a conversa se compacta sozinha, quanto falta para isso ("Faltam 411k para ela se compactar sozinha, em 967k"), uma linha dizendo que conversa mais cheia gasta mais do plano a cada mensagem, e **Compactar agora** (`bots.compact`), com a linha do que isso faz. Durante a compactação o botão diz "Compactando…" e o anel pulsa; com o bot ocupado, a linha acima do compositor avisa que ele compacta quando terminar; com o bot parado, o botão fica desligado e diz por quê. Sem `Bot.context`, o anel não aparece. No chat, a compactação vira um aviso no idioma do dono: feita a pedido, feita sozinha porque a conversa encheu, ou o motivo de não ter dado.
+- Num chat estreito, o que fica à direita no compositor (espaço, esforço, modelo e enviar) desce para uma linha própria, o esforço mostra só o ícone e o nome do modelo é cortado, em vez de tudo sair da caixa.
- **Área principal com uma crew:** a timeline (messages entre os bots e do dono) e as tasks. Embaixo do nome, "Trabalha em ", que abre a pasta no Explorer; o menu da crew tem "Abrir pasta de trabalho" e "Mudar pasta de trabalho…", que abre o seletor de pastas do Windows e confirma antes de mudar, avisando que cada bot reinicia quando terminar o que está fazendo.
- **Nova crew:** nome; "Para que é esta equipe?", o objetivo que vira as instruções do chefe, com a explicação de que a equipe começa com um Chefe que planeja o trabalho e sugere os bots de que precisa; "Pasta de trabalho", com "Escolher pasta…" (o seletor do Windows; sem escolha, "Uma pasta nova dentro do Botloft", a `shared\`); e "Modelo do Chefe". Criada, o app abre o chat do chefe.
- **Chefe:** uma coroa ao lado do nome na barra lateral e nos cartões da crew, e "Chefe" no cabeçalho do bot, com a dica "Lidera : planeja o trabalho e sugere bots novos". O menu do bot tem "Tornar chefe da equipe" ou "Deixar de ser chefe".
+- **Excluir** (7.6): "Excluir bot" é a última ação do menu do bot (no cabeçalho e no clique direito) e "Excluir equipe" a última do menu da crew, as duas em vermelho, depois de Arquivar. A confirmação leva o nome no título ("Excluir Scout?") e diz, em palavras simples, o que sai e o que fica: o bot para agora e sai do Botloft de vez, com a conversa, as rotinas e as tarefas de que fazia parte, e não dá para desfazer; se ele é o chefe, que a equipe fica sem chefe; e, num quadro à parte, que a pasta dele continua no computador, com o caminho para copiar. A da crew diz quantos bots param e saem com ela, e mostra a pasta de trabalho, lembrando que a pasta de cada bot também fica. No mesmo quadro, uma caixa desmarcada: "Mandar esta pasta para a Lixeira" (na crew, "Mandar as pastas da equipe para a Lixeira"). Marcada, o texto do quadro passa a dizer que a pasta vai para a Lixeira, de onde ainda dá para recuperar, e a crew mostra a pasta dela no Botloft em vez da `shared\`; se o dono escolheu a pasta de trabalho, o quadro diz que a pasta de cada bot vai e a escolhida fica onde está. O app manda `recycleFolder` só com a caixa marcada. Se a pasta não pôde ir (`folder.recycled` com `error`), um aviso diz que ela não foi para a Lixeira e continua lá, com o caminho e o motivo. Confirmado, o app tira o bot ou a crew da tela sem esperar o aviso do daemon; se falhar, um aviso diz que não foi possível e nada muda. Excluído o bot aberto, fica a página da crew; excluída a crew aberta, nenhuma fica escolhida (sem crews, a tela de boas-vindas). Uma message de um bot que saiu, arquivado ou excluído, aparece na conversa e na timeline como de "um bot que não está mais na equipe".
- **Sugestão de bot** (10.2): um cartão no chat do chefe, " sugere um bot novo", com o porquê e os campos editáveis Nome, Modelo, Função e Instruções, a linha de que o bot começa na hora e recebe o trabalho do chefe, um campo para dizer ao chefe por que não, e os botões Criar bot e Agora não. Respondido, vira uma linha ("Você criou Designer", "Você recusou Designer") que abre o que foi sugerido.
+- **Pedido de permissão** (10.1): um cartão no chat, " quer " ("Scout quer rodar um comando"), com Permitir, Negar e um campo para dizer ao bot por que não. Num comando, o texto principal é a explicação do bot, em letra comum; embaixo, menor, " escreveu isto. O que roda de verdade é o comando abaixo.", e "Ver o comando", uma seta que abre o comando inteiro, em letra de código e com as quebras de linha. Sem explicação, o cartão diz " não disse para que serve este comando. Na dúvida, negue e pergunte." e já vem com o comando aberto. Um comando grande demais para guardar aparece até onde coube, com o aviso de que está cortado. Nas outras ferramentas, o resumo (o arquivo, a URL) e a entrada inteira em "Detalhes". Respondido, o cartão vira uma linha ("Permitido: rodar um comando") com a explicação ou, sem ela, o comando. A linha da ferramenta acima do cartão segue a mesma ideia: "Rodar um comando" e a explicação do bot (sem ela, o comando), e um clique abre o comando e a saída; a lista de conversas mostra o mesmo texto.
- **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. Detalhes e arquivos são painéis laterais que o dono redimensiona arrastando a borda esquerda (ou com as setas do teclado, Shift anda mais; duplo clique volta ao padrão): 256 a 900 px, sempre deixando ao chat ao menos 22rem, e a largura de cada um fica no `localStorage` (`botloft.panel.details`, `botloft.panel.files`, `botloft.panel.browser`, `botloft.panel.screens`). O chat encolhe com eles e nunca passa por baixo nem por cima do painel.
- **Arquivos do bot** (8.4): um botão "Mostrar arquivos" no cabeçalho abre, à direita do chat, um painel com o que o bot fez, do mais novo ao mais antigo (ícone pelo tipo, nome, pasta, tamanho, "há 5 min"). Um clique mostra o arquivo no próprio painel: imagem, PDF, markdown e texto aparecem; o resto diz que não tem prévia. "Abrir" usa o programa que o Windows escolheu e "Mostrar na pasta" abre o Explorer com o arquivo marcado. A lista é lida ao abrir o bot, a cada 4 s enquanto ele trabalha e de novo quando ele para. Com o painel fechado, o botão ganha um contador dos arquivos que apareceram desde a última vez que o dono olhou; abertos, esses levam a etiqueta "Novo". No chat, a linha de uma ferramenta que mudou um arquivo (`Write`, `Edit`...; item `tool` com `file`, 8.2) e não falhou ganha o botão "Ver em arquivos", que abre o painel direto na prévia desse arquivo; se ele não existe mais, o painel diz. Detalhes, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. O painel abre deslizando da borda direita (460 ms, sem pressa no começo) e fecha voltando para ela (360 ms) antes de sair, com o conteúdo na largura final o tempo todo e o chat acompanhando; quem troca de painel começa da largura do anterior. Fechando, o painel já não recebe clique nem leitor de tela. Com movimento reduzido, abre e fecha de uma vez. O painel segue o que o bot começa a fazer, para o dono ver acontecer: o navegador abre quando o bot sobe o navegador ou pede uma mão (21.8), as telas quando ele começa a escrever uma (22.5), no lugar do painel que estiver aberto e uma vez por começo, a menos que o dono tenha desligado isso nas Configurações. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. O painel volta com o bot: o app guarda, por bot e até fechar (no store, não no `localStorage`), o painel que ficou ao lado do chat, aberto pelo dono ou pelo bot, e trocar de bot ou ir à página da crew e voltar o mostra de novo, já aberto, sem deslizar. Um painel que o dono fechou volta fechado. Só o painel é guardado: o arquivo ou a tela em foco dentro dele e o navegador nas mãos do dono, que é devolvido ao trocar de bot (21.10), não. Voltar conta como abrir o bot: um navegador que o bot abriu nesse meio-tempo fica atrás do botão, e o que abre sozinho ao abrir um bot (um pedido de mão ainda sem resposta, uma tela que ele ainda escreve) e o que ele começa dali em diante entram no lugar do painel que voltou.
@@ -850,7 +935,7 @@ M2 a M4 foram entregues com ConPTY, terminal com replay, inbox por named pipe e
## 19. Pontos a verificar na versão alvo do Claude Code
-Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Confirmado" quer dizer documentado; o teste real na versão alvo continua no checklist do marco indicado.
+Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esforço, pedidos de controle, uso de contexto e compactação, em 2026-09-30; itens mais novos trazem a própria data. "Confirmado" quer dizer documentado; o teste real na versão alvo continua no checklist do marco indicado.
| Item | Seção | Resultado | Teste real |
|---|---|---|---|
@@ -860,6 +945,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Co
| `--replay-user-messages` | 9.1 | Flag no `--help` sem detalhes na documentação. **Testado com 2.1.284**: devolve a mensagem com `isReplay: true` e o mesmo `uuid` quando o turno dela começa, não quando é lida | feito (M4.1) |
| Eventos do `--output-format stream-json` | 8.1 | Parcialmente documentado (`headless`): `system/init`, `stream_event` com `text_delta` (exige `--verbose` e `--include-partial-messages`), `system/api_retry` com `error` (`rate_limit`, `authentication_failed`...), `result`. **Visto com 2.1.284**: também `rate_limit_event` (status, `resetsAt`, uso das janelas de 5 h e 7 dias), `system/post_turn_summary`, `system/task_summary`, `system/thinking_tokens`; erro de API vem em `assistant.error` e `result.terminal_reason` | feito (M4.1) |
| `--permission-prompt-tool` | 10.1 | Confirmado (`cli-reference`, `headless`). **Testado com 2.1.284**: chama a tool com `{tool_name, input, tool_use_id}` e segue `{"behavior":"allow","updatedInput":...}`. Pelo daemon e pelo app: permitir depois de 95 s funcionou; negar com nota fez o bot citar a nota e não usar a ferramenta | feito (M4.1) |
+| `description` na entrada do `Bash` e do `PowerShell` | 10.1 | Confirmado (`hooks`, entrada do `PreToolUse`): nas duas ferramentas, `description` é "Optional description of what the command does", ao lado de `command`, `timeout` e `run_in_background`. **Testado com 2.1.284** em 2026-09-30 (`-p` com uma tool de aprovação que só grava a entrada e nega; depois com a linha de comando de 7.4; Haiku 4.5, Sonnet 5.5 e o padrão do plano, Opus 5.5): a tool de aprovação recebe a mesma entrada do bloco `tool_use`, e os 32 pedidos de `Bash` e `PowerShell` vistos traziam `description`. Sem regra, o texto é curto e técnico ("Install requests package"); com o dono escrevendo em português, Haiku e Opus descreveram em português e o Sonnet em inglês, também pelo daemon. Com a regra de 10.1, os três escreveram em português, em palavras simples e com o porquê ("Instala o pacote requests, que ajuda o Python a acessar sites"). Pelo daemon e pelo app de dev (Sonnet, modo Manual): o cartão mostrou a explicação, "Ver o comando" abriu o script em heredoc inteiro, e permitir rodou esse comando; o log de `debug` não teve o texto. **O campo não é garantido**: em uso real com 2.1.284, dois bots em Sonnet fizeram 28 comandos sem `description` (o `tool_use` já vinha só com `command`), e os outros bots, quase todos em Opus, mandaram nos 15 que fizeram. A falta não se repetiu em teste, então não foi visto se a regra a evita: o cartão tem o caso sem explicação | feito; manual (PR): ver se bots antigos em Sonnet passam a explicar depois de reiniciar |
| `--permission-prompts host` | 10.1 | Documentado só para o SDK. **Visto com 2.1.284**: sem o aperto de mão do SDK, nega tudo (`system/permission_denied`). Não usado | não se aplica |
| `timeout` por servidor MCP | 10 | Confirmado (`env-vars`): HTTP tem 60 s por request e 5 min sem resposta por padrão; `timeout` >= 1000 no servidor sobe os dois. **Testado com 2.1.284**: a aprovação respondida depois de 95 s chegou ao bot | feito (M4.1) |
| `--setting-sources project,local` | 7.4 | Confirmado (`cli-reference`). **Testado com 2.1.284**: sem os hooks, skills e agents do usuário; modo `default`; login da assinatura continua valendo | feito (M4.1) |
@@ -892,6 +978,12 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Co
| Dono no controle com o Claude Code real | 21.10 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): pedido para abrir uma página de login local e chamar o dono, o bot carregou `browser_ask_owner` pelo `ToolSearch` sozinho, com a tarefa na língua do dono. Pelo cartão, o painel abriu já nas mãos do dono; clique, digitação, troca de campo e Enter chegaram ao Edge, e a página de login entrou na conta. Devolvido, a tool respondeu com a página nova e o bot listou os pedidos. A senha digitada não apareceu no log, no banco nem na conversa do Claude Code (o bot leu só o que a página mostrava). Numa página comprida, a roda do mouse sobre a tela ao vivo rolou a página do bot e não o painel | feito |
| Abas nas mãos do dono com o Claude Code real | 21.10 | **A verificar**: com um bot no meio de uma tarefa num site, o dono assume, abre uma aba nova em outro endereço e devolve. O bot deve ler o aviso de que a aba mudou (no erro da tool que ia agir, ou no `browser_look`), ler a aba nova e seguir a tarefa nela ou abrir de novo a página de que precisa, sem agir às cegas. Recarregar sem assumir: o bot lê "The owner reloaded the page." e continua | manual (PR) |
| Cursor do bot nas telas | 22.3 | **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual), com o app num Edge sem janela: pedidas quatro páginas (floricultura, cafeteria, livraria, academia), o cursor seguiu o título, cada cartão, cada item de plano e cada linha de tabela enquanto o bot escrevia, e a prancheta rolou junto. Visto aqui: num `iframe` de outra origem, o script às vezes lê a página com `innerHeight` 0 e só depois recebe o tamanho (por isso a espera do `resize`); a versão escondida fala antes de ir para a frente (por isso vale a marca do quadro à mostra). O conteúdo das páginas não foi para o log | feito |
+| Esforço em `-p` | 7.4 | Confirmado (`cli-reference`, `model-config`, `headless`): `--effort` aceita `low`, `medium`, `high`, `xhigh`, `max` (e `ultracode`, não usado), vale só para a sessão e não é guardado; os níveis dependem do modelo (Fable, Opus e Sonnet atuais têm os cinco; Opus 4.6 e Sonnet 4.6 não têm `xhigh`); sem escolha, o padrão é `high`, menos em Opus 5.5 e Sonnet 5.5 (`medium`) e Opus 4.7 (`xhigh`); `/effort ` funciona em `-p` e vale só para a sessão; as chaves `effortLevel` e `modelSettings` das settings não aceitam `max`; trocar o esforço no meio da sessão mantém o cache em Opus 5.5, Sonnet 5.5 e Fable 5.1 e o invalida nos outros (`prompt-caching`). **Testado com 2.1.284** (`--setting-sources project,local`, plano Max): `--effort max` e `xhigh` com `--model sonnet` são aplicados; um valor desconhecido só avisa no stderr e usa o padrão; `--resume` sem a flag volta ao padrão do modelo; sem a flag, Sonnet 5.5 e Opus 5.5 aplicam `medium` e Fable 5.1 `high`; Haiku 4.5 não tem níveis (o `initialize` do SDK lista `supportedEffortLevels` para os outros e nada para ele) e ignora a flag sem erro. `/effort low` mandado como mensagem troca na hora, com uma resposta sintética e sem custo, e o pedido de controle `apply_flag_settings {effortLevel}` também; o daemon reinicia o processo em vez disso. O que acontece com um nível que o modelo não tem (`xhigh` num modelo antigo) não foi visto: os apelidos do Botloft apontam para modelos com os cinco | feito |
+| Pedidos de controle no stdin | 9.2 | `getContextUsage()` é documentado no Agent SDK (`agent-sdk/typescript`), com `totalTokens`, `maxTokens`, `rawMaxTokens`, `percentage`, `autoCompactThreshold` e `isAutoCompactEnabled`; o formato no fio e `get_settings` **não são documentados**. **Testado com 2.1.284**: `{"type":"control_request","request_id","request":{"subtype":"get_context_usage"}}` é respondido com `control_response` (`subtype: "success"`, o mesmo `request_id`, `response`) em até 2,5 s, antes do primeiro turno, entre turnos e durante um turno, sem `system/init` nem `result`; `get_settings` responde `{effective, sources, applied: {model, effort, ...}}`, com `effort: null` em Haiku; um `subtype` desconhecido responde `subtype: "error"`. Numa sessão retomada, a primeira resposta levou uma vez mais de 15 s. **Pelo daemon de dev** (Haiku e Sonnet): o modelo e o esforço do modelo chegaram ao app antes do primeiro turno, e `--model sonnet --effort low` apareceu na linha de comando do bot depois de `bots.setEffort` | feito |
+| Uso de contexto no `stream-json` | 8.6 | Parcialmente documentado: `usage` e `modelUsage` no `result` (`agent-sdk/typescript`), janela de 1 milhão em Fable, Sonnet 5+ e Opus 4.7+ e compactação automática em cerca de 967 mil (`model-config`). **Visto com 2.1.284**: cada `assistant` traz `message.usage` (`input_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens`, `output_tokens`), e a soma dos três primeiros bate com o `totalTokens` do `get_context_usage` a menos de 1%; `result.modelUsage[modelo]` traz `contextWindow` (200000 em Haiku 4.5, 1000000 em Sonnet 5.5) e `maxOutputTokens`; `result.usage.iterations[]` traz cada pedido do turno; `autoCompactThreshold` é a janela menos 33 mil (167000, 967000; 67000 com `--autocompact 100k`, que também encolhe `maxTokens`); `/context` como mensagem devolve um `assistant` sintético com `context_usage` e abre um turno, por isso o daemon usa o pedido de controle. `total_cost_usd` é o total do processo, não do turno. **Pelo daemon de dev**: o app recebeu o tamanho ao subir o bot, a cada pedido ao modelo e no fim do turno | feito |
+| Compactar em `-p` | 8.6 | `/compact [instruções]` é documentado (`commands`) sem dizer se vale em `-p`; o custo com cache quente e frio está em `prompt-caching`. **Testado com 2.1.284** (Haiku): `/compact` como mensagem imprime `system/status` (`status: "compacting"`), outro com `status: null` e `compact_result: "success"`, `system/init`, `system/compact_boundary` (`compact_metadata: {trigger: "manual", pre_tokens, post_tokens, duration_ms, ...}`), um `user` com `isSynthetic` e o resumo, dois `user` com `isReplay` (a saída `Compacted` e o comando, com o `uuid` enviado) e um `result` com `local_command: "compact"`, `num_turns: 0` e custo; levou de 12 a 16 s numa conversa pequena, e o bot lembrou depois o que tinha sido dito antes. Escrito durante um turno, roda quando o turno termina. Numa conversa vazia responde um `assistant` sintético "Error: No messages to compact" com `local_command_outcome: {kind: "failed"}` e um `result` sem erro. **Compactação automática** (Haiku, `--autocompact 100k`, quatro arquivos lidos): no meio do turno, os dois `system/status`, o `compact_boundary` com `trigger: "auto"` e o `user` sintético, sem `system/init` nem `result` próprios; o turno seguiu e terminou certo. **Pelo daemon de dev**: `bots.compact` deixou o bot `busy`, o aviso `compacted` entrou no chat, o tamanho caiu e o bot respondeu depois a palavra que tinha guardado; o log de `debug` só teve ids e números. Uma compactação que falha por erro da API não foi vista | feito |
+| CLAUDE.md do dono nos bots | 5, 7.5 | Confirmado (`memory`, `settings-reference`): o Claude Code carrega `CLAUDE.md`, `CLAUDE.local.md` e `.claude/CLAUDE.md` do cwd e de toda pasta acima dele; `claudeMdExcludes` pula arquivos por caminho absoluto ou glob, vale em qualquer camada de settings e também para `AGENTS.md`; o `CLAUDE.md` gerenciado não pode ser excluído. **Testado com 2.1.284** (`-p --setting-sources project,local`, `memoryFiles` do `get_context_usage`, numa pasta de teste dentro de `%USERPROFILE%`). Sem a setting: o `%USERPROFILE%\.claude\CLAUDE.md` do dono entra como `Project`, e de cada pasta acima entram `CLAUDE.md`, `CLAUDE.local.md`, `.claude/CLAUDE.md` e `.claude/rules/` com as subpastas; as skills são só as embutidas. Forma do padrão: `C:/Users/...` e `C:\Users\...` casam, também misturadas; `/c/Users/...` e `//c/Users/...` não casam; a comparação diferencia maiúsculas, até na letra do drive, e o Claude Code monta os caminhos com o cwd como o recebeu (um cwd em minúsculas aparece em minúsculas). Numa pasta chamada `odd (x) [y] {z}`, o caminho exato de um arquivo ainda casa, mas `.../.claude/rules/**` não; com `?` no lugar de cada um desses seis caracteres, casa. Espaço, acento e ``!+@#$%^&',;=~`` casam como estão. O `AGENTS.md` de uma pasta acima só entra com `instructionFiles: claude-md-and-agents-md` (os bots sempre têm `CLAUDE.md`), e o padrão o tira. `--add-dir` carrega `CLAUDE.md` e `.claude/rules/` só da própria pasta: os das pastas acima de uma pasta de trabalho escolhida não entram, com ou sem a setting. **Pelo daemon de dev** (workspaces dentro de `%USERPROFILE%`, com `CLAUDE.md` de teste em cada pasta acima; Haiku): com o `settings.json` gerado, `memoryFiles` e o próprio bot listaram só o `CLAUDE.md` da pasta da crew, o do bot, as regras e o da `shared\` | feito |
+| Memória automática do Claude Code nos bots | 7.5 | Confirmado (`memory`, `settings-reference`): ligada por padrão; o Claude Code grava `MEMORY.md` e um arquivo por nota em `%USERPROFILE%\.claude\projects\\memory\` e carrega o índice em toda sessão; `` vem do repositório git ou, fora de um, do caminho do cwd; `autoMemoryEnabled: false` vale em qualquer settings. **Testado com 2.1.284** (`-p --setting-sources project,local`, Haiku): ligada também assim, com cerca de 3 mil tokens a mais no prompt de sistema (6457 contra 3495). A pasta `memory\` é criada quando a sessão sobe. Pedido para lembrar de uma coisa, o bot gravou a nota e o `MEMORY.md` nessa pasta, fora do workspace, sem pedido de permissão no modo `default`, e não tocou no `CLAUDE.md` dele. O nome de `` é o caminho com `-` no lugar de tudo que não é letra nem número (`C--Users-ana-Botloft-site-revisor`); por essa regra `site-web\dev` e `site\web-dev` dariam a mesma pasta (não testado). Com o cwd dentro de um repositório git, a sessão carregou como `AutoMem` o `MEMORY.md` que o Claude Code guarda para o dono naquele repositório. Com `autoMemoryEnabled: false` no `settings.json` do projeto, o prompt encolhe e nada disso é carregado, nem dentro do repositório. **Pelo daemon de dev** (Haiku, "Aceitar edições"): pedido para lembrar, o bot editou o `CLAUDE.md` do workspace, e nenhuma pasta `memory\` foi criada. Mover a memória automática para dentro do workspace (`autoMemoryDirectory`) não foi testado | feito |
| Telas no WebView2 do app | 22.5 | Não visto: a CSP do Tauri com `frame-src http://127.0.0.1:*` e as telas carregando no `iframe` | manual (PR): `pnpm tauri dev`, pedir uma página HTML a um bot e ver a área de design |
Itens do runtime anterior (ConPTY, hooks em exec form, `crossSessionInbound`, linha de auth do inbox, diálogo de confiança, `ESC[6n` do ConPTY, consultas do terminal no replay) foram verificados no M2–M4 e deixaram de se aplicar com a ADR 0001; o histórico está no git e na ADR.
|