diff --git a/Cargo.lock b/Cargo.lock index 038c6baf..84a49b9a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -496,6 +496,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "windows", + "windows-core", ] [[package]] diff --git a/app/src/features/account/ArchivedSettings.tsx b/app/src/features/account/ArchivedSettings.tsx new file mode 100644 index 00000000..7666c76d --- /dev/null +++ b/app/src/features/account/ArchivedSettings.tsx @@ -0,0 +1,140 @@ +// Settings, "Archived": the bots and crews the owner archived, which the +// rest of the app does not show, with a way to delete each for good +// (spec 7.6). + +import { Users } from "lucide-react"; +import { type ReactNode, useEffect, useState } from "react"; +import { useT } from "../../i18n"; +import { errorText } from "../../lib/api"; +import { fromNow } from "../../lib/format"; +import type { Archive, Bot, Crew } from "../../lib/protocol.gen"; +import { useApi, useApp } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { Callout } from "../../ui/Callout"; +import { BotAvatar } from "../bots/BotAvatar"; +import { DeleteBot } from "../bots/DeleteBot"; +import { DeleteCrew } from "../crews/DeleteCrew"; +import { Section } from "./settingsParts"; + +type Asking = { bot: Bot } | { crew: Crew } | null; + +export function ArchivedSettings() { + const s = useT().account.settings; + const api = useApi(); + const active = useApp((state) => state.crews); + const [archive, setArchive] = useState(null); + const [failed, setFailed] = useState(null); + const [asking, setAsking] = useState(null); + + useEffect(() => { + let alive = true; + api.call("archive.list").then( + (list) => alive && setArchive(list), + (error) => alive && setFailed(errorText(error)), + ); + return () => { + alive = false; + }; + }, [api]); + + if (failed) { + return ( +
+ + {failed} + +
+ ); + } + if (!archive) { + return
{null}
; + } + + // A bot of an archived crew goes with its crew; the others are listed. + const botsOf = (crew: Crew) => archive.bots.filter((bot) => bot.crewId === crew.id); + const bots = archive.bots.filter((bot) => !archive.crews.some((crew) => crew.id === bot.crewId)); + const forget = (gone: Asking) => + setArchive({ + crews: archive.crews.filter((crew) => !(gone && "crew" in gone && gone.crew.id === crew.id)), + bots: archive.bots.filter( + (bot) => + !(gone && "bot" in gone && gone.bot.id === bot.id) && + !(gone && "crew" in gone && gone.crew.id === bot.crewId), + ), + }); + + return ( +
+

{s.archivedIntro}

+ {archive.crews.length + bots.length === 0 ? ( +

{s.archivedEmpty}

+ ) : ( +
    + {archive.crews.map((crew) => ( + + + + } + name={crew.name} + line={s.archivedCrew(botsOf(crew).length, fromNow(crew.archivedAt ?? 0))} + onDelete={() => setAsking({ crew })} + /> + ))} + {bots.map((bot) => ( + } + name={bot.name} + line={s.archivedBot(active[bot.crewId]?.name ?? "", fromNow(bot.archivedAt ?? 0))} + onDelete={() => setAsking({ bot })} + /> + ))} +
+ )} + {asking && "bot" in asking && ( + setAsking(null)} + onDeleted={() => forget(asking)} + /> + )} + {asking && "crew" in asking && ( + setAsking(null)} + onDeleted={() => forget(asking)} + /> + )} +
+ ); +} + +function Row({ + icon, + name, + line, + onDelete, +}: { + icon: ReactNode; + name: string; + line: string; + onDelete(): void; +}) { + const s = useT().account.settings; + return ( +
  • + {icon} +
    +

    {name}

    +

    {line}

    +
    + +
  • + ); +} diff --git a/app/src/features/account/SettingsDialog.tsx b/app/src/features/account/SettingsDialog.tsx index 99bd5eba..c5128067 100644 --- a/app/src/features/account/SettingsDialog.tsx +++ b/app/src/features/account/SettingsDialog.tsx @@ -1,8 +1,10 @@ // Settings (spec 15.1), in parts listed on the left: general (what // Botloft does in the background, the language), the chat, notifications, -// the look, and the versions for when something needs reporting. +// the look, what was archived, and the versions for when something needs +// reporting. import { + Archive, Bell, Info, type LucideIcon, @@ -16,16 +18,18 @@ import { Dialog } from "../../ui/Dialog"; import { AboutSettings } from "./AboutSettings"; import { AlertsSettings } from "./AlertsSettings"; import { AppearanceSettings } from "./AppearanceSettings"; +import { ArchivedSettings } from "./ArchivedSettings"; import { ChatSettings } from "./ChatSettings"; import { GeneralSettings } from "./GeneralSettings"; -type Page = "general" | "chat" | "alerts" | "appearance" | "about"; +type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "about"; const PAGES: { id: Page; icon: LucideIcon }[] = [ { id: "general", icon: SlidersHorizontal }, { id: "chat", icon: MessageSquare }, { id: "alerts", icon: Bell }, { id: "appearance", icon: Palette }, + { id: "archived", icon: Archive }, { id: "about", icon: Info }, ]; @@ -74,6 +78,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) { {page === "chat" && } {page === "alerts" && } {page === "appearance" && } + {page === "archived" && } {page === "about" && } diff --git a/app/src/features/account/archived.test.tsx b/app/src/features/account/archived.test.tsx new file mode 100644 index 00000000..edcdea9c --- /dev/null +++ b/app/src/features/account/archived.test.tsx @@ -0,0 +1,90 @@ +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { RpcError } from "../../lib/rpc"; +import { crewOpened, renderApp } from "../../test/app"; + +afterEach(cleanup); + +/** Opens Settings on its "Archived" part. */ +async function openArchived() { + fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); + const dialog = screen.getByRole("dialog", { name: "Settings" }); + fireEvent.click(within(dialog).getByRole("tab", { name: "Archived" })); + return dialog; +} + +/** Ops with Writer and an archived Scout, and an archived crew Docs of one bot. */ +async function someArchived() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const docs = fake.addCrew("Docs"); + const scout = fake.addBot(ops.id, "Scout"); + fake.addBot(ops.id, "Writer"); + const editor = fake.addBot(docs.id, "Editor"); + await fake.call("bots.archive", { botId: scout.id }); + await fake.call("crews.archive", { crewId: docs.id }); + renderApp(fake); + await crewOpened("Ops"); + return { fake, ops, docs, scout, editor }; +} + +describe("settings, archived", () => { + test("lists what was archived, which the app shows nowhere else", async () => { + await someArchived(); + const dialog = await openArchived(); + const list = await within(dialog).findByRole("list", { name: "Archived" }); + const rows = within(list).getAllByRole("listitem"); + expect(rows.map((row) => row.textContent)).toEqual([ + "DocsCrew · 1 bot · archived nowDelete", + "ScoutBot in Ops · archived nowDelete", + ]); + // A bot of an archived crew goes with its crew, not on its own. + expect(within(list).queryByText("Editor")).toBeNull(); + expect(dialog.textContent).toContain("Botloft still keeps their conversations"); + }); + + test("deletes an archived bot, saying it leaves rather than stops", async () => { + const { fake, scout } = await someArchived(); + const dialog = await openArchived(); + fireEvent.click(await within(dialog).findByRole("button", { name: "Delete Scout" })); + const confirm = screen.getByRole("dialog", { name: "Delete Scout?" }); + expect(confirm.textContent).toContain("Scout leaves Botloft for good"); + expect(confirm.textContent).not.toContain("stops now"); + expect(within(confirm).getByText(scout.workspace)).toBeDefined(); + fireEvent.click(within(confirm).getByRole("button", { name: "Delete bot" })); + await waitFor(() => expect(within(dialog).queryByText("Scout")).toBeNull()); + expect(fake.bots.has(scout.id)).toBe(false); + expect(within(dialog).getByText("Docs")).toBeDefined(); + }); + + test("deletes an archived crew with its bots, then has nothing left", async () => { + const { fake, docs, scout, editor } = await someArchived(); + await fake.call("bots.delete", { botId: scout.id }); + const dialog = await openArchived(); + fireEvent.click(await within(dialog).findByRole("button", { name: "Delete Docs" })); + const confirm = screen.getByRole("dialog", { name: "Delete Docs?" }); + expect(confirm.textContent).toContain("Docs and its bot leave Botloft for good"); + expect(within(confirm).getByText(docs.workFolder)).toBeDefined(); + fireEvent.click(within(confirm).getByRole("button", { name: "Delete crew" })); + expect(await within(dialog).findByText("Nothing is archived.")).toBeDefined(); + expect(fake.crews.has(docs.id)).toBe(false); + expect(fake.bots.has(editor.id)).toBe(false); + }); + + test("says so when nothing is archived, or when the list cannot be read", async () => { + const fake = new FakeBotloft(); + fake.addCrew("Ops"); + renderApp(fake); + await crewOpened("Ops"); + const dialog = await openArchived(); + expect(await within(dialog).findByText("Nothing is archived.")).toBeDefined(); + + fake.failNext("archive.list", new RpcError(-32603, "internal error")); + fireEvent.click(within(dialog).getByRole("tab", { name: "About" })); + fireEvent.click(within(dialog).getByRole("tab", { name: "Archived" })); + const alert = await within(dialog).findByRole("alert"); + expect(alert.textContent).toContain("Could not load what is archived"); + }); +}); diff --git a/app/src/features/bots/DeleteBot.tsx b/app/src/features/bots/DeleteBot.tsx new file mode 100644 index 00000000..9b2a9dfb --- /dev/null +++ b/app/src/features/bots/DeleteBot.tsx @@ -0,0 +1,55 @@ +import { useState } from "react"; +import { useT } from "../../i18n"; +import type { Bot } 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 bot for good (spec 7.6), saying what goes and + * what stays, with the choice to send its folder to the Recycle Bin. + * `chiefOf` is the crew it leads, if it leads one. + */ +export function DeleteBot({ + bot, + chiefOf, + onClose, + onDeleted, +}: { + bot: Bot; + chiefOf?: string | undefined; + onClose(): void; + onDeleted?(): void; +}) { + const words = useT().bots.header; + const api = useApi(); + const dropBot = useApp((state) => state.dropBot); + // The folder stays unless the owner says otherwise. + const [recycle, setRecycle] = useState(false); + return ( + + attempt(words.failed.delete, async () => { + const asked = recycle ? { botId: bot.id, recycleFolder: true } : { botId: bot.id }; + dropBot((await api.call("bots.delete", asked)).botId); + onDeleted?.(); + }) + } + > +

    + {words.deleteConfirm.removed(bot.name, bot.archivedAt === null)} + {chiefOf !== undefined && ` ${words.deleteConfirm.chief(chiefOf)}`} +

    + + {recycle ? words.deleteConfirm.recycled(bot.name) : words.deleteConfirm.kept(bot.name)} + +
    + ); +} diff --git a/app/src/features/bots/botActions.tsx b/app/src/features/bots/botActions.tsx index 246b2c9d..58524ce5 100644 --- a/app/src/features/bots/botActions.tsx +++ b/app/src/features/bots/botActions.tsx @@ -1,4 +1,4 @@ -import { Archive, Crown, FolderOpen, Pencil, RefreshCcw } from "lucide-react"; +import { Archive, Crown, FolderOpen, Pencil, RefreshCcw, Trash2 } from "lucide-react"; import { type ReactNode, useState } from "react"; import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; @@ -8,8 +8,9 @@ import type { MenuItem } from "../../ui/Menu"; import { attempt } from "../../ui/toast"; import { BotDialog } from "./BotDialog"; import { isChief } from "./ChiefBadge"; +import { DeleteBot } from "./DeleteBot"; -type Open = "edit" | "archive" | "fresh" | null; +type Open = "edit" | "archive" | "delete" | "fresh" | null; /** * What the owner can do with a bot from a menu, and the dialogs those @@ -54,6 +55,7 @@ export function useBotActions( onSelect: () => attempt(words.failed.openFolder, () => host.openPath(bot.workspace)), }, { label: words.archive, icon: Archive, danger: true, onSelect: () => setOpen("archive") }, + { label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") }, ]; const dialogs = @@ -85,6 +87,8 @@ export function useBotActions( > {words.archiveConfirm.body} + ) : open === "delete" ? ( + ) : null; return { items, dialogs }; diff --git a/app/src/features/bots/delete.test.tsx b/app/src/features/bots/delete.test.tsx new file mode 100644 index 00000000..ff07d360 --- /dev/null +++ b/app/src/features/bots/delete.test.tsx @@ -0,0 +1,242 @@ +import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { RpcError } from "../../lib/rpc"; +import { crewOpened, openBot, openTab, renderApp, sidebar } from "../../test/app"; + +afterEach(cleanup); + +/** A crew "Ops" with Scout and Writer, open on the crew's page. */ +async function twoBots() { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + const scout = fake.addBot(crew.id, "Scout"); + const writer = fake.addBot(crew.id, "Writer"); + renderApp(fake); + await crewOpened("Ops"); + return { fake, crew, scout, writer }; +} + +const inSidebar = (name: string) => + within(sidebar()).queryByRole("button", { name: new RegExp(name) }); + +/** Picks Delete in the open bot's menu and returns the confirmation. */ +function askToDelete(name: string) { + fireEvent.click(screen.getByRole("button", { name: "More bot actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete bot" })); + return screen.getByRole("dialog", { name: `Delete ${name}?` }); +} + +describe("deleting a bot", () => { + test("says what goes and what stays, then removes the bot", async () => { + const { fake, scout } = await twoBots(); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + expect(dialog.textContent).toContain( + "Scout stops now and leaves Botloft for good, with its conversation, its routines and the tasks it was part of. This can't be undone.", + ); + expect(dialog.textContent).toContain("Scout's folder stays on your computer"); + expect(within(dialog).getByText(scout.workspace)).toBeDefined(); + expect(dialog.textContent).not.toContain("without a chief"); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + // Back on the crew's page, without the bot. + await waitFor(() => expect(inSidebar("Scout")).toBeNull()); + expect(screen.getByRole("heading", { level: 1, name: "Ops" })).toBeDefined(); + expect(fake.calls.at(-1)).toEqual({ method: "bots.delete", params: { botId: scout.id } }); + expect(fake.bots.has(scout.id)).toBe(false); + expect(inSidebar("Writer")).not.toBeNull(); + }); + + test("cancelling keeps the bot", async () => { + const { fake, scout } = await twoBots(); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" })); + expect(screen.queryByRole("dialog")).toBeNull(); + expect(fake.bots.has(scout.id)).toBe(true); + expect(fake.calls.some((call) => call.method === "bots.delete")).toBe(false); + }); + + test("works from the right-click menu, for a bot that is not open", async () => { + const { fake, scout } = await twoBots(); + openBot("Writer"); + await screen.findByRole("heading", { level: 1, name: "Writer" }); + fireEvent.contextMenu(inSidebar("Scout") as HTMLElement, { clientX: 80, clientY: 120 }); + const menu = screen.getByRole("menu", { name: "Actions for Scout" }); + // Delete comes last, after Archive. + expect(within(menu).getAllByRole("menuitem").at(-1)?.textContent).toBe("Delete bot"); + fireEvent.click(within(menu).getByRole("menuitem", { name: "Delete bot" })); + const dialog = screen.getByRole("dialog", { name: "Delete Scout?" }); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + await waitFor(() => expect(inSidebar("Scout")).toBeNull()); + expect(fake.bots.has(scout.id)).toBe(false); + expect(screen.getByRole("heading", { level: 1, name: "Writer" })).toBeDefined(); + }); + + test("warns that the crew loses its chief", async () => { + const { fake, crew, scout } = await twoBots(); + await fake.call("crews.setLead", { crewId: crew.id, botId: scout.id }); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + expect(dialog.textContent).toContain("Ops will be left without a chief."); + }); + + test("a delete that fails says so and keeps the bot", async () => { + const { fake, scout } = await twoBots(); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + fake.failNext("bots.delete", new RpcError(-32603, "internal error")); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + await screen.findByText(/Could not delete the bot/); + expect(fake.bots.has(scout.id)).toBe(true); + expect(inSidebar("Scout")).not.toBeNull(); + }); + + test("what it sent stays in the timeline, from a bot no longer in the crew", async () => { + const { fake, scout, writer } = await twoBots(); + fake.conversation.say({ from: scout.id, to: writer.id, body: "The draft is in shared/." }); + await fake.call("bots.delete", { botId: scout.id }); + openTab("Timeline"); + const timeline = screen.getByRole("tabpanel", { name: "Timeline" }); + const message = (await within(timeline).findByText("The draft is in shared/.")).closest("li"); + expect(message?.textContent).toContain("a bot no longer in the crew"); + }); +}); + +describe("deleting a crew", () => { + function askToDeleteCrew(name: string) { + fireEvent.click(screen.getByRole("button", { name: "More crew actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete crew" })); + return screen.getByRole("dialog", { name: `Delete ${name}?` }); + } + + test("says what goes and what stays, then removes the crew and its bots", async () => { + const { fake, crew, scout } = await twoBots(); + const docs = fake.addCrew("Docs"); + await waitFor(() => + expect(within(sidebar()).getByRole("button", { name: /Docs/ })).toBeDefined(), + ); + const dialog = askToDeleteCrew("Ops"); + expect(dialog.textContent).toContain( + "Ops and its 2 bots stop now and leave Botloft for good, with their conversations, routines and tasks. This can't be undone.", + ); + expect(dialog.textContent).toContain("The folders stay on your computer"); + expect(within(dialog).getByText(crew.workFolder)).toBeDefined(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete crew" })); + await waitFor(() => + expect(within(sidebar()).queryByRole("button", { name: /Ops/ })).toBeNull(), + ); + expect(fake.calls.at(-1)).toEqual({ method: "crews.delete", params: { crewId: crew.id } }); + expect(fake.bots.has(scout.id)).toBe(false); + expect(inSidebar("Scout")).toBeNull(); + expect(fake.crews.has(docs.id)).toBe(true); + expect(within(sidebar()).getByRole("button", { name: /Docs/ })).toBeDefined(); + }); + + test("an empty crew and a crew of one read right", async () => { + const fake = new FakeBotloft(); + const crew = fake.addCrew("Ops"); + renderApp(fake); + await crewOpened("Ops"); + const empty = askToDeleteCrew("Ops"); + expect(empty.textContent).toContain("Ops leaves Botloft for good. This can't be undone."); + fireEvent.click(within(empty).getByRole("button", { name: "Cancel" })); + + fake.addBot(crew.id, "Scout"); + await waitFor(() => expect(inSidebar("Scout")).not.toBeNull()); + expect(askToDeleteCrew("Ops").textContent).toContain("Ops and its bot stop now"); + }); + + test("the last crew gone, the app is back at the start", async () => { + const { fake } = await twoBots(); + const dialog = askToDeleteCrew("Ops"); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete crew" })); + expect(await screen.findByRole("heading", { name: "Welcome to Botloft" })).toBeDefined(); + expect(fake.crews.size).toBe(0); + }); +}); + +describe("sending the folder to the Recycle Bin", () => { + const box = (dialog: HTMLElement, name: string) => + within(dialog).getByRole("checkbox", { name }) as HTMLInputElement; + + test("is off until the owner asks, then the bot's folder goes with it", async () => { + const { fake, scout } = await twoBots(); + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + const recycle = box(dialog, "Move this folder to the Recycle Bin"); + expect(recycle.checked).toBe(false); + + fireEvent.click(recycle); + expect(dialog.textContent).toContain( + "Scout's folder goes to the Recycle Bin, where you can still get it back:", + ); + expect(dialog.textContent).not.toContain("stays on your computer"); + expect(within(dialog).getByText(scout.workspace)).toBeDefined(); + + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + await waitFor(() => expect(inSidebar("Scout")).toBeNull()); + expect(fake.calls.at(-1)).toEqual({ + method: "bots.delete", + params: { botId: scout.id, recycleFolder: true }, + }); + expect(fake.recycled).toEqual([scout.workspace]); + }); + + test("says so when the folder could not go, and where it still is", async () => { + const { fake, scout } = await twoBots(); + fake.recycleError = "Windows cannot put this folder in the Recycle Bin"; + openBot("Scout"); + await screen.findByRole("heading", { level: 1, name: "Scout" }); + const dialog = askToDelete("Scout"); + fireEvent.click(box(dialog, "Move this folder to the Recycle Bin")); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete bot" })); + + const notice = await screen.findByText(/did not go to the Recycle Bin/); + expect(notice.textContent).toBe( + `The folder ${scout.workspace} did not go to the Recycle Bin and is still there: Windows cannot put this folder in the Recycle Bin`, + ); + // The bot is deleted all the same. + expect(fake.bots.has(scout.id)).toBe(false); + expect(fake.recycled).toEqual([]); + }); + + test("a crew's own folder goes whole, and a work folder the owner chose never does", async () => { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const site = await fake.call("crews.create", { + name: "Site", + workFolder: "C:\\Projects\\Site", + }); + fake.addBot(ops.id, "Scout"); + renderApp(fake); + await crewOpened("Ops"); + + fireEvent.click(screen.getByRole("button", { name: "More crew actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete crew" })); + const dialog = screen.getByRole("dialog", { name: "Delete Ops?" }); + fireEvent.click(box(dialog, "Move the crew's folders to the Recycle Bin")); + expect(dialog.textContent).toContain("The crew's folder goes to the Recycle Bin"); + // The crew's folder itself, not only `shared` inside it. + expect(within(dialog).getByText("C:\\Users\\owner\\Botloft\\ops")).toBeDefined(); + fireEvent.click(within(dialog).getByRole("button", { name: "Delete crew" })); + await waitFor(() => expect(fake.recycled).toEqual(["C:\\Users\\owner\\Botloft\\ops"])); + expect(fake.calls.at(-1)?.params).toEqual({ crewId: ops.id, recycleFolder: true }); + + fireEvent.click(within(sidebar()).getByRole("button", { name: /Site/ })); + await crewOpened("Site"); + fireEvent.click(screen.getByRole("button", { name: "More crew actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Delete crew" })); + const chosen = screen.getByRole("dialog", { name: "Delete Site?" }); + fireEvent.click(box(chosen, "Move the crew's folders to the Recycle Bin")); + expect(chosen.textContent).toContain("The work folder you chose stays where it is:"); + expect(within(chosen).getByText(site.workFolder)).toBeDefined(); + }); +}); diff --git a/app/src/features/chat/InboundRow.tsx b/app/src/features/chat/InboundRow.tsx index 7858cc15..20bceda3 100644 --- a/app/src/features/chat/InboundRow.tsx +++ b/app/src/features/chat/InboundRow.tsx @@ -71,7 +71,7 @@ function OtherMessage({ message }: { message: Message }) { const sender = useApp((state) => (message.fromBotId ? state.bots[message.fromBotId] : undefined)); const delivery = useApp((state) => state.deliveries[message.id]); const system = message.fromKind === "system"; - const name = system ? "Botloft" : (sender?.name ?? t.chat.inbound.archivedBot); + const name = system ? "Botloft" : (sender?.name ?? t.chat.inbound.goneBot); const arrival = useArrival(message.createdAt); return (
  • 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..f719d9ce 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", 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..6fcc7b2e 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", 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..7acb2950 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", 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/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/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/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 3b0b4bc3..09740475 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, }; @@ -1038,7 +1139,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 +1148,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 +1160,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 +1189,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/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 && ( + + )} +
    + ); +} diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs index cbfd3e32..83d43fb7 100644 --- a/crates/botloft-core/src/chat.rs +++ b/crates/botloft-core/src/chat.rs @@ -3,7 +3,7 @@ use serde_json::Value; -use crate::protocol::{Activity, ActivityKind, ChatBody, SenderKind}; +use crate::protocol::{Activity, ActivityKind, ChatBody, NoticeCode, SenderKind}; /// Longest tool input kept, in bytes. pub const TOOL_INPUT_MAX: usize = 4 * 1024; @@ -149,6 +149,15 @@ pub fn activity(body: &ChatBody, at: i64) -> Option { 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 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, }; @@ -258,6 +267,23 @@ mod tests { 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/protocol/chat.rs b/crates/botloft-core/src/protocol/chat.rs index 97d55450..d9d6d98b 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", } ); 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/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/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/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..cf84b2a5 100644 --- a/crates/botloftd/src/chat/events.rs +++ b/crates/botloftd/src/chat/events.rs @@ -4,28 +4,13 @@ use botloft_core::chat::{TOOL_OUTPUT_MAX, clip, tool_file, tool_input_max, tool_summary}; 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 +31,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 +41,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 +85,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 +93,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") => { @@ -144,59 +137,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 +199,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 +214,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..f3ea73ba 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -220,6 +220,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, }; 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/mod.rs b/crates/botloftd/src/workspace/mod.rs index 12bab010..07e1fdd4 100644 --- a/crates/botloftd/src/workspace/mod.rs +++ b/crates/botloftd/src/workspace/mod.rs @@ -111,6 +111,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, }; 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/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_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..006b2bee 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -117,7 +117,7 @@ Workspaces ficam num caminho curto e visível para o usuário abrir no Explorer **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. -**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. @@ -241,6 +241,7 @@ 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 ``` @@ -252,6 +253,8 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - `--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. @@ -282,7 +285,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 @@ -304,7 +308,11 @@ Uma linha JSON por evento, em UTF-8. Linha que não é JSON, ou maior que 8 MiB, | `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. @@ -319,7 +327,7 @@ Todo item tem `id` (`cht_`), `botId`, `kind`, `createdAt` e `updatedAt`. | `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) | | `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. @@ -344,6 +352,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 +418,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 @@ -505,16 +557,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 +585,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, 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 +610,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 +650,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,9 +698,9 @@ 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). @@ -653,6 +709,7 @@ Layout, como um app de mensagens: - **Á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. - **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 +907,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. "Confirmado" quer dizer documentado; o teste real na versão alvo continua no checklist do marco indicado. | Item | Seção | Resultado | Teste real | |---|---|---|---| @@ -892,6 +949,11 @@ 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.1 | **Visto com 2.1.284** (`/context` e `get_context_usage` com `--setting-sources project,local`): o `%USERPROFILE%\.claude\CLAUDE.md` do dono aparece como memória de projeto numa sessão cujo cwd fica dentro de `%USERPROFILE%`. É o documentado (`memory`): o Claude Code carrega `CLAUDE.md` e `.claude/CLAUDE.md` do cwd e de toda pasta acima dele, e a pasta do usuário é uma delas. Os workspaces dos bots ficam dentro dela por padrão, então as instruções pessoais do dono chegam aos bots, ao contrário do que 7.4 pretende. A setting `claudeMdExcludes` existe para pular arquivos assim. Não tratado aqui | a decidir | | 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.