From d11497f7a90a721d5ef18569e2cac8226c4197b1 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:56:15 -0300 Subject: [PATCH 01/15] feat(app): delete a bot or a crew from its menu Delete is the last action of the bot's menu (header and right-click) and of the crew's menu. The confirmation names the bot or crew and says what goes (the conversation, routines and tasks, for good) and what stays (the folder, with its path). A message from a bot that left, archived or deleted, reads as from a bot no longer in the crew. --- app/src/features/bots/botActions.tsx | 24 +++- app/src/features/bots/delete.test.tsx | 163 +++++++++++++++++++++++ app/src/features/chat/InboundRow.tsx | 2 +- app/src/features/crews/CrewView.tsx | 21 ++- app/src/features/crews/botMenu.test.tsx | 1 + app/src/features/messages/MessageRow.tsx | 2 +- app/src/i18n/en/bots.ts | 10 ++ app/src/i18n/en/chat.ts | 2 +- app/src/i18n/en/crews.ts | 9 ++ app/src/i18n/en/messages.ts | 2 +- app/src/i18n/es/bots.ts | 11 ++ app/src/i18n/es/chat.ts | 2 +- app/src/i18n/es/crews.ts | 9 ++ app/src/i18n/es/messages.ts | 2 +- app/src/i18n/pt-BR/bots.ts | 11 ++ app/src/i18n/pt-BR/chat.ts | 2 +- app/src/i18n/pt-BR/crews.ts | 9 ++ app/src/i18n/pt-BR/messages.ts | 2 +- app/src/ui/KeptFolder.tsx | 13 ++ docs/spec.md | 8 +- 20 files changed, 291 insertions(+), 14 deletions(-) create mode 100644 app/src/features/bots/delete.test.tsx create mode 100644 app/src/ui/KeptFolder.tsx diff --git a/app/src/features/bots/botActions.tsx b/app/src/features/bots/botActions.tsx index 246b2c9d..86b33021 100644 --- a/app/src/features/bots/botActions.tsx +++ b/app/src/features/bots/botActions.tsx @@ -1,15 +1,16 @@ -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"; import { useApi, useApp, useHost } from "../../store/context"; import { Confirm } from "../../ui/Confirm"; +import { KeptFolder } from "../../ui/KeptFolder"; import type { MenuItem } from "../../ui/Menu"; import { attempt } from "../../ui/toast"; import { BotDialog } from "./BotDialog"; import { isChief } from "./ChiefBadge"; -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 @@ -26,6 +27,7 @@ export function useBotActions( const host = useHost(); const putBot = useApp((state) => state.putBot); const putCrew = useApp((state) => state.putCrew); + const dropBot = useApp((state) => state.dropBot); const chief = isChief(bot, crew); const [open, setOpen] = useState(null); const close = () => setOpen(null); @@ -54,6 +56,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 +88,23 @@ export function useBotActions( > {words.archiveConfirm.body} + ) : open === "delete" ? ( + + attempt(words.failed.delete, async () => + dropBot((await api.call("bots.delete", { botId: bot.id })).botId), + ) + } + > +

+ {words.deleteConfirm.removed(bot.name)} + {chief && ` ${words.deleteConfirm.chief(crew.name)}`} +

+ {words.deleteConfirm.kept(bot.name)} +
) : 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..a3ba782c --- /dev/null +++ b/app/src/features/bots/delete.test.tsx @@ -0,0 +1,163 @@ +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); + }); +}); 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..65f6a413 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"; @@ -17,6 +18,7 @@ import { botsOf } from "../../store/app"; import { useApi, useApp, useHost } from "../../store/context"; import { Button } from "../../ui/Button"; import { Confirm } from "../../ui/Confirm"; +import { KeptFolder } from "../../ui/KeptFolder"; import { Menu } from "../../ui/Menu"; import { type Tab, Tabs, tabId } from "../../ui/Tabs"; import { attempt } from "../../ui/toast"; @@ -28,7 +30,7 @@ import { TaskList } from "../tasks/TaskList"; import { CrewBots } from "./CrewBots"; import { CrewDialog } from "./CrewDialog"; -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 }) { @@ -37,6 +39,7 @@ export function CrewView({ crew }: { crew: Crew }) { const api = useApi(); const host = useHost(); const putCrew = useApp((state) => state.putCrew); + const dropCrew = useApp((state) => state.dropCrew); const bots = useApp(useShallow((state) => botsOf(state, crew.id))); const [open, setOpen] = useState(null); const [pane, setPane] = useState("bots"); @@ -109,6 +112,7 @@ export function CrewView({ crew }: { crew: Crew }) { danger: true, onSelect: () => setOpen("archive"), }, + { label: words.delete, icon: Trash2, danger: true, onSelect: () => setOpen("delete") }, ]} /> @@ -188,6 +192,21 @@ export function CrewView({ crew }: { crew: Crew }) { {words.archiveBody(bots.length)} )} + {open === "delete" && ( + + attempt(words.failed.delete, async () => + dropCrew((await api.call("crews.delete", { crewId: crew.id })).crewId), + ) + } + > +

    {words.deleteBody(crew.name, bots.length)}

    + {words.deleteKept} +
    + )} ); } 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/bots.ts b/app/src/i18n/en/bots.ts index 47896821..febb9a04 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,14 @@ 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", + removed: (name: string) => + `${name} stops now and 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:`, + }, }, 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..45fc6940 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,20 @@ 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}?`, + deleteBody: (crew: string, bots: number) => + bots === 0 + ? `${crew} leaves Botloft for good. This can't be undone.` + : `${crew} and ${bots === 1 ? "its bot" : `its ${bots} bots`} stop now and 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:", 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/es/bots.ts b/app/src/i18n/es/bots.ts index ef9fa0e4..e1d45944 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,15 @@ 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) => + `${name} se detiene ahora y 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:`, + }, }, 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..45c2a0cb 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,20 @@ 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) => + bots === 0 + ? `${crew} sale de Botloft para siempre. No se puede deshacer.` + : `${crew} y ${bots === 1 ? "su bot" : `sus ${bots} bots`} se detienen ahora y 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:", 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/pt-BR/bots.ts b/app/src/i18n/pt-BR/bots.ts index add69c70..6077acef 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,15 @@ 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) => + `${name} para agora e 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:`, + }, }, 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..7cb73c13 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,20 @@ 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) => + bots === 0 + ? `${crew} sai do Botloft de vez. Não dá para desfazer.` + : `${crew} e ${bots === 1 ? "o bot dela" : `os ${bots} bots dela`} param agora e 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:", 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/ui/KeptFolder.tsx b/app/src/ui/KeptFolder.tsx new file mode 100644 index 00000000..3f07db07 --- /dev/null +++ b/app/src/ui/KeptFolder.tsx @@ -0,0 +1,13 @@ +import type { ReactNode } from "react"; + +/** In a confirmation: what stays on disk, and the folder it is in. */ +export function KeptFolder({ path, children }: { path: string; children: ReactNode }) { + return ( +
    +

    {children}

    +

    + {path} +

    +
    + ); +} diff --git a/docs/spec.md b/docs/spec.md index c78bbfe8..9c6ea0e7 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 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. 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. @@ -281,7 +281,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. +- **Próximos passos:** na confirmação, uma caixa para mandar a pasta para a Lixeira; e uma lista dos arquivados, que hoje o app não mostra, então ele só oferece excluir os ativos (o daemon já aceita os dois). ## 8. Chat @@ -640,7 +641,7 @@ 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. - **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. @@ -651,6 +652,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. 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. From f320f6bf9f7fc80ce37609cf7bd353dc9ecb34c3 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:05:54 -0300 Subject: [PATCH 02/15] feat(daemon): archive.list, what the owner archived The other lists leave archived crews and bots out, so nothing showed them. archive.list returns both, the latest archived first, for the app to list and delete. --- app/src/lib/fake.ts | 4 ++ app/src/lib/protocol.gen.ts | 8 +++ crates/botloft-core/src/protocol/export.rs | 2 + crates/botloft-core/src/protocol/mod.rs | 1 + crates/botloft-core/src/protocol/model.rs | 11 ++++ crates/botloftd/src/rpc/dispatch.rs | 5 +- crates/botloftd/src/service/archive.rs | 38 +++++++++++ crates/botloftd/src/service/mod.rs | 1 + crates/botloftd/tests/archive.rs | 75 ++++++++++++++++++++++ 9 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 crates/botloftd/src/service/archive.rs create mode 100644 crates/botloftd/tests/archive.rs diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 82e5f6ce..43685160 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -221,6 +221,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/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 5b35c1d4..b7c079cd 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -372,6 +372,13 @@ 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, }; + /** * Who wrote a message. */ @@ -1005,6 +1012,7 @@ export interface RpcMethods { "crews.setLead": { params: CrewsSetLeadParams; result: Crew }; "crews.archive": { params: CrewIdParams; result: Crew }; "crews.delete": { params: CrewIdParams; 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 }; diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index c7a302a7..482bc3e2 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -127,6 +127,7 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -232,6 +233,7 @@ fn export_bindings() { &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); diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 60b971bd..228f2afe 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -90,6 +90,7 @@ 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"; diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index 169ca45f..e6d0810a 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -179,3 +179,14 @@ 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, +} diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 9d37c7e0..4c707c0a 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)?)), 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/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/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"]); +} From 597a72fcca12978fc68fa2b8c05d98a67b7f81f4 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:05:55 -0300 Subject: [PATCH 03/15] feat(app): archived bots and crews in Settings, each with Delete Settings gets an Archived part: the crews and the bots the owner archived, which the rest of the app does not show, with the same delete confirmation as the menus. The confirmation is now a component of its own (DeleteBot, DeleteCrew) and says "leaves" instead of "stops now" for what was already stopped. --- app/src/features/account/ArchivedSettings.tsx | 140 ++++++++++++++++++ app/src/features/account/SettingsDialog.tsx | 9 +- app/src/features/account/archived.test.tsx | 90 +++++++++++ app/src/features/bots/DeleteBot.tsx | 45 ++++++ app/src/features/bots/botActions.tsx | 20 +-- app/src/features/crews/CrewView.tsx | 19 +-- app/src/features/crews/DeleteCrew.tsx | 42 ++++++ app/src/i18n/en/account.ts | 11 ++ app/src/i18n/en/bots.ts | 5 +- app/src/i18n/en/crews.ts | 5 +- app/src/i18n/es/account.ts | 10 ++ app/src/i18n/es/bots.ts | 4 +- app/src/i18n/es/crews.ts | 4 +- app/src/i18n/pt-BR/account.ts | 10 ++ app/src/i18n/pt-BR/bots.ts | 4 +- app/src/i18n/pt-BR/crews.ts | 4 +- docs/spec.md | 7 +- 17 files changed, 377 insertions(+), 52 deletions(-) create mode 100644 app/src/features/account/ArchivedSettings.tsx create mode 100644 app/src/features/account/archived.test.tsx create mode 100644 app/src/features/bots/DeleteBot.tsx create mode 100644 app/src/features/crews/DeleteCrew.tsx 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..90310386 --- /dev/null +++ b/app/src/features/bots/DeleteBot.tsx @@ -0,0 +1,45 @@ +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. `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); + return ( + + attempt(words.failed.delete, async () => { + dropBot((await api.call("bots.delete", { botId: bot.id })).botId); + onDeleted?.(); + }) + } + > +

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

    + {words.deleteConfirm.kept(bot.name)} +
    + ); +} diff --git a/app/src/features/bots/botActions.tsx b/app/src/features/bots/botActions.tsx index 86b33021..58524ce5 100644 --- a/app/src/features/bots/botActions.tsx +++ b/app/src/features/bots/botActions.tsx @@ -4,11 +4,11 @@ import { useT } from "../../i18n"; import type { Bot, Crew } from "../../lib/protocol.gen"; import { useApi, useApp, useHost } from "../../store/context"; import { Confirm } from "../../ui/Confirm"; -import { KeptFolder } from "../../ui/KeptFolder"; 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" | "delete" | "fresh" | null; @@ -27,7 +27,6 @@ export function useBotActions( const host = useHost(); const putBot = useApp((state) => state.putBot); const putCrew = useApp((state) => state.putCrew); - const dropBot = useApp((state) => state.dropBot); const chief = isChief(bot, crew); const [open, setOpen] = useState(null); const close = () => setOpen(null); @@ -89,22 +88,7 @@ export function useBotActions( {words.archiveConfirm.body} ) : open === "delete" ? ( - - attempt(words.failed.delete, async () => - dropBot((await api.call("bots.delete", { botId: bot.id })).botId), - ) - } - > -

    - {words.deleteConfirm.removed(bot.name)} - {chief && ` ${words.deleteConfirm.chief(crew.name)}`} -

    - {words.deleteConfirm.kept(bot.name)} -
    + ) : null; return { items, dialogs }; diff --git a/app/src/features/crews/CrewView.tsx b/app/src/features/crews/CrewView.tsx index 65f6a413..b322903a 100644 --- a/app/src/features/crews/CrewView.tsx +++ b/app/src/features/crews/CrewView.tsx @@ -18,7 +18,6 @@ import { botsOf } from "../../store/app"; import { useApi, useApp, useHost } from "../../store/context"; import { Button } from "../../ui/Button"; import { Confirm } from "../../ui/Confirm"; -import { KeptFolder } from "../../ui/KeptFolder"; import { Menu } from "../../ui/Menu"; import { type Tab, Tabs, tabId } from "../../ui/Tabs"; import { attempt } from "../../ui/toast"; @@ -29,6 +28,7 @@ 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" | "delete" | { move: string } | null; type Pane = "bots" | "timeline" | "tasks" | "routines"; @@ -39,7 +39,6 @@ export function CrewView({ crew }: { crew: Crew }) { const api = useApi(); const host = useHost(); const putCrew = useApp((state) => state.putCrew); - const dropCrew = useApp((state) => state.dropCrew); const bots = useApp(useShallow((state) => botsOf(state, crew.id))); const [open, setOpen] = useState(null); const [pane, setPane] = useState("bots"); @@ -192,21 +191,7 @@ export function CrewView({ crew }: { crew: Crew }) { {words.archiveBody(bots.length)} )} - {open === "delete" && ( - - attempt(words.failed.delete, async () => - dropCrew((await api.call("crews.delete", { crewId: crew.id })).crewId), - ) - } - > -

    {words.deleteBody(crew.name, bots.length)}

    - {words.deleteKept} -
    - )} + {open === "delete" && } ); } diff --git a/app/src/features/crews/DeleteCrew.tsx b/app/src/features/crews/DeleteCrew.tsx new file mode 100644 index 00000000..ef786118 --- /dev/null +++ b/app/src/features/crews/DeleteCrew.tsx @@ -0,0 +1,42 @@ +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. `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); + return ( + + attempt(words.failed.delete, async () => { + dropCrew((await api.call("crews.delete", { crewId: crew.id })).crewId); + onDeleted?.(); + }) + } + > +

    {words.deleteBody(crew.name, bots, crew.archivedAt === null)}

    + {words.deleteKept} +
    + ); +} 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 febb9a04..f7fa547a 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -72,8 +72,9 @@ export const bots = { deleteConfirm: { title: (name: string) => `Delete ${name}?`, confirm: "Delete bot", - removed: (name: string) => - `${name} stops now and leaves Botloft for good, with its conversation, its routines and the tasks it was part of. This can't be undone.`, + /** `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:`, }, diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts index 45fc6940..602873ea 100644 --- a/app/src/i18n/en/crews.ts +++ b/app/src/i18n/en/crews.ts @@ -64,10 +64,11 @@ export const crews = { ? "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}?`, - deleteBody: (crew: string, bots: number) => + /** `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`} stop now and leave Botloft for good, with their conversations, routines and tasks. 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:", failed: { 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 e1d45944..71199fec 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -68,8 +68,8 @@ export const bots: Messages["bots"] = { deleteConfirm: { title: (name: string) => `¿Eliminar ${name}?`, confirm: "Eliminar bot", - removed: (name: string) => - `${name} se detiene ahora y sale de Botloft para siempre, con su conversación, sus rutinas y las tareas en las que participaba. No se puede deshacer.`, + 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:`, diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts index 45c2a0cb..93279dcd 100644 --- a/app/src/i18n/es/crews.ts +++ b/app/src/i18n/es/crews.ts @@ -61,10 +61,10 @@ export const crews: Messages["crews"] = { ? "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) => + 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`} se detienen ahora y salen de Botloft para siempre, con sus conversaciones, rutinas y tareas. 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:", failed: { 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 6077acef..e1368803 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -68,8 +68,8 @@ export const bots: Messages["bots"] = { deleteConfirm: { title: (name: string) => `Excluir ${name}?`, confirm: "Excluir bot", - removed: (name: string) => - `${name} para agora e sai do Botloft de vez, com a conversa, as rotinas e as tarefas de que fazia parte. Não dá para desfazer.`, + 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:`, diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts index 7cb73c13..69a115d6 100644 --- a/app/src/i18n/pt-BR/crews.ts +++ b/app/src/i18n/pt-BR/crews.ts @@ -62,10 +62,10 @@ export const crews: Messages["crews"] = { ? "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) => + 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`} param agora e saem do Botloft de vez, com as conversas, as rotinas e as tarefas. 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:", failed: { diff --git a/docs/spec.md b/docs/spec.md index 9c6ea0e7..89e92864 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -281,8 +281,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). -- **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. -- **Próximos passos:** na confirmação, uma caixa para mandar a pasta para a Lixeira; e uma lista dos arquivados, que hoje o app não mostra, então ele só oferece excluir os ativos (o daemon já aceita os dois). +- **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). +- **Próximo passo:** na confirmação, uma caixa para mandar a pasta para a Lixeira. ## 8. Chat @@ -506,6 +506,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `crews.archive` | `crewId` | `Crew` | | `crews.delete` | `crewId` | `{crewId}`; exclui a crew, ativa ou arquivada, com todos os bots dela (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` | @@ -643,7 +644,7 @@ 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, 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). From 97da749539d5ccb40c212986fefaa12f9513ecdc Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:37:12 -0300 Subject: [PATCH 04/15] feat(platform): move a folder to the Recycle Bin, never deleting it for good With the shell's IFileOperation on a thread of its own (it needs a single-threaded apartment). A progress sink aborts any item the shell would destroy instead of recycling, so what the bin cannot take stays where it is. The tests use the real Recycle Bin, check the folder is in it and take it out again. --- Cargo.lock | 1 + crates/botloftd/Cargo.toml | 4 +- crates/botloftd/src/platform/mod.rs | 8 +- crates/botloftd/src/platform/unix.rs | 8 + crates/botloftd/src/platform/windows.rs | 2 + .../botloftd/src/platform/windows/recycle.rs | 352 ++++++++++++++++++ 6 files changed, 370 insertions(+), 5 deletions(-) create mode 100644 crates/botloftd/src/platform/windows/recycle.rs 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/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/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..57ecce7e --- /dev/null +++ b/crates/botloftd/src/platform/windows/recycle.rs @@ -0,0 +1,352 @@ +//! 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 { + use std::path::PathBuf; + + use super::*; + use crate::platform::windows::CurrentUser; + + /// 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 + } + + /// 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 = dir.path().join("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 = dir.path().join("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 = dir.path().join("botloft-recycle-long"); + std::fs::create_dir(&folder).expect("folder"); + // The verbatim form of the path, which may pass 260 characters. + let top = std::fs::canonicalize(&folder).expect("verbatim path"); + 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"); + } + } + } +} From f7ec55856d6f628260635dd9c7dab14c60b0e25c Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:37:12 -0300 Subject: [PATCH 05/15] feat(daemon): send a deleted bot's or crew's folder to the Recycle Bin when asked bots.delete and crews.delete take recycleFolder. The method answers at once; the folder moves afterwards, off the server's workers, tried again while the process that was just killed still holds its files, and folder.recycled tells the apps how it ended. Only a folder under the workspaces goes, and never one that holds a work folder the owner chose. --- app/src/lib/protocol.gen.ts | 39 ++++- crates/botloft-core/src/protocol/export.rs | 13 +- crates/botloft-core/src/protocol/mod.rs | 1 + crates/botloft-core/src/protocol/model.rs | 38 +++++ crates/botloftd/src/lib.rs | 1 + crates/botloftd/src/main.rs | 2 + crates/botloftd/src/rpc/mod.rs | 3 + crates/botloftd/src/service/delete.rs | 58 ++++++- crates/botloftd/src/state.rs | 14 +- crates/botloftd/src/trash.rs | 136 ++++++++++++++++ crates/botloftd/src/workspace/folder.rs | 2 +- crates/botloftd/tests/common/mod.rs | 8 + crates/botloftd/tests/delete_folders.rs | 172 +++++++++++++++++++++ docs/spec.md | 15 +- 14 files changed, 482 insertions(+), 20 deletions(-) create mode 100644 crates/botloftd/src/trash.rs create mode 100644 crates/botloftd/tests/delete_folders.rs diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index b7c079cd..118cad7b 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -379,6 +379,40 @@ export type CrewDeleted = { crewId: CrewId, }; */ 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. */ @@ -1011,7 +1045,7 @@ 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 }; @@ -1020,7 +1054,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 }; @@ -1054,6 +1088,7 @@ export interface RpcNotifications { "bot.changed": Bot; "crew.deleted": CrewDeleted; "bot.deleted": BotDeleted; + "folder.recycled": FolderRecycled; "bot.state": BotStateChanged; "chat.item": ChatItemChanged; "chat.delta": ChatDelta; diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 482bc3e2..05ce8536 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -128,6 +128,9 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -230,7 +233,7 @@ 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::()); @@ -255,7 +258,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); @@ -330,6 +333,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::(); diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 228f2afe..0ed482e3 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -134,6 +134,7 @@ 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 CHAT_ITEM: &str = "chat.item"; pub const CHAT_DELTA: &str = "chat.delta"; diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index e6d0810a..f29684d7 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -190,3 +190,41 @@ 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/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs index 20e3ac95..8a6dbb30 100644 --- a/crates/botloftd/src/lib.rs +++ b/crates/botloftd/src/lib.rs @@ -24,4 +24,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/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index 8b935c2b..ce3b07a7 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -202,6 +202,9 @@ 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::ChatItem(item) => (notification::CHAT_ITEM, serde_json::to_value(item)), Event::ChatDelta(delta) => (notification::CHAT_DELTA, serde_json::to_value(delta)), 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/state.rs b/crates/botloftd/src/state.rs index 73742cfc..019369db 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -6,7 +6,8 @@ use std::time::Instant; use botloft_core::protocol::{ AccountUsage, Bot, BotDeleted, BotStateChanged, BrowserAction, BrowserState, ChatDelta, - ChatItemChanged, Crew, CrewDeleted, Delivery, Message, Routine, RoutineRun, ScreenDraft, Task, + ChatItemChanged, Crew, CrewDeleted, Delivery, FolderRecycled, Message, Routine, RoutineRun, + ScreenDraft, Task, }; use botloft_store::Store; use tokio::sync::broadcast; @@ -24,6 +25,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,6 +35,7 @@ pub enum Event { CrewDeleted(CrewDeleted), BotChanged(Bot), BotDeleted(BotDeleted), + FolderRecycled(FolderRecycled), BotState(BotStateChanged), ChatItem(ChatItemChanged), ChatDelta(ChatDelta), @@ -83,6 +86,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 +102,7 @@ pub struct Daemon { pub routines: Routines, pub browsers: Browsers, pub screens: Screens, + pub trash: Arc, /// Time for everything stored or compared with stored times. pub clock: Arc, store: Mutex, @@ -132,6 +138,7 @@ impl Daemon { Arc::clone(&options.clock), ), screens: Screens::default(), + trash: options.trash, clock: options.clock, paths: options.paths, port: options.port, @@ -169,6 +176,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/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/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/tests/common/mod.rs b/crates/botloftd/tests/common/mod.rs index 8d14da67..d998df26 100644 --- a/crates/botloftd/tests/common/mod.rs +++ b/crates/botloftd/tests/common/mod.rs @@ -31,6 +31,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 +49,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 +92,7 @@ pub struct Parts { pub runtime: FakeRuntime, pub clock: Arc, pub paths: Paths, + pub trash: FakeTrash, pub dir: TempDir, } @@ -97,6 +101,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 +120,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 +163,7 @@ impl TestDaemon { daemon: parts.daemon, runtime: parts.runtime, clock: parts.clock, + trash: parts.trash, _dir: parts.dir, } } 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/docs/spec.md b/docs/spec.md index 89e92864..63f71de5 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: 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. +**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. @@ -282,7 +282,7 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - **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). - **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). -- **Próximo passo:** na confirmação, uma caixa para mandar a pasta para a Lixeira. +- **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 @@ -504,7 +504,7 @@ 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` | @@ -514,7 +514,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `bots.setModel` | `botId, model` (`default`, `fable`, `opus`, `sonnet`, `haiku`) | `Bot`; o bot reinicia no novo modelo quando nada estiver em andamento (7.4) | | `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` | @@ -534,7 +534,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co ### 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.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 @@ -595,7 +595,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 as threads do daemon são MTA), 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) | @@ -653,7 +654,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. 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". +- **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. From 617da0fe60b0948d67c780f721b6ae8ebf875ac8 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:37:14 -0300 Subject: [PATCH 06/15] feat(app): a box in the delete confirmation to send the folder to the Recycle Bin Unchecked by default: the folder stays. Checked, the confirmation says the folder goes to the Recycle Bin, where it can still be got back, and a notice tells the owner if it could not go. --- app/src/features/bots/DeleteBot.tsx | 16 +++++- app/src/features/bots/delete.test.tsx | 79 +++++++++++++++++++++++++++ app/src/features/crews/DeleteCrew.tsx | 27 ++++++++- app/src/i18n/en/bots.ts | 3 + app/src/i18n/en/crews.ts | 5 ++ app/src/i18n/en/shell.ts | 3 + app/src/i18n/es/bots.ts | 3 + app/src/i18n/es/crews.ts | 5 ++ app/src/i18n/es/shell.ts | 1 + app/src/i18n/pt-BR/bots.ts | 3 + app/src/i18n/pt-BR/crews.ts | 5 ++ app/src/i18n/pt-BR/shell.ts | 1 + app/src/lib/fake.ts | 13 +++++ app/src/lib/fakeBots.ts | 5 +- app/src/lib/fakeCrews.ts | 7 ++- app/src/shell/Workspace.tsx | 2 + app/src/shell/folders.ts | 21 +++++++ app/src/ui/KeptFolder.tsx | 25 ++++++++- 18 files changed, 213 insertions(+), 11 deletions(-) create mode 100644 app/src/shell/folders.ts diff --git a/app/src/features/bots/DeleteBot.tsx b/app/src/features/bots/DeleteBot.tsx index 90310386..9b2a9dfb 100644 --- a/app/src/features/bots/DeleteBot.tsx +++ b/app/src/features/bots/DeleteBot.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useT } from "../../i18n"; import type { Bot } from "../../lib/protocol.gen"; import { useApi, useApp } from "../../store/context"; @@ -7,7 +8,8 @@ import { attempt } from "../../ui/toast"; /** * Asks before deleting a bot for good (spec 7.6), saying what goes and - * what stays. `chiefOf` is the crew it leads, if it leads one. + * 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, @@ -23,6 +25,8 @@ export function DeleteBot({ 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 () => { - dropBot((await api.call("bots.delete", { botId: bot.id })).botId); + const asked = recycle ? { botId: bot.id, recycleFolder: true } : { botId: bot.id }; + dropBot((await api.call("bots.delete", asked)).botId); onDeleted?.(); }) } @@ -39,7 +44,12 @@ export function DeleteBot({ {words.deleteConfirm.removed(bot.name, bot.archivedAt === null)} {chiefOf !== undefined && ` ${words.deleteConfirm.chief(chiefOf)}`}

    - {words.deleteConfirm.kept(bot.name)} + + {recycle ? words.deleteConfirm.recycled(bot.name) : words.deleteConfirm.kept(bot.name)} +
    ); } diff --git a/app/src/features/bots/delete.test.tsx b/app/src/features/bots/delete.test.tsx index a3ba782c..ff07d360 100644 --- a/app/src/features/bots/delete.test.tsx +++ b/app/src/features/bots/delete.test.tsx @@ -161,3 +161,82 @@ describe("deleting a crew", () => { 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/crews/DeleteCrew.tsx b/app/src/features/crews/DeleteCrew.tsx index ef786118..1c6466f6 100644 --- a/app/src/features/crews/DeleteCrew.tsx +++ b/app/src/features/crews/DeleteCrew.tsx @@ -1,3 +1,4 @@ +import { useState } from "react"; import { useT } from "../../i18n"; import type { Crew } from "../../lib/protocol.gen"; import { useApi, useApp } from "../../store/context"; @@ -7,7 +8,8 @@ import { attempt } from "../../ui/toast"; /** * Asks before deleting a crew for good (spec 7.6), saying what goes and - * what stays. `bots` is how many bots go with it. + * 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, @@ -23,6 +25,19 @@ export function DeleteCrew({ 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 () => { - dropCrew((await api.call("crews.delete", { crewId: crew.id })).crewId); + 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)}

    - {words.deleteKept} + + {said} +
    ); } diff --git a/app/src/i18n/en/bots.ts b/app/src/i18n/en/bots.ts index f7fa547a..a8234f5e 100644 --- a/app/src/i18n/en/bots.ts +++ b/app/src/i18n/en/bots.ts @@ -77,6 +77,9 @@ export const bots = { `${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: { diff --git a/app/src/i18n/en/crews.ts b/app/src/i18n/en/crews.ts index 602873ea..b84f232e 100644 --- a/app/src/i18n/en/crews.ts +++ b/app/src/i18n/en/crews.ts @@ -71,6 +71,11 @@ export const crews = { : `${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", 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/bots.ts b/app/src/i18n/es/bots.ts index 71199fec..0cb7480a 100644 --- a/app/src/i18n/es/bots.ts +++ b/app/src/i18n/es/bots.ts @@ -73,6 +73,9 @@ export const bots: Messages["bots"] = { 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: { diff --git a/app/src/i18n/es/crews.ts b/app/src/i18n/es/crews.ts index 93279dcd..06d239c4 100644 --- a/app/src/i18n/es/crews.ts +++ b/app/src/i18n/es/crews.ts @@ -67,6 +67,11 @@ export const crews: Messages["crews"] = { : `${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", 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/bots.ts b/app/src/i18n/pt-BR/bots.ts index e1368803..8469dc91 100644 --- a/app/src/i18n/pt-BR/bots.ts +++ b/app/src/i18n/pt-BR/bots.ts @@ -73,6 +73,9 @@ export const bots: Messages["bots"] = { 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: { diff --git a/app/src/i18n/pt-BR/crews.ts b/app/src/i18n/pt-BR/crews.ts index 69a115d6..36ea056a 100644 --- a/app/src/i18n/pt-BR/crews.ts +++ b/app/src/i18n/pt-BR/crews.ts @@ -68,6 +68,11 @@ export const crews: Messages["crews"] = { : `${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", 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 43685160..6d350ba2 100644 --- a/app/src/lib/fake.ts +++ b/app/src/lib/fake.ts @@ -58,6 +58,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); @@ -183,6 +187,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; diff --git a/app/src/lib/fakeBots.ts b/app/src/lib/fakeBots.ts index 6057818a..449811d7 100644 --- a/app/src/lib/fakeBots.ts +++ b/app/src/lib/fakeBots.ts @@ -91,7 +91,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 +102,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/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 index 3f07db07..e6ed6ecc 100644 --- a/app/src/ui/KeptFolder.tsx +++ b/app/src/ui/KeptFolder.tsx @@ -1,13 +1,34 @@ import type { ReactNode } from "react"; -/** In a confirmation: what stays on disk, and the folder it is in. */ -export function KeptFolder({ path, children }: { path: string; children: ReactNode }) { +/** + * 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 && ( + + )}
    ); } From 61490b5b38808fcba2a15f84fdeb000cd704a571 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:38:41 -0300 Subject: [PATCH 07/15] test(platform): the Recycle Bin tests in a file of their own --- .../botloftd/src/platform/windows/recycle.rs | 136 +----------------- .../src/platform/windows/recycle/tests.rs | 136 ++++++++++++++++++ 2 files changed, 137 insertions(+), 135 deletions(-) create mode 100644 crates/botloftd/src/platform/windows/recycle/tests.rs diff --git a/crates/botloftd/src/platform/windows/recycle.rs b/crates/botloftd/src/platform/windows/recycle.rs index 57ecce7e..3027404c 100644 --- a/crates/botloftd/src/platform/windows/recycle.rs +++ b/crates/botloftd/src/platform/windows/recycle.rs @@ -215,138 +215,4 @@ impl IFileOperationProgressSink_Impl for Sink_Impl { } #[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::*; - use crate::platform::windows::CurrentUser; - - /// 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 - } - - /// 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 = dir.path().join("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 = dir.path().join("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 = dir.path().join("botloft-recycle-long"); - std::fs::create_dir(&folder).expect("folder"); - // The verbatim form of the path, which may pass 260 characters. - let top = std::fs::canonicalize(&folder).expect("verbatim path"); - 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"); - } - } - } -} +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..b2b1ec63 --- /dev/null +++ b/crates/botloftd/src/platform/windows/recycle/tests.rs @@ -0,0 +1,136 @@ +//! 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; + +/// 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 +} + +/// 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 = dir.path().join("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 = dir.path().join("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 = dir.path().join("botloft-recycle-long"); + std::fs::create_dir(&folder).expect("folder"); + // The verbatim form of the path, which may pass 260 characters. + let top = std::fs::canonicalize(&folder).expect("verbatim path"); + 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"); + } + } +} From 6d013257ecc2190f6daaba58df16cbd0690e5727 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:41:06 -0300 Subject: [PATCH 08/15] docs(spec): why the Recycle Bin move has a thread of its own --- docs/spec.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/spec.md b/docs/spec.md index 63f71de5..49a7a2b4 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -596,7 +596,7 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations | 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. 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 as threads do daemon são MTA), 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 | +| 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) | From 0f20b9f955058c03c96d136732470998a1d5f9e8 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:56:01 -0300 Subject: [PATCH 09/15] test(platform): look in the Recycle Bin for the long form of the path The bin records the long path, and the temporary folder of the CI runner comes in the short one (RUNNER~1), so the tests did not find the folder they had just moved. --- .../src/platform/windows/recycle/tests.rs | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/crates/botloftd/src/platform/windows/recycle/tests.rs b/crates/botloftd/src/platform/windows/recycle/tests.rs index b2b1ec63..3e794e29 100644 --- a/crates/botloftd/src/platform/windows/recycle/tests.rs +++ b/crates/botloftd/src/platform/windows/recycle/tests.rs @@ -6,6 +6,10 @@ 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)> { @@ -42,6 +46,15 @@ fn in_bin(original: &Path) -> Option<(PathBuf, PathBuf)> { 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) { @@ -52,7 +65,7 @@ fn purge(record: &Path, item: &Path) { #[test] fn a_folder_goes_to_the_bin_whole_and_can_be_found_there() { let dir = tempfile::tempdir().expect("tempdir"); - let folder = dir.path().join("botloft-recycle-test"); + 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"); @@ -72,7 +85,7 @@ fn a_folder_goes_to_the_bin_whole_and_can_be_found_there() { #[test] fn a_folder_with_a_file_in_use_stays_until_the_file_is_let_go() { let dir = tempfile::tempdir().expect("tempdir"); - let folder = dir.path().join("botloft-recycle-busy"); + 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"); @@ -105,10 +118,9 @@ fn a_folder_with_very_long_paths_is_moved_whole_or_not_at_all() { // 11 moves the folder whole; whatever a Windows does, nothing of it // may be lost. let dir = tempfile::tempdir().expect("tempdir"); - let folder = dir.path().join("botloft-recycle-long"); + let folder = place(&dir, "botloft-recycle-long"); std::fs::create_dir(&folder).expect("folder"); - // The verbatim form of the path, which may pass 260 characters. - let top = std::fs::canonicalize(&folder).expect("verbatim path"); + 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--"); From ad58bbc4701613a07ec9f6f6589a9711c82c153f Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:59:17 -0300 Subject: [PATCH 10/15] feat(daemon): a command carries what the bot says it is for Claude Code's Bash and PowerShell tools take an optional `description` next to `command`, written by the model, and it reaches the permission tool unchanged (checked with 2.1.284, spec 19). The daemon now passes it along as `explanation` on the tool and approval chat items, so the app can put plain words before the command. `summary` is the command itself again, as the spec says. The bot writes the explanation, so it never replaces the command: a command's input is kept up to 32 KB instead of 4 KB, for the owner to read all of what they allow, and Claude Code still gets back the input it asked about. The generated rules ask each bot to describe every command for an owner who may not be technical, in the language the owner writes in. Without the rule the text was terse and, on Sonnet, in English. The field stays optional: real Sonnet bots were seen leaving it out. --- app/src/features/chat/rows.test.ts | 1 + app/src/lib/fakeChat.ts | 16 ++- app/src/lib/protocol.gen.ts | 13 +- crates/botloft-core/src/chat.rs | 24 ++-- crates/botloft-core/src/command.rs | 88 +++++++++++++ crates/botloft-core/src/lib.rs | 1 + crates/botloft-core/src/protocol/chat.rs | 14 ++- crates/botloft-store/src/approvals.rs | 1 + crates/botloft-store/src/chat.rs | 2 + crates/botloftd/src/approvals/mod.rs | 16 ++- crates/botloftd/src/chat/events.rs | 2 + crates/botloftd/src/workspace/files.rs | 6 + crates/botloftd/tests/chat.rs | 3 +- crates/botloftd/tests/command_requests.rs | 146 ++++++++++++++++++++++ docs/spec.md | 25 ++-- 15 files changed, 333 insertions(+), 25 deletions(-) create mode 100644 crates/botloft-core/src/command.rs create mode 100644 crates/botloftd/tests/command_requests.rs diff --git a/app/src/features/chat/rows.test.ts b/app/src/features/chat/rows.test.ts index a5435a5b..f8dfa7a0 100644 --- a/app/src/features/chat/rows.test.ts +++ b/app/src/features/chat/rows.test.ts @@ -18,6 +18,7 @@ const tool = (name: string) => toolUseId: name, name, summary: "", + explanation: null, input: "{}", status: "done", output: null, diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts index 0db5c99b..b07784eb 100644 --- a/app/src/lib/fakeChat.ts +++ b/app/src/lib/fakeChat.ts @@ -38,12 +38,12 @@ export function activityLine( break; case "tool": kind = "tool"; - text = body.summary; + text = body.explanation ?? body.summary; tool = body.name; break; case "approval": kind = "approval"; - text = body.summary; + text = body.explanation ?? body.summary; tool = body.toolName; break; case "notice": @@ -93,6 +93,7 @@ export class FakeChat { toolUseId: this.fake.id("toolu"), name, summary: "", + explanation: null, input: "{}", status: "running", output: null, @@ -113,8 +114,14 @@ export class FakeChat { return this.add(botId, { kind: "turn", durationMs: 4200, costUsd: 0.0123, error }); } - /** A permission request waiting for the owner. */ - ask(botId: BotId, toolName: string, summary: string, input = "{}"): ChatItem { + /** A permission request waiting for the owner; `explanation` is what the bot says a command is for. */ + ask( + botId: BotId, + toolName: string, + summary: string, + input = "{}", + explanation: string | null = null, + ): ChatItem { const approval: Approval = { id: this.fake.id("apr"), botId, @@ -132,6 +139,7 @@ export class FakeChat { approvalId: approval.id, toolName, summary, + explanation, input, status: "pending", note: null, diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 1aa00419..af85bc34 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -603,6 +603,11 @@ export type ToolItem = { toolUseId: string, name: string, * One line about what the call does, e.g. the command or the file. */ summary: string, +/** + * What the bot says a command is for, in its own words (spec 10.1); + * absent when it said nothing, for other tools and for older items. + */ +explanation: string | null, /** * The call's input as JSON, cut at 4 KB. */ @@ -617,7 +622,13 @@ output: string | null, */ file: string | null, }; -export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, input: string, status: ApprovalStatus, +export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, +/** + * What the bot says the command is for, as in `ToolItem`. The bot + * wrote it: it goes before the command, never in its place. Kept only + * here, not in the `Approval`. + */ +explanation: string | null, input: string, status: ApprovalStatus, /** * What the owner wrote with a denial. */ diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs index cbfd3e32..30d123dd 100644 --- a/crates/botloft-core/src/chat.rs +++ b/crates/botloft-core/src/chat.rs @@ -3,6 +3,7 @@ use serde_json::Value; +use crate::command::{COMMAND_INPUT_MAX, is_command}; use crate::protocol::{Activity, ActivityKind, ChatBody, SenderKind}; /// Longest tool input kept, in bytes. @@ -57,6 +58,7 @@ pub fn tool_input_max(tool: &str) -> usize { match tool { PLAN_TOOL => PLAN_INPUT_MAX, SUGGEST_TOOL => SUGGESTION_INPUT_MAX, + tool if is_command(tool) => COMMAND_INPUT_MAX, _ => TOOL_INPUT_MAX, } } @@ -74,9 +76,8 @@ pub fn tool_summary(name: &str, input: &Value) -> String { .map(file_name) }; let summary = match name { - "Bash" | "PowerShell" => field(input, "description") - .map(str::to_owned) - .or_else(|| field(input, "command").map(str::to_owned)), + // The command itself; what the bot says about it is the explanation. + "Bash" | "PowerShell" => field(input, "command").map(str::to_owned), "Read" | "Edit" | "MultiEdit" | "Write" | "NotebookEdit" => path(), "Grep" | "Glob" => field(input, "pattern").map(str::to_owned), "WebFetch" => field(input, "url").map(str::to_owned), @@ -147,8 +148,15 @@ pub fn activity(body: &ChatBody, at: i64) -> Option { _ => (ActivityKind::Message, item.message.body.clone()), }, ChatBody::Reply(item) => (ActivityKind::Reply, item.text.clone()), - ChatBody::Tool(item) => (ActivityKind::Tool, item.summary.clone()), - ChatBody::Approval(item) => (ActivityKind::Approval, item.summary.clone()), + // A command reads better in the bot's words, when it gave them. + ChatBody::Tool(item) => ( + ActivityKind::Tool, + item.explanation.as_ref().unwrap_or(&item.summary).clone(), + ), + ChatBody::Approval(item) => ( + ActivityKind::Approval, + item.explanation.as_ref().unwrap_or(&item.summary).clone(), + ), ChatBody::Notice(item) => (ActivityKind::Notice, item.text.clone()), ChatBody::Turn(_) => return None, }; @@ -188,10 +196,10 @@ mod tests { "Bash", &json!({ "command": "npm test", "description": "Run the tests" }) ), - "Run the tests" + "npm test" ); assert_eq!( - tool_summary("Bash", &json!({ "command": "ls\n-la" })), + tool_summary("PowerShell", &json!({ "command": "ls\n-la" })), "ls -la" ); assert_eq!( @@ -254,6 +262,8 @@ mod tests { ), "Move the cache" ); + assert_eq!(tool_input_max("Bash"), COMMAND_INPUT_MAX); + assert_eq!(tool_input_max("Read"), TOOL_INPUT_MAX); assert_eq!(tool_label("mcp__botloft__send_message"), "send_message"); assert_eq!(tool_label("Bash"), "Bash"); } diff --git a/crates/botloft-core/src/command.rs b/crates/botloft-core/src/command.rs new file mode 100644 index 00000000..c6d96dfc --- /dev/null +++ b/crates/botloft-core/src/command.rs @@ -0,0 +1,88 @@ +//! Shell commands a bot runs (`Bash`, `PowerShell`): the one kind of tool +//! call whose input carries the bot's own words about it, which the owner +//! reads before allowing the command (spec 10.1). + +use serde_json::Value; + +use crate::chat::one_line; + +/// Longest command input kept, in bytes: the owner can read all of what +/// they allow, as with a plan. +pub const COMMAND_INPUT_MAX: usize = 32 * 1024; +/// Longest explanation, in characters. +const EXPLANATION_MAX_CHARS: usize = 300; + +/// Whether `tool` runs a shell command. +pub fn is_command(tool: &str) -> bool { + matches!(tool, "Bash" | "PowerShell") +} + +/// What the bot says a command is for: the `description` Claude Code's +/// shell tools take next to `command`, on one line. The model writes it and +/// may leave it out, and one that only repeats the command explains nothing. +pub fn tool_explanation(tool: &str, input: &Value) -> Option { + if !is_command(tool) { + return None; + } + let flat = |name: &str| { + let text = input.get(name).and_then(Value::as_str)?; + Some(text.split_whitespace().collect::>().join(" ")) + }; + let said = flat("description").filter(|said| !said.is_empty())?; + if flat("command").as_deref() == Some(said.as_str()) { + return None; + } + Some(one_line(&said, EXPLANATION_MAX_CHARS)) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn the_explanation_is_what_the_bot_wrote_about_a_command() { + let input = json!({ "command": "pip install requests", "description": "Instala o\npacote requests" }); + assert_eq!( + tool_explanation("Bash", &input).as_deref(), + Some("Instala o pacote requests") + ); + assert_eq!( + tool_explanation("PowerShell", &input).as_deref(), + Some("Instala o pacote requests") + ); + } + + #[test] + fn a_command_without_one_has_none() { + assert_eq!( + tool_explanation("Bash", &json!({ "command": "make" })), + None + ); + let blank = json!({ "command": "make", "description": " \n" }); + assert_eq!(tool_explanation("Bash", &blank), None); + let not_text = json!({ "command": "make", "description": 3 }); + assert_eq!(tool_explanation("Bash", ¬_text), None); + // Repeating the command is not explaining it. + let echo = json!({ "command": "npm run build", "description": "npm run build" }); + assert_eq!(tool_explanation("Bash", &echo), None); + } + + #[test] + fn other_tools_have_none() { + // A helper's `description` is its title, already the summary. + let helper = json!({ "description": "Find the endpoints", "prompt": "..." }); + assert_eq!(tool_explanation("Agent", &helper), None); + let file = json!({ "file_path": "a.md", "description": "x" }); + assert_eq!(tool_explanation("Write", &file), None); + } + + #[test] + fn a_long_one_is_cut() { + let input = json!({ "command": "make", "description": "a".repeat(500) }); + let said = tool_explanation("Bash", &input).expect("explanation"); + assert_eq!(said.chars().count(), EXPLANATION_MAX_CHARS); + assert!(said.ends_with('…')); + } +} diff --git a/crates/botloft-core/src/lib.rs b/crates/botloft-core/src/lib.rs index d29b5529..70d16923 100644 --- a/crates/botloft-core/src/lib.rs +++ b/crates/botloft-core/src/lib.rs @@ -3,6 +3,7 @@ pub mod avatar; pub mod chat; +pub mod command; pub mod envelope; pub mod ids; pub mod protocol; diff --git a/crates/botloft-core/src/protocol/chat.rs b/crates/botloft-core/src/protocol/chat.rs index 97d55450..910cb340 100644 --- a/crates/botloft-core/src/protocol/chat.rs +++ b/crates/botloft-core/src/protocol/chat.rs @@ -116,6 +116,10 @@ pub struct ToolItem { pub name: String, /// One line about what the call does, e.g. the command or the file. pub summary: String, + /// What the bot says a command is for, in its own words (spec 10.1); + /// absent when it said nothing, for other tools and for older items. + #[serde(default)] + pub explanation: Option, /// The call's input as JSON, cut at 4 KB. pub input: String, pub status: ToolStatus, @@ -134,6 +138,11 @@ pub struct ApprovalItem { pub approval_id: ApprovalId, pub tool_name: String, pub summary: String, + /// What the bot says the command is for, as in `ToolItem`. The bot + /// wrote it: it goes before the command, never in its place. Kept only + /// here, not in the `Approval`. + #[serde(default)] + pub explanation: Option, pub input: String, pub status: ApprovalStatus, /// What the owner wrote with a denial. @@ -264,14 +273,17 @@ mod tests { tool_use_id: "toolu_1".into(), name: "Bash".into(), summary: "npm test".into(), + explanation: None, input: "{}".into(), status: ToolStatus::Running, output: None, file: None, }); - let json = serde_json::to_value(&tool).expect("serialize"); + let mut json = serde_json::to_value(&tool).expect("serialize"); assert_eq!(json["kind"], "tool"); assert_eq!(json["toolUseId"], "toolu_1"); + // Items stored before a field existed still read. + json.as_object_mut().expect("object").remove("explanation"); assert_eq!(serde_json::from_value::(json).ok(), Some(tool)); } } diff --git a/crates/botloft-store/src/approvals.rs b/crates/botloft-store/src/approvals.rs index 72574322..ee668d51 100644 --- a/crates/botloft-store/src/approvals.rs +++ b/crates/botloft-store/src/approvals.rs @@ -135,6 +135,7 @@ mod tests { approval_id: approval.id.clone(), tool_name: approval.tool_name.clone(), summary: approval.summary.clone(), + explanation: None, input: approval.input.clone(), status: ApprovalStatus::Pending, note: None, diff --git a/crates/botloft-store/src/chat.rs b/crates/botloft-store/src/chat.rs index 7367e805..43cb4ee9 100644 --- a/crates/botloft-store/src/chat.rs +++ b/crates/botloft-store/src/chat.rs @@ -214,6 +214,7 @@ mod tests { tool_use_id: "toolu_7".into(), name: "Bash".into(), summary: "npm test".into(), + explanation: None, input: "{}".into(), status: ToolStatus::Running, output: None, @@ -254,6 +255,7 @@ mod tests { tool_use_id: "t".into(), name: "Write".into(), summary: String::new(), + explanation: None, input: "{}".into(), status, output: None, diff --git a/crates/botloftd/src/approvals/mod.rs b/crates/botloftd/src/approvals/mod.rs index bc0e4411..ff282bcc 100644 --- a/crates/botloftd/src/approvals/mod.rs +++ b/crates/botloftd/src/approvals/mod.rs @@ -9,6 +9,7 @@ use std::collections::HashMap; use std::sync::{Mutex, MutexGuard}; use botloft_core::chat::{SUGGEST_TOOL, clip, tool_input_max, tool_summary}; +use botloft_core::command::tool_explanation; use botloft_core::ids::{ApprovalId, BotId, ChatItemId}; use botloft_core::protocol::{ Approval, ApprovalItem, ApprovalStatus, ApprovalsAnswerParams, ChatBody, @@ -218,7 +219,8 @@ fn open( created_at: now, answered_at: None, }; - let item = items::add(daemon, bot, ChatBody::Approval(item_of(&approval)))?; + let shown = item_of(&approval, tool_explanation(tool_name, input)); + let item = items::add(daemon, bot, ChatBody::Approval(shown))?; let record = ApprovalRecord { approval, chat_item_id: item.id, @@ -231,11 +233,12 @@ fn open( Some(Pending { record }) } -fn item_of(approval: &Approval) -> ApprovalItem { +fn item_of(approval: &Approval, explanation: Option) -> ApprovalItem { ApprovalItem { approval_id: approval.id.clone(), tool_name: approval.tool_name.clone(), summary: approval.summary.clone(), + explanation, input: approval.input.clone(), status: approval.status, note: approval.note.clone(), @@ -270,7 +273,14 @@ fn settle( fn show(daemon: &Daemon, record: &ApprovalRecord) { let item: &ChatItemId = &record.chat_item_id; - items::update(daemon, item, ChatBody::Approval(item_of(&record.approval))); + // The bot's explanation is kept only in the chat item. + let shown = daemon.store().chat_item(item).ok().flatten(); + let explanation = match shown.map(|shown| shown.body) { + Some(ChatBody::Approval(shown)) => shown.explanation, + _ => None, + }; + let shown = item_of(&record.approval, explanation); + items::update(daemon, item, ChatBody::Approval(shown)); } /// Expires the request if the MCP call goes away before it is settled, diff --git a/crates/botloftd/src/chat/events.rs b/crates/botloftd/src/chat/events.rs index 72708710..991b3045 100644 --- a/crates/botloftd/src/chat/events.rs +++ b/crates/botloftd/src/chat/events.rs @@ -3,6 +3,7 @@ //! and grows between Claude Code versions. use botloft_core::chat::{TOOL_OUTPUT_MAX, clip, tool_file, tool_input_max, tool_summary}; +use botloft_core::command::tool_explanation; use botloft_core::ids::BotId; use botloft_core::protocol::{ AccountUsage, ChatBody, ChatDelta, NoticeCode, NoticeItem, NoticeLevel, ReplyItem, ToolItem, @@ -144,6 +145,7 @@ fn assistant(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { tool_use_id: block["id"].as_str().unwrap_or_default().to_owned(), name: name.to_owned(), summary: tool_summary(name, input), + explanation: tool_explanation(name, input), input: clip(&input.to_string(), tool_input_max(name)), status: ToolStatus::Running, output: None, diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index bbb5e4d3..63e15d8e 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -116,6 +116,11 @@ app; the other bots of your crew send you messages too. message lists them. Images also come inline. - When you need a permission, the owner gets a request in the chat with Allow and Deny. If they deny it, find another way or ask them. +- The owner may not be technical. Every time you run a command, fill in its + `description` for them: one short sentence, in the language the owner + writes to you in, that says what the command does and why you need it, + in everyday words, with no code, file paths or jargon. They read it to + decide whether to allow the command. ## Working with your crew @@ -230,6 +235,7 @@ mod tests { assert!(rules.contains("Ask the owner")); assert!(rules.contains("/ws/site/shared")); assert!(rules.contains("attachments/")); + assert!(rules.contains("fill in its\n `description` for them")); assert!(rules.contains("`browser_*` tools")); assert!(rules.contains(r#""#)); assert!(!rules.contains("You lead this crew")); diff --git a/crates/botloftd/tests/chat.rs b/crates/botloftd/tests/chat.rs index e1bccd8a..62c49e41 100644 --- a/crates/botloftd/tests/chat.rs +++ b/crates/botloftd/tests/chat.rs @@ -80,7 +80,8 @@ async fn what_the_bot_prints_becomes_the_chat() { assert_eq!(reply["activity"]["text"], "Running."); let running = kind(&mut c.app, "tool").await; assert_eq!(running["item"]["body"]["status"], "running"); - assert_eq!(running["item"]["body"]["summary"], "Run the tests"); + assert_eq!(running["item"]["body"]["summary"], "npm test"); + assert_eq!(running["item"]["body"]["explanation"], "Run the tests"); // The app names the tool in the owner's language. assert_eq!(running["activity"]["text"], "Run the tests"); assert_eq!(running["activity"]["tool"], "Bash"); diff --git a/crates/botloftd/tests/command_requests.rs b/crates/botloftd/tests/command_requests.rs new file mode 100644 index 00000000..780ebbf6 --- /dev/null +++ b/crates/botloftd/tests/command_requests.rs @@ -0,0 +1,146 @@ +//! Requests to run a command (spec 10.1): what the bot says the command is +//! for goes to the owner next to the command, never in its place, and the +//! command is kept whole. + +mod common; + +use common::bots::{Crew, two_bots}; +use common::mcp::Mcp; +use common::stream; +use serde_json::{Value, json}; + +/// The next `chat.item` of `kind`. +async fn kind(app: &mut common::Client, kind: &str) -> Value { + loop { + let changed = app.notification("chat.item").await; + if changed["item"]["body"]["kind"] == kind { + return changed; + } + } +} + +/// A crew whose @lead is in a turn, ready to use tools. +async fn working() -> Crew { + let mut c = two_bots().await; + let lead = c.app.call("bots.list", json!({})).await.expect("bots")[0].clone(); + c.app + .call( + "messages.send", + json!({ "botId": lead["id"], "body": "Go" }), + ) + .await + .expect("send"); + let lines = c.lead_process.wait_lines(1).await; + let line = lines.last().expect("line"); + c.lead_process.emit(stream::replay(line)).await; + c +} + +/// @lead calls `tool` with `input` and Claude Code asks for permission; the +/// decision comes out of the handle. +async fn ask(c: &Crew, id: &str, tool: &str, input: &Value) -> tokio::task::JoinHandle { + c.lead_process + .emit(stream::tool_use(id, tool, input.clone())) + .await; + let mut mcp = Mcp::new(c.lead.addr, c.lead.token.clone()); + let args = json!({ "tool_name": tool, "input": input, "tool_use_id": id }); + tokio::spawn(async move { mcp.tool("permission_prompt", args).await.expect("decision") }) +} + +#[tokio::test] +async fn the_bots_explanation_goes_with_the_command_and_what_runs_is_the_command() { + let mut c = working().await; + let input = json!({ + "command": "pip install requests", + "description": "Instala o pacote requests, para o script acessar páginas da internet", + }); + let decision = ask(&c, "toolu_1", "Bash", &input).await; + + let tool = kind(&mut c.app, "tool").await; + assert_eq!(tool["item"]["body"]["summary"], "pip install requests"); + assert_eq!(tool["item"]["body"]["explanation"], input["description"]); + let pending = kind(&mut c.app, "approval").await; + let body = &pending["item"]["body"]; + assert_eq!(body["summary"], "pip install requests"); + assert_eq!(body["explanation"], input["description"]); + assert_eq!(pending["activity"]["text"], input["description"]); + + c.app + .call( + "approvals.answer", + json!({ "approvalId": body["approvalId"], "allow": true }), + ) + .await + .expect("answer"); + // Claude Code gets back the input it asked about, untouched. + let outcome = decision.await.expect("task"); + assert_eq!( + outcome, + json!({ "behavior": "allow", "updatedInput": input }) + ); + // The answered request still says what the bot said. + let answered = kind(&mut c.app, "approval").await; + assert_eq!(answered["item"]["body"]["status"], "allowed"); + assert_eq!( + answered["item"]["body"]["explanation"], + input["description"] + ); +} + +#[tokio::test] +async fn a_command_without_an_explanation_has_none() { + let mut c = working().await; + let decision = ask( + &c, + "toolu_2", + "PowerShell", + &json!({ "command": "Get-Date" }), + ) + .await; + let pending = kind(&mut c.app, "approval").await; + let body = &pending["item"]["body"]; + assert_eq!(body["summary"], "Get-Date"); + assert_eq!(body["explanation"], Value::Null); + assert_eq!(pending["activity"]["text"], "Get-Date"); + c.app + .call( + "approvals.answer", + json!({ "approvalId": body["approvalId"], "allow": false }), + ) + .await + .expect("answer"); + assert_eq!(decision.await.expect("task")["behavior"], "deny"); +} + +#[tokio::test] +async fn a_long_command_reaches_the_owner_whole() { + let mut c = working().await; + // Longer than other tools' input is kept (4 KB), as a script in a + // heredoc is. + let script = format!("python - <<'EOF'\n{}EOF", "print('hello')\n".repeat(600)); + let input = json!({ "command": script, "description": "Gera a planilha" }); + let decision = ask(&c, "toolu_3", "Bash", &input).await; + for shown in ["tool", "approval"] { + let item = kind(&mut c.app, shown).await["item"]["body"].clone(); + let kept: Value = serde_json::from_str(item["input"].as_str().expect("input")) + .unwrap_or_else(|_| panic!("the {shown} item has the whole input")); + assert_eq!(kept["command"], script); + } + c.lead_process.exit(1).await; + decision.await.expect("task"); +} + +#[tokio::test] +async fn a_command_too_long_to_keep_is_cut_and_still_explained() { + let mut c = working().await; + let script = "echo hello\n".repeat(4000); + let input = json!({ "command": script, "description": "Gera a planilha" }); + let decision = ask(&c, "toolu_4", "Bash", &input).await; + let pending = kind(&mut c.app, "approval").await; + let body = &pending["item"]["body"]; + let kept = body["input"].as_str().expect("input"); + assert!(kept.len() <= 32 * 1024 && kept.ends_with('…')); + assert_eq!(body["explanation"], "Gera a planilha"); + c.lead_process.exit(1).await; + decision.await.expect("task"); +} diff --git a/docs/spec.md b/docs/spec.md index 7096697f..0c4a11df 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -129,7 +129,7 @@ O **handle** do bot (`@revisao`) é derivado do nome pela mesma regra, acompanha attachments\\ arquivos que o dono mandou no chat (9.5) .claude\ settings.json regra de negação para os segredos (gerado pelo daemon, sobrescrito a cada start) - rules\botloft.md identidade, papel, crew, pasta de trabalho, guia de uso das tools e, no chefe, como liderar (gerado pelo daemon) + rules\botloft.md identidade, papel, crew, pasta de trabalho, guia de uso das tools, como explicar cada comando ao dono (10.1) e, no chefe, como liderar (gerado pelo daemon) .botloft\ mcp.json config MCP do bot (gerado pelo daemon) ``` @@ -282,7 +282,7 @@ Uma linha JSON por evento, em UTF-8. Linha que não é JSON, ou maior que 8 MiB, | `user` com `isReplay: true` | a message com aquele `uuid` começou a ser processada: a delivery ganha `read_at` (9.1) | | `stream_event` com `text_delta` | texto ao vivo da resposta (`chat.delta`, 8.3); não é gravado | | `assistant`, bloco `text` | item `reply` com o texto (markdown) | -| `assistant`, bloco `tool_use` | item `tool` em `running`, com resumo da entrada | +| `assistant`, bloco `tool_use` | item `tool` em `running`, com resumo da entrada e, num comando, a explicação do bot (10.1) | | `user`, bloco `tool_result` | atualiza o item `tool` do mesmo `tool_use_id`: `done` ou `failed` e um trecho da saída | | `assistant` com `error` | item `notice` e, conforme o erro, estado `rate_limited` ou `auth_error` (7.2); `model_not_found` vira o aviso `model_unavailable` | | `rate_limit_event` | uso da conta (janelas de 5 h e 7 dias) em `system.status.usage`; status diferente de `allowed` leva a `rate_limited` | @@ -298,13 +298,13 @@ Todo item tem `id` (`cht_`), `botId`, `kind`, `createdAt` e `updatedAt`. |---|---|---| | `inbound` | `message` (a `Message`, com anexos) | criado junto com a message para o bot: dono, outro bot ou daemon | | `reply` | `text` | texto do bot | -| `tool` | `toolUseId`, `name`, `summary`, `input`, `status` (`running`, `done`, `failed`), `output`, `file` | ferramenta usada pelo bot; `file` é o caminho completo que uma chamada de `Write`, `Edit`, `MultiEdit` ou `NotebookEdit` altera (ausente nas outras e nos itens antigos) | -| `approval` | `approvalId`, `toolName`, `summary`, `input`, `status` (`pending`, `allowed`, `denied`, `expired`), `note` | pedido de permissão (10.1) | +| `tool` | `toolUseId`, `name`, `summary`, `explanation`, `input`, `status` (`running`, `done`, `failed`), `output`, `file` | ferramenta usada pelo bot; `explanation` é o que o bot diz que um comando faz (10.1; ausente nas outras ferramentas, quando o bot não disse nada e nos itens antigos); `file` é o caminho completo que uma chamada de `Write`, `Edit`, `MultiEdit` ou `NotebookEdit` altera (ausente nas outras e nos itens antigos) | +| `approval` | `approvalId`, `toolName`, `summary`, `explanation`, `input`, `status` (`pending`, `allowed`, `denied`, `expired`), `note` | pedido de permissão (10.1); `explanation` como no item `tool`, guardada só no item do chat | | `turn` | `durationMs`, `costUsd`, `error` | fim de um turno | | `notice` | `level` (`info`, `warning`, `error`), `code` (`signed_out`, `usage_limit`, `turn_failed`, `model_unavailable`; ausente em avisos antigos), `text` | avisos do daemon: limite de uso, login, turno com erro. O app escreve os avisos com `code` no idioma do dono; `text` fica em inglês para quem não conhece o código e, em `turn_failed`, traz o detalhe do erro | -- `summary` é um trecho curto tirado da entrada, sem palavras do daemon: o comando do `Bash`, o arquivo do `Read`/`Edit`/`Write`, o padrão do `Grep`/`Glob`, a URL do `WebFetch`, a busca do `WebSearch`, o destinatário do `send_message` (`@writer`), a tecla do `browser_press`. Fica vazio onde só palavras diriam algo (`TodoWrite`, `complete_task`, `ToolSearch` que carrega ferramentas, a direção do `browser_scroll`) e onde o trecho não diz nada ao dono (a `ref` de um elemento do navegador). O app escreve o nome da ferramenta como uma ação no idioma do dono ("Mandar uma mensagem", "Abrir uma página"; uma ferramenta que ele não conhece aparece pelo nome) e, nas que ficam vazias, o que der para dizer a partir da entrada (as ferramentas carregadas, a direção da rolagem). -- `input` guarda o JSON da entrada até 4 KB; `output`, até 8 KB de texto. O resto fica só no transcript do próprio Claude Code. +- `summary` é um trecho curto tirado da entrada, sem palavras do daemon: o comando do `Bash` e do `PowerShell` (o comando mesmo, nunca o que o bot diz dele: isso é a `explanation`), o arquivo do `Read`/`Edit`/`Write`, o padrão do `Grep`/`Glob`, a URL do `WebFetch`, a busca do `WebSearch`, o destinatário do `send_message` (`@writer`), a tecla do `browser_press`. Fica vazio onde só palavras diriam algo (`TodoWrite`, `complete_task`, `ToolSearch` que carrega ferramentas, a direção do `browser_scroll`) e onde o trecho não diz nada ao dono (a `ref` de um elemento do navegador). O app escreve o nome da ferramenta como uma ação no idioma do dono ("Mandar uma mensagem", "Abrir uma página"; uma ferramenta que ele não conhece aparece pelo nome) e, nas que ficam vazias, o que der para dizer a partir da entrada (as ferramentas carregadas, a direção da rolagem). +- `input` guarda o JSON da entrada até 4 KB (a de um comando, até 32 KB, para o dono ler inteiro o que permite, 10.1); `output`, até 8 KB de texto. O resto fica só no transcript do próprio Claude Code. - A resposta do bot (`reply`) é guardada inteira. - Uma message de um bot para outro aparece duas vezes: no chat de quem mandou, como o item `tool` do `send_message`; no chat de quem recebe, como `inbound`. @@ -445,6 +445,13 @@ Com `--permission-prompt-tool mcp__botloft__permission_prompt`, toda ferramenta "Permitir sempre" (gravar uma regra para o bot) fica para depois do MVP. +**Explicação do comando.** Um pedido para rodar um comando mostrava só o comando, e quem não lê comandos permitia sem saber o que era. A entrada do `Bash` e do `PowerShell` traz, ao lado de `command`, um campo opcional `description`, escrito pelo modelo, que chega igual à tool de aprovação (19). O daemon o passa adiante como `explanation` do item `tool` e do item `approval` (8.2): uma linha, até 300 caracteres; ausente se o campo não veio, veio vazio ou só repete o comando. + +- **Quem escreve é o bot.** A explicação vem antes do comando, nunca no lugar dele. O cartão (15.1) a mostra como texto puro, sem markdown nem links, diz que foi o bot que escreveu e que o que roda é o comando, e deixa o comando a um clique, inteiro e com as quebras de linha. Para isso a entrada de um comando é guardada até 32 KB; um comando maior aparece cortado, com o aviso de que não coube. O que o daemon devolve ao Claude Code é a entrada original, a mesma que o dono pôde abrir. +- **Sem explicação**, o cartão diz que o bot não disse para que serve o comando, sugere negar e perguntar, e já vem com o comando aberto. O campo é opcional e o modelo às vezes o deixa de fora (19), então esse caso continua existindo. +- **Regras do bot** (5.1): todo comando leva uma `description` para o dono, que pode não ser técnico: uma frase curta, na língua em que o dono escreve para o bot, dizendo o que o comando faz e por quê, em palavras do dia a dia, sem código, caminhos nem jargão. Sem a regra a descrição vinha curta e técnica, e às vezes em inglês (19). O daemon não conhece o idioma do app; quem acerta a língua é o bot, pela conversa. +- **Outras ferramentas** não têm explicação: a entrada delas não traz um texto escrito para quem lê. O pedido mostra a ação no idioma do dono (15.6) e o `summary` (o arquivo, a URL), com a entrada inteira em "Detalhes". Um pedido para escrever ou editar um arquivo mostra o nome do arquivo, e uma tela HTML aparece em rascunho na área de design antes do OK (22.3); plano, sugestão de bot e navegador têm cartões próprios. A `description` do `Agent` é o título do trabalho do ajudante e continua sendo o `summary`. + **Plano.** No modo `plan`, o bot pede para seguir com a ferramenta `ExitPlanMode`, cuja entrada traz o plano em markdown (`{plan}`). O pedido passa pela mesma tool e vira no chat um cartão com o plano inteiro: a entrada dessa ferramenta é guardada até 32 KB (as outras, até 4 KB) e o resumo é a primeira linha do plano. "Aprovar plano" permite; "Pedir mudanças" nega com a nota do dono, e o bot continua planejando. Depois de aprovado, o Claude Code troca de modo sozinho (7.4). Que o pedido passa pela tool em `-p` e para qual modo o bot vai ainda precisam de teste real (19). A aprovação só abre se `tool_use_id` for de uma ferramenta em `running` no chat daquele bot. O daemon espera até 2 s pelo evento, que às vezes chega depois da chamada. Senão nega na hora, sem incomodar o dono. Assim um bot que chame `permission_prompt` por conta própria não consegue pôr um pedido inventado na frente do dono. A descrição da tool também diz para não chamá-la. @@ -508,7 +515,7 @@ 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` 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 (num comando, com a explicação do bot, se ele deu uma, 10.1), com a ferramenta à parte em `tool`; o app completa no idioma do dono ("Mandar uma mensagem · @writer", "Aguardando aprovação: rodar um comando"). ### 11.3 Notificações do servidor @@ -632,6 +639,7 @@ Layout, como um app de mensagens: - **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". - **Sugestão de bot** (10.2): um cartão no chat do chefe, " sugere um bot novo", com o porquê e os campos editáveis Nome, Modelo, Função e Instruções, a linha de que o bot começa na hora e recebe o trabalho do chefe, um campo para dizer ao chefe por que não, e os botões Criar bot e Agora não. Respondido, vira uma linha ("Você criou Designer", "Você recusou Designer") que abre o que foi sugerido. +- **Pedido de permissão** (10.1): um cartão no chat, " quer " ("Scout quer rodar um comando"), com Permitir, Negar e um campo para dizer ao bot por que não. Num comando, o texto principal é a explicação do bot, em letra comum; embaixo, menor, " escreveu isto. O que roda de verdade é o comando abaixo.", e "Ver o comando", uma seta que abre o comando inteiro, em letra de código e com as quebras de linha. Sem explicação, o cartão diz " não disse para que serve este comando. Na dúvida, negue e pergunte." e já vem com o comando aberto. Um comando grande demais para guardar aparece até onde coube, com o aviso de que está cortado. Nas outras ferramentas, o resumo (o arquivo, a URL) e a entrada inteira em "Detalhes". Respondido, o cartão vira uma linha ("Permitido: rodar um comando") com a explicação ou, sem ela, o comando. A linha da ferramenta acima do cartão segue a mesma ideia: "Rodar um comando" e a explicação do bot (sem ela, o comando), e um clique abre o comando e a saída; a lista de conversas mostra o mesmo texto. - **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. Detalhes e arquivos são painéis laterais que o dono redimensiona arrastando a borda esquerda (ou com as setas do teclado, Shift anda mais; duplo clique volta ao padrão): 256 a 900 px, sempre deixando ao chat ao menos 22rem, e a largura de cada um fica no `localStorage` (`botloft.panel.details`, `botloft.panel.files`, `botloft.panel.browser`, `botloft.panel.screens`). O chat encolhe com eles e nunca passa por baixo nem por cima do painel. - **Arquivos do bot** (8.4): um botão "Mostrar arquivos" no cabeçalho abre, à direita do chat, um painel com o que o bot fez, do mais novo ao mais antigo (ícone pelo tipo, nome, pasta, tamanho, "há 5 min"). Um clique mostra o arquivo no próprio painel: imagem, PDF, markdown e texto aparecem; o resto diz que não tem prévia. "Abrir" usa o programa que o Windows escolheu e "Mostrar na pasta" abre o Explorer com o arquivo marcado. A lista é lida ao abrir o bot, a cada 4 s enquanto ele trabalha e de novo quando ele para. Com o painel fechado, o botão ganha um contador dos arquivos que apareceram desde a última vez que o dono olhou; abertos, esses levam a etiqueta "Novo". No chat, a linha de uma ferramenta que mudou um arquivo (`Write`, `Edit`...; item `tool` com `file`, 8.2) e não falhou ganha o botão "Ver em arquivos", que abre o painel direto na prévia desse arquivo; se ele não existe mais, o painel diz. Detalhes, arquivos, o navegador (21.8) e as telas (22.5) dividem o mesmo lugar: abrir um fecha o outro. O painel abre deslizando da borda direita (460 ms, sem pressa no começo) e fecha voltando para ela (360 ms) antes de sair, com o conteúdo na largura final o tempo todo e o chat acompanhando; quem troca de painel começa da largura do anterior. Fechando, o painel já não recebe clique nem leitor de tela. Com movimento reduzido, abre e fecha de uma vez. O painel segue o que o bot começa a fazer, para o dono ver acontecer: o navegador abre quando o bot sobe o navegador ou pede uma mão (21.8), as telas quando ele começa a escrever uma (22.5), no lugar do painel que estiver aberto e uma vez por começo, a menos que o dono tenha desligado isso nas Configurações. Fechado pelo dono, fica fechado, com o ponto no botão. O navegador nas mãos do dono (21.10) nunca é trocado. @@ -827,7 +835,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; itens mais novos trazem a própria data. "Confirmado" quer dizer documentado; o teste real na versão alvo continua no checklist do marco indicado. | Item | Seção | Resultado | Teste real | |---|---|---|---| @@ -837,6 +845,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Co | `--replay-user-messages` | 9.1 | Flag no `--help` sem detalhes na documentação. **Testado com 2.1.284**: devolve a mensagem com `isReplay: true` e o mesmo `uuid` quando o turno dela começa, não quando é lida | feito (M4.1) | | Eventos do `--output-format stream-json` | 8.1 | Parcialmente documentado (`headless`): `system/init`, `stream_event` com `text_delta` (exige `--verbose` e `--include-partial-messages`), `system/api_retry` com `error` (`rate_limit`, `authentication_failed`...), `result`. **Visto com 2.1.284**: também `rate_limit_event` (status, `resetsAt`, uso das janelas de 5 h e 7 dias), `system/post_turn_summary`, `system/task_summary`, `system/thinking_tokens`; erro de API vem em `assistant.error` e `result.terminal_reason` | feito (M4.1) | | `--permission-prompt-tool` | 10.1 | Confirmado (`cli-reference`, `headless`). **Testado com 2.1.284**: chama a tool com `{tool_name, input, tool_use_id}` e segue `{"behavior":"allow","updatedInput":...}`. Pelo daemon e pelo app: permitir depois de 95 s funcionou; negar com nota fez o bot citar a nota e não usar a ferramenta | feito (M4.1) | +| `description` na entrada do `Bash` e do `PowerShell` | 10.1 | Confirmado (`hooks`, entrada do `PreToolUse`): nas duas ferramentas, `description` é "Optional description of what the command does", ao lado de `command`, `timeout` e `run_in_background`. **Testado com 2.1.284** em 2026-09-30 (`-p` com uma tool de aprovação que só grava a entrada e nega; depois com a linha de comando de 7.4; Haiku 4.5, Sonnet 5.5 e o padrão do plano, Opus 5.5): a tool de aprovação recebe a mesma entrada do bloco `tool_use`, e os 32 pedidos de `Bash` e `PowerShell` vistos traziam `description`. Sem regra, o texto é curto e técnico ("Install requests package"); com o dono escrevendo em português, Haiku e Opus descreveram em português e o Sonnet em inglês, também pelo daemon. Com a regra de 10.1, os três escreveram em português, em palavras simples e com o porquê ("Instala o pacote requests, que ajuda o Python a acessar sites"). Pelo daemon e pelo app de dev (Sonnet, modo Manual): o cartão mostrou a explicação, "Ver o comando" abriu o script em heredoc inteiro, e permitir rodou esse comando; o log de `debug` não teve o texto. **O campo não é garantido**: em uso real com 2.1.284, dois bots em Sonnet fizeram 28 comandos sem `description` (o `tool_use` já vinha só com `command`), e os outros bots, quase todos em Opus, mandaram nos 15 que fizeram. A falta não se repetiu em teste, então não foi visto se a regra a evita: o cartão tem o caso sem explicação | feito; manual (PR): ver se bots antigos em Sonnet passam a explicar depois de reiniciar | | `--permission-prompts host` | 10.1 | Documentado só para o SDK. **Visto com 2.1.284**: sem o aperto de mão do SDK, nega tudo (`system/permission_denied`). Não usado | não se aplica | | `timeout` por servidor MCP | 10 | Confirmado (`env-vars`): HTTP tem 60 s por request e 5 min sem resposta por padrão; `timeout` >= 1000 no servidor sobe os dois. **Testado com 2.1.284**: a aprovação respondida depois de 95 s chegou ao bot | feito (M4.1) | | `--setting-sources project,local` | 7.4 | Confirmado (`cli-reference`). **Testado com 2.1.284**: sem os hooks, skills e agents do usuário; modo `default`; login da assinatura continua valendo | feito (M4.1) | From 755b86c95da60ae619f7740d62c42bd8cead8a81 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:59:27 -0300 Subject: [PATCH 11/15] feat(app): a command request leads with the bot's explanation The card for a request to run a command used to show the raw command, which an owner who does not read commands allowed without knowing what it did. It now shows what the bot says the command is for as its main text, says the bot wrote it and that what runs is the command, and keeps the whole command behind "See the command". With no explanation the card says so and opens the command. The tool line above the card shows the same words, and opens to the command itself instead of its JSON. Other tools keep their summary, with the input under "Details". The preview's seed has a long heredoc command with an explanation and a plain one without. --- app/src/dev/seedChats.ts | 28 +++++++-- app/src/features/chat/ApprovalCard.tsx | 82 ++++++++++++++++++++------ app/src/features/chat/ToolLines.tsx | 24 ++++++-- app/src/features/chat/chat.test.tsx | 74 +++++++++++++++++++++-- app/src/features/chat/command.ts | 31 ++++++++++ app/src/i18n/en/chat.ts | 7 ++- app/src/i18n/es/chat.ts | 8 ++- app/src/i18n/pt-BR/chat.ts | 7 ++- app/src/ui/Details.tsx | 20 +++++-- 9 files changed, 241 insertions(+), 40 deletions(-) create mode 100644 app/src/features/chat/command.ts diff --git a/app/src/dev/seedChats.ts b/app/src/dev/seedChats.ts index 91ac4bcc..f5af5f08 100644 --- a/app/src/dev/seedChats.ts +++ b/app/src/dev/seedChats.ts @@ -33,6 +33,18 @@ The report is rebuilt on every visit, and Monday is when everyone opens it. **Not changing:** the report's layout or its sources.`; +/** The kind of command an owner cannot read at a glance. */ +const CHECK_LINKS = `cd "C:/Work/Research" && python - <<'EOF' +import re, urllib.request + +text = open("week-39-report.md", encoding="utf-8").read() +for url in sorted(set(re.findall(r"https?://[^\\s)]+", text))): + try: + urllib.request.urlopen(url, timeout=10) + except Exception as error: + print("broken:", url, error) +EOF`; + interface Crew { scout: BotId; writer: BotId; @@ -126,12 +138,18 @@ export function seedChats(fake: FakeBotloft, crew: Crew): void { talk.read(check.delivery.id); at(39); chat.reply(crew.reviewer, "I'll run the link checker first, then compare each figure."); - const command = JSON.stringify({ - command: "npm run check-links", - description: "Check the links", + // A command with what the bot says it is for, and one without (spec 10.1). + const why = "Opens every link in the report draft and lists the ones that no longer work"; + const links = JSON.stringify({ command: CHECK_LINKS, description: why }); + const flat = CHECK_LINKS.split(/\s+/).join(" ").slice(0, 160); + chat.tool(crew.reviewer, "Bash", { summary: flat, explanation: why, input: links }); + chat.ask(crew.reviewer, "Bash", flat, links, why); + const figures = JSON.stringify({ command: "npm run compare-figures -- --section 2" }); + chat.tool(crew.reviewer, "Bash", { + summary: "npm run compare-figures -- --section 2", + input: figures, }); - chat.tool(crew.reviewer, "Bash", { summary: "Check the links", input: command }); - chat.ask(crew.reviewer, "Bash", "npm run check-links", command); + chat.ask(crew.reviewer, "Bash", "npm run compare-figures -- --section 2", figures); at(12); const late = talk.say({ from: crew.scout, diff --git a/app/src/features/chat/ApprovalCard.tsx b/app/src/features/chat/ApprovalCard.tsx index e7948b63..19505971 100644 --- a/app/src/features/chat/ApprovalCard.tsx +++ b/app/src/features/chat/ApprovalCard.tsx @@ -6,14 +6,25 @@ import { useId } from "react"; import { useT } from "../../i18n"; import type { ApprovalItem, Bot } from "../../lib/protocol.gen"; import { Button } from "../../ui/Button"; +import { Details } from "../../ui/Details"; import { HELP_TOOL, HelpCard } from "../browser/HelpCard"; import { SITE_TOOL, SiteCard } from "../browser/SiteCard"; +import { commandOf, isCommand } from "./command"; import { PLAN_TOOL, PlanCard } from "./PlanCard"; import { SUGGEST_TOOL, SuggestionCard } from "./SuggestionCard"; import { pretty } from "./ToolLines"; import { toolAction } from "./toolNames"; import { useAnswer } from "./useAnswer"; +/** What an answered request was about: the bot's words, or the command or file. */ +function About({ approval }: { approval: ApprovalItem }) { + return approval.explanation ? ( + {approval.explanation} + ) : ( + {approval.summary} + ); +} + function Answered({ approval }: { approval: ApprovalItem }) { const t = useT(); const label = toolAction(approval.toolName, t.tools); @@ -24,7 +35,7 @@ function Answered({ approval }: { approval: ApprovalItem }) {

    {t.chat.approval.allowed(label)} - {approval.summary} +

    ); case "denied": @@ -32,9 +43,11 @@ function Answered({ approval }: { approval: ApprovalItem }) {

    {t.chat.approval.denied(label)} - - {approval.note ? `“${approval.note}”` : approval.summary} - + {approval.note ? ( + {`“${approval.note}”`} + ) : ( + + )}

    ); default: @@ -62,6 +75,39 @@ export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: B } } +const BOX = + "max-h-56 overflow-auto break-all rounded-lg border border-line bg-sunken px-2.5 py-1.5"; + +/** + * A command the bot wants to run: first what the bot says it is for, in + * plain text, then the command itself, one click away. The bot wrote the + * explanation, so the card says so and never shows it in the command's + * place; with none, the command is open from the start (spec 10.1). + */ +function CommandAsked({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { + const t = useT(); + const said = approval.explanation; + const command = commandOf(approval.input); + return ( + <> + {said ? ( + <> +

    + {said} +

    +

    {t.chat.approval.explainedBy(bot.name)}

    + + ) : ( +

    {t.chat.approval.unexplained(bot.name)}

    + )} +
    +
    {command.text}
    + {!command.whole &&

    {t.chat.tools.commandCut}

    } +
    + + ); +} + function ToolApproval({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { const t = useT(); const { note, setNote, busy, answer } = useAnswer(approval, { @@ -85,22 +131,20 @@ function ToolApproval({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { {t.chat.approval.wants(bot.name, label)}

    - {approval.summary && ( -

    - {approval.summary} -

    + {isCommand(approval.toolName) ? ( + + ) : ( + <> + {approval.summary && ( +

    + {approval.summary} +

    + )} +
    +
    {pretty(approval.input)}
    +
    + )} -
    - - {t.chat.approval.fullInput} - -
    -          {pretty(approval.input)}
    -        
    -
    diff --git a/app/src/features/chat/ToolLines.tsx b/app/src/features/chat/ToolLines.tsx index 90c46dd7..7f9a9c9a 100644 --- a/app/src/features/chat/ToolLines.tsx +++ b/app/src/features/chat/ToolLines.tsx @@ -1,5 +1,6 @@ // What the bot did with its tools: one compact line per call (icon, tool, -// summary, state) that opens to show the input and the output (spec 15.3). +// what it is about, state) that opens to show the input, or the command, +// and the output (spec 15.3). import { Check, @@ -29,6 +30,7 @@ import { useArrival } from "../../ui/motion"; import { isBrowserTool, ShowBrowser } from "../browser/showBrowser"; import { ShowFile } from "../files/showFile"; import { isScreenFile, ShowScreen } from "../screens/showScreen"; +import { commandOf, isCommand } from "./command"; import { toolDetail, toolKey, toolTitle } from "./toolNames"; const ICONS: Record = { @@ -92,7 +94,11 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) { const showScreen = useContext(ShowScreen); const arrival = useArrival(createdAt); const title = toolTitle(tool.name, t.tools); - const detail = toolDetail(tool, t.tools); + // A command reads in the bot's own words, when it gave them; the command + // itself is one click away, below. + const said = tool.explanation; + const detail = said || toolDetail(tool, t.tools); + const command = isCommand(tool.name) ? commandOf(tool.input) : null; const Icon = iconOf(toolKey(tool.name)); const block = "max-h-64 overflow-auto whitespace-pre-wrap break-all rounded-lg border border-line bg-sunken px-2.5 py-1.5 font-mono text-xs leading-relaxed"; @@ -107,7 +113,10 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) { > {title} - + {detail} @@ -153,8 +162,13 @@ function ToolLine({ tool, createdAt }: { tool: ToolItem; createdAt: number }) { {open && (
    -

    {t.chat.tools.input}

    -
    {pretty(tool.input)}
    +

    + {command ? t.chat.tools.command : t.chat.tools.input} +

    +
    {command ? command.text : pretty(tool.input)}
    + {command && !command.whole && ( +

    {t.chat.tools.commandCut}

    + )} {tool.output !== null && ( <>

    diff --git a/app/src/features/chat/chat.test.tsx b/app/src/features/chat/chat.test.tsx index fd2f4b8a..a28f54d2 100644 --- a/app/src/features/chat/chat.test.tsx +++ b/app/src/features/chat/chat.test.tsx @@ -67,12 +67,16 @@ describe("chat", () => { let call = fake.chat.items[0]; act(() => { call = fake.chat.tool(scout.id, "Bash", { - summary: "Run the tests", - input: '{"command":"npm test"}', + summary: "npm test", + explanation: "Run the tests", + input: '{"command":"npm test","description":"Run the tests"}', }); }); const line = within(chat()).getByRole("button", { name: /Run a command/ }); + // The bot's words on the line; the command itself opens below. expect(within(line).getByText("Run the tests")).toBeDefined(); + expect(within(chat()).queryByText("npm test")).toBeNull(); + expect(within(sidebar()).getByText("Run a command · Run the tests")).toBeDefined(); expect(within(line).getByLabelText("Running")).toBeDefined(); act(() => { if (call) { @@ -84,7 +88,8 @@ describe("chat", () => { expect(within(line).getByLabelText("Done")).toBeDefined(); fireEvent.click(line); expect(within(chat()).getByText("12 passed")).toBeDefined(); - expect(within(chat()).getByText(/"command": "npm test"/)).toBeDefined(); + expect(within(chat()).getByText("Command")).toBeDefined(); + expect(within(chat()).getByText("npm test")).toBeDefined(); expect(within(chat()).getByText("Done in 4.2 s")).toBeDefined(); }); @@ -95,7 +100,10 @@ describe("chat", () => { fake.chat.ask(scout.id, "Bash", "rm -rf build", '{"command":"rm -rf build"}'); }); const card = screen.getByRole("region", { name: "Scout asks to run a command" }); - expect(within(card).getByText("rm -rf build")).toBeDefined(); + // The bot said nothing about it: the card says so and shows the command. + expect(within(card).getByText(/^Scout did not say what this command is for\./)).toBeDefined(); + const command = within(card).getByText("rm -rf build"); + expect(command.closest("details")?.open).toBe(true); fireEvent.change(within(card).getByLabelText("Note for Scout if you deny"), { target: { value: "keep the cache" }, }); @@ -114,6 +122,64 @@ describe("chat", () => { expect(await within(chat()).findByText("Allowed: write a file")).toBeDefined(); }); + test("a command request leads with the bot's explanation and keeps the command a click away", async () => { + const { fake, scout } = crew(); + await openScout(fake); + const script = "cd C:/Work && python - <<'EOF'\nprint('hello')\nEOF"; + const why = "Instala o pacote requests, para o script acessar páginas da internet"; + act(() => { + fake.chat.ask( + scout.id, + "Bash", + "cd C:/Work && python - <<'EOF' print('hello') EOF", + JSON.stringify({ command: script, description: why }), + why, + ); + }); + const card = screen.getByRole("region", { name: "Scout asks to run a command" }); + expect(within(card).getByText(why)).toBeDefined(); + // The bot wrote it, so the card says whose words these are and where + // the real thing is; the command is whole, with its line breaks. + expect( + within(card).getByText("Scout wrote this. What actually runs is the command below."), + ).toBeDefined(); + const folded = within(card).getByText("See the command").closest("details"); + expect(folded?.open).toBe(false); + expect(folded?.querySelector("div div")?.textContent).toBe(script); + expect(within(card).queryByText(/too long to show/)).toBeNull(); + fireEvent.click(within(card).getByRole("button", { name: "Allow" })); + const answered = await within(chat()).findByText("Allowed: run a command"); + expect(within(answered.closest("p") as HTMLElement).getByText(why)).toBeDefined(); + }); + + test("a command too long to keep says it is cut, and other tools keep their details", async () => { + const { fake, scout } = crew(); + await openScout(fake); + act(() => { + fake.chat.ask( + scout.id, + "PowerShell", + "Get-Date", + '{"command":"Get-Date; Get-Da…', + "Shows today", + ); + }); + const card = screen.getByRole("region", { name: "Scout asks to run a command" }); + expect(within(card).getByText("This command is too long to show in full.")).toBeDefined(); + expect(within(card).getByText('{"command":"Get-Date; Get-Da…')).toBeDefined(); + fireEvent.click(within(card).getByRole("button", { name: "Deny" })); + await within(chat()).findByText("Denied: run a command"); + + act(() => { + fake.chat.ask(scout.id, "WebFetch", "https://example.com", '{"url":"https://example.com"}'); + }); + const page = screen.getByRole("region", { name: "Scout asks to read a web page" }); + expect(within(page).getByText("https://example.com")).toBeDefined(); + const details = within(page).getByText("Details").closest("details"); + expect(details?.open).toBe(false); + expect(details?.textContent).toContain('"url": "https://example.com"'); + }); + test("files are picked or dropped, shown before sending, and sent with the text", async () => { const { fake } = crew(); await openScout(fake); diff --git a/app/src/features/chat/command.ts b/app/src/features/chat/command.ts new file mode 100644 index 00000000..2adaf97c --- /dev/null +++ b/app/src/features/chat/command.ts @@ -0,0 +1,31 @@ +// A shell command a bot runs or asks to run (spec 10.1). Its input holds +// the command and, maybe, what the bot says it is for; the daemon sends +// that apart, as the item's `explanation`. + +/** The tools that run a shell command. */ +export function isCommand(tool: string): boolean { + return tool === "Bash" || tool === "PowerShell"; +} + +export interface Command { + /** The command as the bot wrote it, line breaks and all. */ + text: string; + /** False when it was too long to keep and the end is missing. */ + whole: boolean; +} + +/** The command in a call's input. One cut short does not parse: it is shown as it came. */ +export function commandOf(input: string): Command { + try { + const parsed: unknown = JSON.parse(input); + if (typeof parsed === "object" && parsed !== null && "command" in parsed) { + const { command } = parsed; + if (typeof command === "string") { + return { text: command, whole: true }; + } + } + return { text: JSON.stringify(parsed, null, 2), whole: true }; + } catch { + return { text: input, whole: false }; + } +} diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index 30ef29c9..6f73b343 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -60,11 +60,16 @@ export const chat = { input: "Input", output: "Output", error: "Error", + command: "Command", + commandCut: "This command is too long to show in full.", }, approval: { asks: (bot: string, action: string) => `${bot} asks to ${action}`, wants: (bot: string, action: string) => `${bot} wants to ${action}`, - fullInput: "Full input", + explainedBy: (bot: string) => `${bot} wrote this. What actually runs is the command below.`, + unexplained: (bot: string) => + `${bot} did not say what this command is for. If you are not sure, deny it and ask.`, + command: "See the command", noteLabel: (bot: string) => `Note for ${bot} if you deny`, notePlaceholder: "Why not? Sent to the bot if you deny (optional)", allow: "Allow", diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index a69002a1..1cbf7ca1 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -65,11 +65,17 @@ export const chat: Messages["chat"] = { input: "Entrada", output: "Salida", error: "Error", + command: "Comando", + commandCut: "Este comando es demasiado largo para mostrarse entero.", }, approval: { asks: (bot: string, action: string) => `${bot} pide ${action}`, wants: (bot: string, action: string) => `${bot} quiere ${action}`, - fullInput: "Entrada completa", + explainedBy: (bot: string) => + `${bot} escribió esto. Lo que de verdad se ejecuta es el comando de abajo.`, + unexplained: (bot: string) => + `${bot} no dijo para qué sirve este comando. Si tienes dudas, deniégalo y pregunta.`, + command: "Ver el comando", noteLabel: (bot: string) => `Nota para ${bot} si deniegas`, notePlaceholder: "¿Por qué no? Se envía al bot si deniegas (opcional)", allow: "Permitir", diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index 62d4a9d1..8222e59c 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -65,11 +65,16 @@ export const chat: Messages["chat"] = { input: "Entrada", output: "Saída", error: "Erro", + command: "Comando", + commandCut: "Este comando é longo demais para aparecer inteiro.", }, approval: { asks: (bot: string, action: string) => `${bot} pede para ${action}`, wants: (bot: string, action: string) => `${bot} quer ${action}`, - fullInput: "Entrada completa", + explainedBy: (bot: string) => `${bot} escreveu isto. O que roda de verdade é o comando abaixo.`, + unexplained: (bot: string) => + `${bot} não disse para que serve este comando. Na dúvida, negue e pergunte.`, + command: "Ver o comando", noteLabel: (bot: string) => `Observação para ${bot} se você negar`, notePlaceholder: "Por que não? Vai para o bot se você negar (opcional)", allow: "Permitir", diff --git a/app/src/ui/Details.tsx b/app/src/ui/Details.tsx index eae6f086..8d0ad802 100644 --- a/app/src/ui/Details.tsx +++ b/app/src/ui/Details.tsx @@ -1,13 +1,25 @@ import type { ReactNode } from "react"; import { useT } from "../i18n"; -/** Technical text for troubleshooting, folded away under the plain message. */ -export function Details({ children }: { children: ReactNode }) { +/** + * Technical text folded away under the plain message: an error's detail for + * troubleshooting, or the command a bot wants to run. `label` names what is + * inside when "Details" would not; `open` shows it from the start. + */ +export function Details({ + children, + label, + open = false, +}: { + children: ReactNode; + label?: string; + open?: boolean; +}) { const t = useT(); return ( -

    +
    - {t.common.details} + {label ?? t.common.details}
    {children} From e7d1263b90275cfcaa38cd3cc956b84305263550 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:23:14 -0300 Subject: [PATCH 12/15] feat(daemon): effort per bot, the context in use and compacting the conversation Spec 7.4, 8.6, 9.2, 11.2, 11.3, 12 and the findings in section 19. bots.setEffort restarts the bot with --effort when nothing is in progress; the daemon asks Claude Code what the session applies and how full the conversation is (control requests on stdin), follows each model request, and bots.compact writes /compact. The fake daemon of the app knows the new fields and methods; the UI comes next. --- app/src/lib/fake.ts | 7 + app/src/lib/fakeBots.ts | 27 +- app/src/lib/protocol.gen.ts | 67 ++++- crates/botloft-core/src/chat.rs | 28 +- crates/botloft-core/src/protocol/chat.rs | 6 + crates/botloft-core/src/protocol/export.rs | 5 + .../src/protocol/export_session.rs | 30 ++ crates/botloft-core/src/protocol/mod.rs | 5 + crates/botloft-core/src/protocol/model.rs | 8 +- crates/botloft-core/src/protocol/session.rs | 102 +++++++ .../migrations/0010_bot_effort.sql | 5 + crates/botloft-store/src/bots.rs | 158 ++-------- crates/botloft-store/src/bots/tests.rs | 141 +++++++++ crates/botloft-store/src/lib.rs | 2 + crates/botloft-store/src/migrate.rs | 1 + crates/botloftd/src/chat/account.rs | 114 ++++++++ crates/botloftd/src/chat/control.rs | 57 ++++ crates/botloftd/src/chat/events.rs | 141 ++------- crates/botloftd/src/chat/mod.rs | 2 + crates/botloftd/src/context.rs | 269 ++++++++++++++++++ crates/botloftd/src/lib.rs | 1 + crates/botloftd/src/rpc/dispatch.rs | 2 + crates/botloftd/src/rpc/mod.rs | 1 + crates/botloftd/src/runtime/fake.rs | 40 ++- crates/botloftd/src/service/bots.rs | 10 +- crates/botloftd/src/service/models.rs | 71 ++++- crates/botloftd/src/state.rs | 8 +- crates/botloftd/src/supervisor/mod.rs | 13 + crates/botloftd/src/supervisor/relaunch.rs | 21 +- crates/botloftd/src/supervisor/slot.rs | 8 +- crates/botloftd/src/supervisor/spawn.rs | 17 +- crates/botloftd/src/workspace/files.rs | 2 + crates/botloftd/src/workspace/mod.rs | 2 + crates/botloftd/tests/bot_effort.rs | 172 +++++++++++ crates/botloftd/tests/common/context.rs | 99 +++++++ crates/botloftd/tests/common/mod.rs | 1 + crates/botloftd/tests/compaction.rs | 240 ++++++++++++++++ crates/botloftd/tests/context.rs | 97 +++++++ crates/botloftd/tests/rpc_effort_context.rs | 84 ++++++ docs/spec.md | 70 ++++- 40 files changed, 1860 insertions(+), 274 deletions(-) create mode 100644 crates/botloft-core/src/protocol/export_session.rs create mode 100644 crates/botloft-core/src/protocol/session.rs create mode 100644 crates/botloft-store/migrations/0010_bot_effort.sql create mode 100644 crates/botloft-store/src/bots/tests.rs create mode 100644 crates/botloftd/src/chat/account.rs create mode 100644 crates/botloftd/src/chat/control.rs create mode 100644 crates/botloftd/src/context.rs create mode 100644 crates/botloftd/tests/bot_effort.rs create mode 100644 crates/botloftd/tests/common/context.rs create mode 100644 crates/botloftd/tests/compaction.rs create mode 100644 crates/botloftd/tests/context.rs create mode 100644 crates/botloftd/tests/rpc_effort_context.rs diff --git a/app/src/lib/fake.ts b/app/src/lib/fake.ts index 82e5f6ce..b0c3a574 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, @@ -147,6 +148,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; diff --git a/app/src/lib/fakeBots.ts b/app/src/lib/fakeBots.ts index 914fa21b..2c23c51f 100644 --- a/app/src/lib/fakeBots.ts +++ b/app/src/lib/fakeBots.ts @@ -2,7 +2,7 @@ // handles, colors, archiving and the notifications each change sends. import type { FakeBotloft, Handlers } from "./fake"; -import { checkName } from "./fakeRules"; +import { checkName, conflict } from "./fakeRules"; import { AVATAR_PALETTE, type Bot } from "./protocol.gen"; type BotMethods = Extract; @@ -33,6 +33,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}`, @@ -66,9 +69,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); diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 1aa00419..44dc8d5a 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. @@ -592,7 +652,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, }; @@ -1010,6 +1070,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 }; @@ -1036,6 +1098,7 @@ export interface RpcNotifications { "message.created": Message; "delivery.changed": Delivery; "task.changed": Task; + "bot.context": BotContextChanged; "routine.changed": Routine; "routine.run": RoutineRun; "browser.changed": BrowserState; 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 7e5617a0..2a41262f 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::(); @@ -295,6 +298,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"); @@ -322,6 +326,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 43054c83..f0f7819e 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 @@ -95,6 +97,8 @@ pub mod method { 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_RESTART: &str = "bots.restart"; pub const CHAT_HISTORY: &str = "chat.history"; @@ -130,6 +134,7 @@ pub mod notification { pub const CREW_CHANGED: &str = "crew.changed"; pub const BOT_CHANGED: &str = "bot.changed"; 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 7d890865..8241cde3 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. 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 ff856452..dc666d89 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -149,6 +149,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/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 72708710..21dfe30f 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"), } @@ -109,7 +97,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) @@ -117,9 +105,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") => { @@ -156,59 +149,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) { let Some(uuid) = event["uuid"].as_str() else { @@ -270,38 +210,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) { let failed = event["is_error"].as_bool() == Some(true); let error = failed.then(|| { @@ -311,15 +219,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 bca27b17..ed5103fe 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; 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..29199663 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; diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 4f62a504..15ea6f81 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -36,6 +36,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_RESTART => reply(bots::restart(daemon, parse(params)?)), method::CHAT_HISTORY => reply(chat::history(daemon, parse(params)?)), diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index b5105ef1..f96d0f67 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -201,6 +201,7 @@ fn to_notification(event: &Event) -> String { Event::CrewChanged(crew) => (notification::CREW_CHANGED, serde_json::to_value(crew)), Event::BotChanged(bot) => (notification::BOT_CHANGED, serde_json::to_value(bot)), 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/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/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 f947f00a..5ee65682 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -5,8 +5,8 @@ use std::sync::{Arc, Mutex, MutexGuard}; use std::time::Instant; use botloft_core::protocol::{ - AccountUsage, Bot, BotStateChanged, BrowserAction, BrowserState, ChatDelta, ChatItemChanged, - Crew, Delivery, Message, Routine, RoutineRun, ScreenDraft, Task, + AccountUsage, Bot, BotContextChanged, BotStateChanged, BrowserAction, BrowserState, ChatDelta, + ChatItemChanged, Crew, Delivery, Message, Routine, RoutineRun, ScreenDraft, Task, }; use botloft_store::Store; use tokio::sync::broadcast; @@ -15,6 +15,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; @@ -32,6 +33,7 @@ pub enum Event { CrewChanged(Crew), BotChanged(Bot), BotState(BotStateChanged), + BotContext(BotContextChanged), ChatItem(ChatItemChanged), ChatDelta(ChatDelta), MessageCreated(Message), @@ -95,6 +97,7 @@ pub struct Daemon { pub routines: Routines, pub browsers: Browsers, pub screens: Screens, + pub contexts: Contexts, /// Time for everything stored or compared with stored times. pub clock: Arc, store: Mutex, @@ -130,6 +133,7 @@ impl Daemon { Arc::clone(&options.clock), ), screens: Screens::default(), + contexts: Contexts::default(), clock: options.clock, paths: options.paths, port: options.port, diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs index 0a4f83c0..031533f7 100644 --- a/crates/botloftd/src/supervisor/mod.rs +++ b/crates/botloftd/src/supervisor/mod.rs @@ -170,6 +170,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 0ff7db43..765c11ea 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"; @@ -62,6 +62,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); sessions.insert(bot.id.clone(), launch.session.clone()); slot.generation = Some(generation); slot.restart_at = None; @@ -76,6 +82,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; @@ -152,6 +159,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(); } @@ -219,6 +227,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/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/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/bot_effort.rs b/crates/botloftd/tests/bot_effort.rs new file mode 100644 index 00000000..e7c25008 --- /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 = arg_after(&first, "--session-id").expect("session"); + + 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 8d14da67..bfd7e445 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; diff --git a/crates/botloftd/tests/compaction.rs b/crates/botloftd/tests/compaction.rs new file mode 100644 index 00000000..67563333 --- /dev/null +++ b/crates/botloftd/tests/compaction.rs @@ -0,0 +1,240 @@ +//! 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; + 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/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 7096697f..b39463af 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -238,6 +238,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 ``` @@ -249,6 +250,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. - `--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. @@ -286,7 +289,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. @@ -301,7 +308,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. @@ -326,6 +333,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 @@ -357,6 +399,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 @@ -491,6 +542,8 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `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` | | `chat.history` | `botId, before?, limit?` | `ChatItem[]`, mais novo primeiro; `limit` de 1 a 200, 50 se ausente | @@ -508,11 +561,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`, `crew.changed`, `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), `crew.changed`, `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 @@ -533,7 +586,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` | @@ -827,7 +880,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 | |---|---|---|---| @@ -867,6 +920,11 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Co | Rascunho de tela a partir do `Write` | 22.3 | **Visto com 2.1.284**: com `--include-partial-messages`, o `Write` chega em `stream_event` (`content_block_start` com `tool_use` e `name: "Write"`, depois `input_json_delta`; 379 pedaços para um arquivo de 3 KB). **Testado com 2.1.284** (daemon e app de dev, Haiku, modo Manual): pedida uma landing page, a área de design abriu sozinha e a página se montou versão a versão (umas 70 de 8 KB) antes do OK para gravar; permitido, a tela passou a vir do disco. Numa segunda página, negar o `Write` tirou a tela da área de design. O log de `debug` só teve status e ids. O painel do navegador do app de desktop do Claude bloqueia `iframe` para 127.0.0.1 (`ERR_BLOCKED_BY_CLIENT`), então a prévia foi vista num Edge sem janela | feito | | 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 | | 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. From 84f3b74bb58d518c85d224c14db5bc7110d17de0 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 12:46:48 -0300 Subject: [PATCH 13/15] feat(app): an effort slider and the conversation's space below the chat Spec 15.1. Next to the model, a slider from faster to smarter sets the bot's effort, with the level its model uses by itself marked as the recommended one; a model without levels says so. A ring shows how full the conversation is, how far it is from compacting by itself, and compacts it now. Compactions read as notices in the owner's language. In a narrow chat the controls on the right move to their own line. --- app/src/dev/preview.ts | 8 + app/src/dev/seedContext.ts | 48 ++++++ app/src/effort.css | 44 ++++++ app/src/features/chat/ChatComposer.tsx | 42 +++-- app/src/features/chat/ContextMeter.tsx | 140 +++++++++++++++++ app/src/features/chat/EffortPicker.tsx | 208 +++++++++++++++++++++++++ app/src/features/chat/ModelPicker.tsx | 8 +- app/src/features/chat/NoticeRow.tsx | 6 + app/src/features/chat/context.test.tsx | 143 +++++++++++++++++ app/src/features/chat/effort.test.tsx | 131 ++++++++++++++++ app/src/i18n/en/chat.ts | 57 +++++++ app/src/i18n/es/chat.ts | 56 +++++++ app/src/i18n/pt-BR/chat.ts | 57 +++++++ app/src/index.css | 1 + app/src/lib/fakeChat.ts | 8 + app/src/lib/format.ts | 16 ++ app/src/store/app.ts | 7 + docs/spec.md | 3 + 18 files changed, 962 insertions(+), 21 deletions(-) create mode 100644 app/src/dev/seedContext.ts create mode 100644 app/src/effort.css create mode 100644 app/src/features/chat/ContextMeter.tsx create mode 100644 app/src/features/chat/EffortPicker.tsx create mode 100644 app/src/features/chat/context.test.tsx create mode 100644 app/src/features/chat/effort.test.tsx diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts index 78c9a345..f8f95d3d 100644 --- a/app/src/dev/preview.ts +++ b/app/src/dev/preview.ts @@ -13,6 +13,7 @@ import { PROTOCOL_VERSION } from "../lib/protocol.gen"; import { seedBrowser } from "./seedBrowser"; import { seedChats } from "./seedChats"; import { seedChief } from "./seedChief"; +import { seedContext } from "./seedContext"; import { seedHands } from "./seedHands"; import { seedRoutines } from "./seedRoutines"; import { seedScreens } from "./seedScreens"; @@ -113,6 +114,13 @@ function seed(fake: FakeBotloft): void { analyst: analyst.id, planner: planner.id, }); + seedContext(fake, { + scout: scout.id, + writer: writer.id, + reviewer: reviewer.id, + analyst: analyst.id, + planner: planner.id, + }); seedRoutines(fake, { scout: scout.id, analyst: analyst.id }); seedBrowser(fake, scout.id); seedScreens(fake, research.id); diff --git a/app/src/dev/seedContext.ts b/app/src/dev/seedContext.ts new file mode 100644 index 00000000..8511d47a --- /dev/null +++ b/app/src/dev/seedContext.ts @@ -0,0 +1,48 @@ +// The preview's effort and conversation space (spec 7.4, 8.6): what Claude +// Code would have reported for each bot, and a compaction that ends a +// moment after it is asked for. + +import type { FakeBotloft } from "../lib/fake"; +import type { BotId, ContextUsage } from "../lib/protocol.gen"; + +const COMPACTION_MS = 2500; + +const holds = (usedTokens: number, windowTokens: number): ContextUsage => ({ + usedTokens, + windowTokens, + autoCompactTokens: windowTokens - 33_000, + compacting: false, + updatedAt: Date.now(), +}); + +export function seedContext( + fake: FakeBotloft, + bots: { scout: BotId; writer: BotId; reviewer: BotId; analyst: BotId; planner: BotId }, +): void { + for (const id of [bots.scout, bots.writer, bots.reviewer, bots.planner]) { + fake.bot(id).effortDefault = "medium"; + } + // Haiku takes no effort level. + fake.bot(bots.analyst).effortDefault = "none"; + fake.bot(bots.reviewer).effort = "high"; + fake.setContext(bots.scout, holds(556_000, 1_000_000)); + fake.setContext(bots.writer, holds(912_400, 1_000_000)); + fake.setContext(bots.reviewer, holds(61_800, 1_000_000)); + fake.setContext(bots.analyst, holds(24_300, 200_000)); + + fake.subscribe((event) => { + if (event.name !== "bot.context" || !event.params.context?.compacting) { + return; + } + const { botId, context } = event.params; + setTimeout(() => { + fake.chat.add(botId, { + kind: "notice", + level: "info", + code: "compacted", + text: "The conversation was compacted: earlier messages are now a summary.", + }); + fake.setContext(botId, holds(24_000, context.windowTokens)); + }, COMPACTION_MS); + }); +} diff --git a/app/src/effort.css b/app/src/effort.css new file mode 100644 index 00000000..8aeecc61 --- /dev/null +++ b/app/src/effort.css @@ -0,0 +1,44 @@ +/* The effort slider below the chat (spec 15.1): five stops from faster to + smarter on a thin track, with a round thumb. */ + +.effort-slider { + appearance: none; + display: block; + width: 100%; + height: 1.25rem; + margin: 0; + background: transparent; + cursor: pointer; +} + +.effort-slider::-webkit-slider-runnable-track { + height: 4px; + border-radius: 999px; + background: var(--line-strong); +} + +.effort-slider::-webkit-slider-thumb { + appearance: none; + width: 16px; + height: 16px; + margin-top: -6px; + border: 2px solid var(--panel); + border-radius: 999px; + background: var(--ink); + box-shadow: 0 1px 3px var(--lift); + transition: transform 120ms ease-out; +} + +.effort-slider:active::-webkit-slider-thumb { + transform: scale(1.15); +} + +/* No level picked and the model's own not known yet: the thumb only waits. */ +.effort-slider[data-unset]::-webkit-slider-thumb { + background: var(--muted); +} + +.effort-slider:disabled { + cursor: default; + opacity: 0.45; +} diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx index fc524195..a7b6b107 100644 --- a/app/src/features/chat/ChatComposer.tsx +++ b/app/src/features/chat/ChatComposer.tsx @@ -20,6 +20,8 @@ import { fileSize } from "../../lib/format"; import { type Bot, FIELD_LIMITS } from "../../lib/protocol.gen"; import { prefs, usePref } from "../../shell/prefs"; import { useApi } from "../../store/context"; +import { ContextMeter } from "./ContextMeter"; +import { EffortPicker } from "./EffortPicker"; import { rememberImage } from "./images"; import { ModelPicker } from "./ModelPicker"; import { ModePicker } from "./ModePicker"; @@ -70,7 +72,7 @@ export function ChatComposer({ const t = useT(); const [text, setText] = useState(""); const [busy, setBusy] = useState(false); - /** A new mode or model that waits for the bot to finish what it is doing. */ + /** A new mode, model or effort, or a compaction, that waits for the bot to finish what it is doing. */ const [later, setLater] = useState(null); const field = useRef(null); const picker = useRef(null); @@ -159,7 +161,7 @@ export function ChatComposer({ {later}

    )} -
    +
    {files.files.length > 0 && (
      {files.files.map((file) => ( @@ -181,7 +183,9 @@ export function ChatComposer({ placeholder={t.chat.composer.placeholder(bot.name)} className="block max-h-60 w-full resize-none bg-transparent px-4 pt-3.5 pb-1.5 leading-relaxed outline-none placeholder:text-muted" /> -
      + {/* The meter and the effort open over the row's right edge. In a + narrow chat, what is on the right moves to a line of its own. */} +
      +
      + + + + +
      {files.error && ( diff --git a/app/src/features/chat/ContextMeter.tsx b/app/src/features/chat/ContextMeter.tsx new file mode 100644 index 00000000..5ff66eb7 --- /dev/null +++ b/app/src/features/chat/ContextMeter.tsx @@ -0,0 +1,140 @@ +// How full the bot's conversation is (spec 8.6): a ring below the chat +// that opens the numbers, how far it is from compacting by itself, and +// the button to compact it now. + +import { useCallback, useRef, useState } from "react"; +import { useT } from "../../i18n"; +import { tokens } from "../../lib/format"; +import type { Bot } from "../../lib/protocol.gen"; +import { useApi, useApp } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { POPOVER } from "../../ui/surface"; +import { attempt } from "../../ui/toast"; +import { useDismiss } from "../../ui/useDismiss"; + +const RADIUS = 7; +const AROUND = 2 * Math.PI * RADIUS; +/** From this share of the way to compacting by itself, the ring warns. */ +const NEAR = 0.8; + +export function ContextMeter({ bot, onLater }: { bot: Bot; onLater(text: string): void }) { + const api = useApi(); + const putBot = useApp((state) => state.putBot); + const m = useT().chat.context; + const [open, setOpen] = useState(false); + const root = useRef(null); + const close = useCallback(() => setOpen(false), []); + useDismiss(open, root, close); + + const context = bot.context; + if (!context) { + return null; + } + const { usedTokens, windowTokens, autoCompactTokens, compacting } = context; + const share = Math.min(1, usedTokens / windowTokens); + const percent = Math.round(share * 100); + const used = tokens(usedTokens); + const total = tokens(windowTokens); + const limit = autoCompactTokens ?? windowTokens; + const near = usedTokens >= limit * NEAR; + const left = autoCompactTokens === null ? null : autoCompactTokens - usedTokens; + const running = bot.state === "idle" || bot.state === "busy" || bot.state === "needs_approval"; + + const compact = async () => { + // It waits its turn behind what the bot is doing. + const working = bot.state === "busy" || bot.state === "needs_approval"; + await attempt(m.failed, async () => { + putBot(await api.call("bots.compact", { botId: bot.id })); + if (working) { + onLater(m.later(bot.name)); + } + }); + }; + + return ( +
      + {open && ( +
      +

      {m.title}

      +

      + {m.used(used, total, percent)} +

      +
      +
      + {autoCompactTokens !== null && ( + + )} +
      +

      + {left === null + ? m.noAuto + : left > 0 + ? m.autoLeft(tokens(left), tokens(autoCompactTokens ?? 0)) + : m.autoNow} +

      +

      {m.about(bot.name)}

      +
      + +

      + {running || compacting ? m.compactHint(bot.name) : m.notRunning(bot.name)} +

      +
      +
      + )} + +
      + ); +} diff --git a/app/src/features/chat/EffortPicker.tsx b/app/src/features/chat/EffortPicker.tsx new file mode 100644 index 00000000..aac55d07 --- /dev/null +++ b/app/src/features/chat/EffortPicker.tsx @@ -0,0 +1,208 @@ +// How much the bot thinks before it answers (spec 7.4), on a slider below +// the chat as in Claude's apps: faster on the left, smarter on the right, +// with the level its model uses by itself marked as the recommended one. + +import { ChevronDown, Gauge } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useT } from "../../i18n"; +import { modelName } from "../../lib/models"; +import type { Bot, BotEffort } from "../../lib/protocol.gen"; +import { useApi, useApp } from "../../store/context"; +import { POPOVER } from "../../ui/surface"; +import { attempt } from "../../ui/toast"; +import { useDismiss } from "../../ui/useDismiss"; + +const LEVELS = ["low", "medium", "high", "xhigh", "max"] as const; +type Level = (typeof LEVELS)[number]; +const LAST = LEVELS.length - 1; +/** Where the "Recommended" word sits under the first and the last stop. */ +const EDGE: Record = { 0: "-4px", [LAST]: "calc(-100% + 4px)" }; +/** How long the slider rests on a stop before the bot is told. */ +const SETTLE_MS = 400; + +const isLevel = (value: string | null): value is Level => + value !== null && (LEVELS as readonly string[]).includes(value); + +export function EffortPicker({ bot, onLater }: { bot: Bot; onLater(text: string): void }) { + const api = useApi(); + const putBot = useApp((state) => state.putBot); + const m = useT().chat.effort; + const [open, setOpen] = useState(false); + /** Where the owner left the slider, until the bot has it. */ + const [draft, setDraft] = useState(null); + const root = useRef(null); + const settle = useRef | undefined>(undefined); + + /** The level the bot's model uses by itself, once Claude Code said it. */ + const recommended = isLevel(bot.effortDefault) ? bot.effortDefault : null; + const unavailable = bot.effortDefault === "none"; + const chosen = bot.effort === "default" ? recommended : bot.effort; + const shown = draft ?? chosen; + const label = unavailable ? m.unavailable : shown ? m.names[shown] : m.recommended; + + const set = async (effort: BotEffort, name: string) => { + if (effort !== bot.effort) { + // The bot restarts on the new level once nothing is in progress. + const working = bot.state === "busy" || bot.state === "needs_approval"; + await attempt(m.failed, async () => { + putBot(await api.call("bots.setEffort", { botId: bot.id, effort })); + if (working) { + onLater(m.later(bot.name, name)); + } + }); + } + setDraft(null); + }; + // The recommended stop follows the model: it is the bot's default. + const commit = (level: Level) => set(level === recommended ? "default" : level, m.names[level]); + const latest = useRef(commit); + latest.current = commit; + const pending = useRef(null); + + const flush = useCallback(() => { + clearTimeout(settle.current); + const level = pending.current; + pending.current = null; + if (level) { + void latest.current(level); + } + }, []); + // A stop the owner left the slider on is not lost with the menu. + useEffect(() => flush, [flush]); + + const close = useCallback(() => { + flush(); + setOpen(false); + }, [flush]); + useDismiss(open, root, close); + + const slide = (index: number) => { + const level = LEVELS[index]; + if (!level) { + return; + } + setDraft(level); + pending.current = level; + clearTimeout(settle.current); + settle.current = setTimeout(flush, SETTLE_MS); + }; + + const model = bot.modelInUse ? modelName(bot.modelInUse) : null; + const at = (level: Level) => LEVELS.indexOf(level); + + return ( +
      + {open && ( +
      +

      {m.title}

      + {unavailable ? ( +

      {m.none(bot.name, model ?? m.thisModel)}

      + ) : ( + <> +
      + {m.faster} + {m.smarter} +
      + slide(Number(event.target.value))} + className="effort-slider mt-1" + /> + {/* The stops sit under where the thumb's center goes. */} +
      + {LEVELS.map((level, index) => ( + + ))} + {recommended && ( + + {m.recommended} + + )} +
      +

      + {shown ? m.names[shown] : m.recommended} + {shown && shown === recommended && ( + + {" · "} + {model ? m.recommendedFor(model) : m.recommended} + + )} +

      +

      + {shown ? m.hints[shown](bot.name) : m.unknown(bot.name)} +

      + + )} +
      +

      {m.cost}

      + {bot.effort !== "default" && !unavailable && ( + + )} +
      +
      + )} + +
      + ); +} diff --git a/app/src/features/chat/ModelPicker.tsx b/app/src/features/chat/ModelPicker.tsx index f0d37a12..5c5ba63d 100644 --- a/app/src/features/chat/ModelPicker.tsx +++ b/app/src/features/chat/ModelPicker.tsx @@ -52,7 +52,7 @@ export function ModelPicker({ bot, onLater }: { bot: Bot; onLater(text: string): model === "default" ? m.hints.default(bot.name, inUse) : m.hints[model](bot.name); return ( -
      +
      {open && (
      setOpen(!open)} - className={`flex h-8 items-center gap-1 rounded-full px-2.5 text-sm text-ink-soft transition-colors hover:bg-sunken hover:text-ink ${ + className={`flex h-8 max-w-full items-center gap-1 rounded-full px-2.5 text-sm text-ink-soft transition-colors hover:bg-sunken hover:text-ink ${ open ? "bg-sunken text-ink" : "" }`} > - {label} + {label}
      diff --git a/app/src/features/chat/NoticeRow.tsx b/app/src/features/chat/NoticeRow.tsx index 8a0f2d67..65cfc673 100644 --- a/app/src/features/chat/NoticeRow.tsx +++ b/app/src/features/chat/NoticeRow.tsx @@ -41,6 +41,12 @@ function noticeText(notice: NoticeItem, words: Messages["chat"]["notice"]): stri return words.turnFailed(notice.text); case "model_unavailable": return words.modelUnavailable; + case "compacted": + return words.compacted; + case "auto_compacted": + return words.autoCompacted; + case "compact_failed": + return words.compactFailed(notice.text); default: return notice.text; } diff --git a/app/src/features/chat/context.test.tsx b/app/src/features/chat/context.test.tsx new file mode 100644 index 00000000..ead40c34 --- /dev/null +++ b/app/src/features/chat/context.test.tsx @@ -0,0 +1,143 @@ +import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { tokens } from "../../lib/format"; +import type { ContextUsage } from "../../lib/protocol.gen"; +import { crewOpened, openBot, renderApp, sidebar } from "../../test/app"; + +afterEach(cleanup); + +const holds = (usedTokens: number, windowTokens = 1_000_000): ContextUsage => ({ + usedTokens, + windowTokens, + autoCompactTokens: windowTokens - 33_000, + compacting: false, + updatedAt: 1, +}); + +/** A crew "Ops" with @scout, open on its chat. */ +async function openScout(context: ContextUsage | null = holds(556_000)) { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "idle"); + fake.bot(scout.id).context = context; + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + await screen.findByRole("list", { name: "Messages" }); + return { fake, scout }; +} + +const meter = () => screen.getByRole("button", { name: /^Conversation space: / }); +const compactions = (fake: FakeBotloft) => + fake.calls.filter((call) => call.method === "bots.compact"); + +function open() { + fireEvent.click(meter()); + return screen.getByRole("dialog", { name: "Conversation space" }); +} + +describe("conversation space", () => { + test("token counts read short", () => { + expect(tokens(850)).toBe("850"); + expect(tokens(24_340)).toBe("24.3k"); + expect(tokens(22_000)).toBe("22k"); + expect(tokens(556_000)).toBe("556k"); + expect(tokens(967_000)).toBe("967k"); + expect(tokens(1_000_000)).toBe("1M"); + expect(tokens(1_240_000)).toBe("1.2M"); + }); + + test("it shows once Claude Code told it, and follows what arrives", async () => { + const { fake, scout } = await openScout(null); + expect(screen.queryByRole("button", { name: /^Conversation space/ })).toBeNull(); + + act(() => fake.setContext(scout.id, holds(556_000))); + expect(meter().getAttribute("aria-label")).toBe("Conversation space: 556k of 1M used (56%)"); + const menu = open(); + expect(within(menu).getByText("556k / 1M (56%)")).toBeDefined(); + expect( + within(menu).getByText("411k left before it compacts by itself, at 967k."), + ).toBeDefined(); + expect(within(menu).getByText(/Everything Scout read and wrote/)).toBeDefined(); + + act(() => fake.setContext(scout.id, holds(968_500))); + expect(within(menu).getByText("969k / 1M (97%)")).toBeDefined(); + expect(within(menu).getByText(/full enough to compact by itself/)).toBeDefined(); + + act(() => fake.setContext(scout.id, { ...holds(24_300, 200_000), autoCompactTokens: null })); + expect(within(menu).getByText("24.3k / 200k (12%)")).toBeDefined(); + expect(within(menu).getByText("It does not compact by itself.")).toBeDefined(); + }); + + test("the owner compacts the conversation and the chat says so", async () => { + const { fake, scout } = await openScout(); + const menu = open(); + fireEvent.click(within(menu).getByRole("button", { name: "Compact now" })); + const waiting = await within(menu).findByRole("button", { name: "Compacting…" }); + expect((waiting as HTMLButtonElement).disabled).toBe(true); + expect(compactions(fake).map((call) => call.params)).toEqual([{ botId: scout.id }]); + expect(screen.queryByText(/compacts the conversation when/)).toBeNull(); + + // Claude Code is done: the notice, then the new size. + act(() => { + fake.chat.add(scout.id, { + kind: "notice", + level: "info", + code: "compacted", + text: "The conversation was compacted: earlier messages are now a summary.", + }); + fake.setContext(scout.id, holds(24_000)); + }); + expect( + await screen.findByText(/what came before is now a summary, and there is room/), + ).toBeDefined(); + expect(within(menu).getByText("24k / 1M (2%)")).toBeDefined(); + expect(within(menu).getByRole("button", { name: "Compact now" })).toBeDefined(); + // Housekeeping does not become the conversation's last line. + expect(within(sidebar()).queryByText(/compacted/)).toBeNull(); + }); + + test("a busy bot compacts when it finishes what it is doing", async () => { + const { fake, scout } = await openScout(); + act(() => fake.setBotState(scout.id, "busy")); + fireEvent.click(within(open()).getByRole("button", { name: "Compact now" })); + expect( + await screen.findByText("Scout compacts the conversation when it finishes what it's doing."), + ).toBeDefined(); + }); + + test("a bot that is not running cannot compact", async () => { + const { fake, scout } = await openScout(); + act(() => fake.setBotState(scout.id, "offline")); + const menu = open(); + const button = within(menu).getByRole("button", { name: "Compact now" }); + expect((button as HTMLButtonElement).disabled).toBe(true); + expect(within(menu).getByText("Scout is not running, so it cannot compact now.")).toBeDefined(); + }); + + test("compacting by itself and a compaction that failed read in the owner's words", async () => { + const { fake, scout } = await openScout(); + act(() => { + fake.chat.add(scout.id, { + kind: "notice", + level: "info", + code: "auto_compacted", + text: "The conversation was full, so it was compacted.", + }); + fake.chat.add(scout.id, { + kind: "notice", + level: "warning", + code: "compact_failed", + text: "No messages to compact", + }); + }); + expect( + await screen.findByText(/was full, so it was compacted: what came before/), + ).toBeDefined(); + expect( + screen.getByText("The conversation could not be compacted: No messages to compact"), + ).toBeDefined(); + }); +}); diff --git a/app/src/features/chat/effort.test.tsx b/app/src/features/chat/effort.test.tsx new file mode 100644 index 00000000..87511aa1 --- /dev/null +++ b/app/src/features/chat/effort.test.tsx @@ -0,0 +1,131 @@ +import { act, cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import type { Bot } from "../../lib/protocol.gen"; +import { crewOpened, openBot, renderApp } from "../../test/app"; + +afterEach(cleanup); + +/** A crew "Ops" with @scout, open on its chat. */ +async function openScout(setup?: (bot: Bot, fake: FakeBotloft) => void) { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "idle"); + // What Claude Code reported for a bot on Sonnet. + fake.bot(scout.id).modelInUse = "claude-sonnet-5-5"; + fake.bot(scout.id).effortDefault = "medium"; + setup?.(fake.bot(scout.id), fake); + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + await screen.findByRole("list", { name: "Messages" }); + return { fake, scout }; +} + +const picker = () => screen.getByRole("button", { name: /^Effort: / }); +const efforts = (fake: FakeBotloft) => + fake.calls.filter((call) => call.method === "bots.setEffort").map((call) => call.params); + +function open() { + fireEvent.click(picker()); + const menu = screen.getByRole("dialog", { name: "Effort" }); + return { menu, slider: within(menu).queryByRole("slider", { name: "Effort" }) }; +} + +function slideTo(slider: HTMLElement | null, stop: number) { + fireEvent.change(slider as HTMLElement, { target: { value: String(stop) } }); +} + +describe("effort", () => { + test("a bot runs at its model's own level until the owner moves the slider", async () => { + const { fake, scout } = await openScout(); + expect(picker().getAttribute("aria-label")).toBe("Effort: Medium"); + const { menu, slider } = open(); + expect((slider as HTMLInputElement).value).toBe("1"); + expect(slider?.getAttribute("aria-valuetext")).toBe("Medium (Recommended)"); + expect(within(menu).getByText("Faster")).toBeDefined(); + expect(within(menu).getByText("Smarter")).toBeDefined(); + expect(within(menu).getByText(/Recommended for Sonnet 5.5/)).toBeDefined(); + expect(within(menu).queryByRole("button", { name: "Use recommended" })).toBeNull(); + + slideTo(slider, 3); + // The words follow the thumb at once; the bot is told when it rests. + expect(within(menu).getByText(/thinks a lot more/)).toBeDefined(); + expect(picker().getAttribute("aria-label")).toBe("Effort: Extra high"); + expect(efforts(fake)).toEqual([]); + await waitFor(() => expect(efforts(fake)).toEqual([{ botId: scout.id, effort: "xhigh" }])); + expect(screen.queryByText(/switches to/)).toBeNull(); + + fireEvent.click(within(menu).getByRole("button", { name: "Use recommended" })); + await screen.findByRole("button", { name: "Effort: Medium" }); + expect(efforts(fake).at(-1)).toEqual({ botId: scout.id, effort: "default" }); + }); + + test("the recommended stop is the model's own level, not a level of the owner's", async () => { + const { fake, scout } = await openScout((bot) => { + bot.effort = "high"; + }); + expect(picker().getAttribute("aria-label")).toBe("Effort: High"); + const { slider } = open(); + slideTo(slider, 1); + await waitFor(() => expect(efforts(fake)).toEqual([{ botId: scout.id, effort: "default" }])); + }); + + test("several stops in a row tell the bot only the last", async () => { + const { fake, scout } = await openScout(); + const { slider } = open(); + slideTo(slider, 2); + slideTo(slider, 3); + slideTo(slider, 4); + await waitFor(() => expect(efforts(fake)).toEqual([{ botId: scout.id, effort: "max" }])); + }); + + test("a stop left on the slider is kept when the menu closes", async () => { + const { fake, scout } = await openScout(); + const { slider } = open(); + slideTo(slider, 0); + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.queryByRole("dialog", { name: "Effort" })).toBeNull(); + await waitFor(() => expect(efforts(fake)).toEqual([{ botId: scout.id, effort: "low" }])); + }); + + test("a busy bot changes effort when it finishes what it is doing", async () => { + const { fake, scout } = await openScout(); + act(() => fake.setBotState(scout.id, "busy")); + const { slider } = open(); + slideTo(slider, 0); + expect( + await screen.findByText("Scout switches to Low effort when it finishes what it's doing."), + ).toBeDefined(); + }); + + test("a model without effort levels has no slider", async () => { + await openScout((bot) => { + bot.modelInUse = "claude-haiku-4-5-20251001"; + bot.effortDefault = "none"; + }); + expect(picker().getAttribute("aria-label")).toBe("Effort: Not available"); + const { menu, slider } = open(); + expect(slider).toBeNull(); + expect( + within(menu).getByText( + "Haiku 4.5 has no effort levels: Scout always answers at the same pace.", + ), + ).toBeDefined(); + expect(within(menu).queryByRole("button", { name: "Use recommended" })).toBeNull(); + }); + + test("nothing is marked until Claude Code said the model's level", async () => { + const { fake, scout } = await openScout((bot) => { + bot.effortDefault = null; + }); + expect(picker().getAttribute("aria-label")).toBe("Effort: Recommended"); + const { menu, slider } = open(); + expect(slider?.hasAttribute("data-unset")).toBe(true); + expect(within(menu).getByText("Scout uses the level recommended for its model")).toBeDefined(); + // With no model level to compare, the stop is the owner's own. + slideTo(slider, 1); + await waitFor(() => expect(efforts(fake)).toEqual([{ botId: scout.id, effort: "medium" }])); + }); +}); diff --git a/app/src/i18n/en/chat.ts b/app/src/i18n/en/chat.ts index 30ef29c9..5913b0bd 100644 --- a/app/src/i18n/en/chat.ts +++ b/app/src/i18n/en/chat.ts @@ -135,6 +135,58 @@ export const chat = { later: (bot: string, model: string) => `${bot} switches to ${model} when it finishes what it's doing.`, }, + effort: { + title: "Effort", + button: (level: string) => `Effort: ${level}`, + short: "Effort", + faster: "Faster", + smarter: "Smarter", + names: { + low: "Low", + medium: "Medium", + high: "High", + xhigh: "Extra high", + max: "Max", + }, + hints: { + low: (bot: string) => `${bot} answers fastest and thinks the least, for simple tasks`, + medium: (bot: string) => `${bot} balances speed and thinking, good for most work`, + high: (bot: string) => `${bot} thinks more before answering, for harder tasks`, + xhigh: (bot: string) => `${bot} thinks a lot more, for long and complex work`, + max: (bot: string) => + `${bot} thinks as much as it can: slowest, and uses the most of your plan`, + }, + recommended: "Recommended", + recommendedFor: (model: string) => `Recommended for ${model}`, + useRecommended: "Use recommended", + unknown: (bot: string) => `${bot} uses the level recommended for its model`, + unavailable: "Not available", + none: (bot: string, model: string) => + `${model} has no effort levels: ${bot} always answers at the same pace.`, + thisModel: "This model", + cost: "More effort uses up your plan's limit faster.", + failed: "Could not change the effort", + later: (bot: string, level: string) => + `${bot} switches to ${level} effort when it finishes what it's doing.`, + }, + context: { + title: "Conversation space", + button: (used: string, total: string, percent: number) => + `Conversation space: ${used} of ${total} used (${percent}%)`, + used: (used: string, total: string, percent: number) => `${used} / ${total} (${percent}%)`, + about: (bot: string) => + `Everything ${bot} read and wrote in this conversation takes up space. The fuller it is, the more of your plan each message uses.`, + autoLeft: (left: string, at: string) => `${left} left before it compacts by itself, at ${at}.`, + autoNow: "It is full enough to compact by itself on the next message.", + noAuto: "It does not compact by itself.", + compact: "Compact now", + compactHint: (bot: string) => + `Replaces what came before with a summary, so ${bot} has room again and each message uses less.`, + compacting: "Compacting…", + later: (bot: string) => `${bot} compacts the conversation when it finishes what it's doing.`, + notRunning: (bot: string) => `${bot} is not running, so it cannot compact now.`, + failed: "Could not compact the conversation", + }, suggestion: { title: (bot: string) => `${bot} suggests a new bot`, why: "Why", @@ -176,5 +228,10 @@ export const chat = { modelUnavailable: "This bot's model isn't available: it may not be on your Claude plan. Pick another model below the chat.", turnFailed: (detail: string) => `The bot couldn't finish this: ${detail}`, + compacted: + "The conversation was compacted: what came before is now a summary, and there is room again.", + autoCompacted: + "The conversation was full, so it was compacted: what came before is now a summary.", + compactFailed: (detail: string) => `The conversation could not be compacted: ${detail}`, }, }; diff --git a/app/src/i18n/es/chat.ts b/app/src/i18n/es/chat.ts index a69002a1..ab3cfe7e 100644 --- a/app/src/i18n/es/chat.ts +++ b/app/src/i18n/es/chat.ts @@ -141,6 +141,58 @@ export const chat: Messages["chat"] = { later: (bot: string, model: string) => `${bot} cambia a ${model} cuando termine lo que está haciendo.`, }, + effort: { + title: "Esfuerzo", + button: (level: string) => `Esfuerzo: ${level}`, + short: "Esfuerzo", + faster: "Más rápido", + smarter: "Más inteligente", + names: { + low: "Bajo", + medium: "Medio", + high: "Alto", + xhigh: "Extra alto", + max: "Máximo", + }, + hints: { + low: (bot: string) => `${bot} responde más rápido y piensa menos, para tareas simples`, + medium: (bot: string) => `${bot} equilibra rapidez y razonamiento, bueno para casi todo`, + high: (bot: string) => `${bot} piensa más antes de responder, para tareas más difíciles`, + xhigh: (bot: string) => `${bot} piensa mucho más, para trabajo largo y complejo`, + max: (bot: string) => + `${bot} piensa todo lo que puede: es el más lento y el que más gasta de tu plan`, + }, + recommended: "Recomendado", + recommendedFor: (model: string) => `Recomendado para ${model}`, + useRecommended: "Usar el recomendado", + unknown: (bot: string) => `${bot} usa el nivel recomendado para su modelo`, + unavailable: "No disponible", + none: (bot: string, model: string) => + `${model} no tiene niveles de esfuerzo: ${bot} responde siempre al mismo ritmo.`, + thisModel: "Este modelo", + cost: "Más esfuerzo gasta el límite de tu plan más rápido.", + failed: "No se pudo cambiar el esfuerzo", + later: (bot: string, level: string) => + `${bot} cambia al esfuerzo ${level} cuando termine lo que está haciendo.`, + }, + context: { + title: "Espacio de la conversación", + button: (used: string, total: string, percent: number) => + `Espacio de la conversación: ${used} de ${total} en uso (${percent}%)`, + used: (used: string, total: string, percent: number) => `${used} / ${total} (${percent}%)`, + about: (bot: string) => + `Todo lo que ${bot} leyó y escribió en esta conversación ocupa espacio. Cuanto más llena, más de tu plan gasta cada mensaje.`, + autoLeft: (left: string, at: string) => `Faltan ${left} para que se compacte sola, en ${at}.`, + autoNow: "Ya está lo bastante llena para compactarse sola en el próximo mensaje.", + noAuto: "No se compacta sola.", + compact: "Compactar ahora", + compactHint: (bot: string) => + `Cambia lo anterior por un resumen: ${bot} vuelve a tener espacio y cada mensaje gasta menos.`, + compacting: "Compactando…", + later: (bot: string) => `${bot} compacta la conversación cuando termine lo que está haciendo.`, + notRunning: (bot: string) => `${bot} no está en marcha, así que no se puede compactar ahora.`, + failed: "No se pudo compactar la conversación", + }, suggestion: { title: (bot: string) => `${bot} sugiere un bot nuevo`, why: "Por qué", @@ -183,5 +235,9 @@ export const chat: Messages["chat"] = { modelUnavailable: "El modelo de este bot no está disponible: quizá no esté en tu plan de Claude. Elige otro modelo debajo del chat.", turnFailed: (detail: string) => `El bot no pudo terminar esto: ${detail}`, + compacted: + "La conversación se compactó: lo anterior ahora es un resumen, y vuelve a haber espacio.", + autoCompacted: "La conversación estaba llena y se compactó: lo anterior ahora es un resumen.", + compactFailed: (detail: string) => `No se pudo compactar la conversación: ${detail}`, }, }; diff --git a/app/src/i18n/pt-BR/chat.ts b/app/src/i18n/pt-BR/chat.ts index 62d4a9d1..35463123 100644 --- a/app/src/i18n/pt-BR/chat.ts +++ b/app/src/i18n/pt-BR/chat.ts @@ -139,6 +139,59 @@ export const chat: Messages["chat"] = { later: (bot: string, model: string) => `${bot} muda para ${model} quando terminar o que está fazendo.`, }, + effort: { + title: "Esforço", + button: (level: string) => `Esforço: ${level}`, + short: "Esforço", + faster: "Mais rápido", + smarter: "Mais inteligente", + names: { + low: "Baixo", + medium: "Médio", + high: "Alto", + xhigh: "Extra alto", + max: "Máximo", + }, + hints: { + low: (bot: string) => `${bot} responde mais rápido e pensa menos, para tarefas simples`, + medium: (bot: string) => `${bot} equilibra rapidez e raciocínio, bom para quase tudo`, + high: (bot: string) => `${bot} pensa mais antes de responder, para tarefas mais difíceis`, + xhigh: (bot: string) => `${bot} pensa bem mais, para trabalho longo e complexo`, + max: (bot: string) => + `${bot} pensa o máximo que pode: é o mais lento e o que mais gasta do seu plano`, + }, + recommended: "Recomendado", + recommendedFor: (model: string) => `Recomendado para o ${model}`, + useRecommended: "Usar o recomendado", + unknown: (bot: string) => `${bot} usa o nível recomendado para o modelo dele`, + unavailable: "Indisponível", + none: (bot: string, model: string) => + `${model} não tem níveis de esforço: ${bot} responde sempre no mesmo ritmo.`, + thisModel: "Este modelo", + cost: "Mais esforço gasta o limite do seu plano mais rápido.", + failed: "Não foi possível mudar o esforço", + later: (bot: string, level: string) => + `${bot} muda para o esforço ${level} quando terminar o que está fazendo.`, + }, + context: { + title: "Espaço da conversa", + button: (used: string, total: string, percent: number) => + `Espaço da conversa: ${used} de ${total} em uso (${percent}%)`, + used: (used: string, total: string, percent: number) => `${used} / ${total} (${percent}%)`, + about: (bot: string) => + `Tudo o que ${bot} leu e escreveu nesta conversa ocupa espaço. Quanto mais cheia, mais do seu plano cada mensagem gasta.`, + autoLeft: (left: string, at: string) => + `Faltam ${left} para ela se compactar sozinha, em ${at}.`, + autoNow: "Já está cheia o bastante para se compactar sozinha na próxima mensagem.", + noAuto: "Ela não se compacta sozinha.", + compact: "Compactar agora", + compactHint: (bot: string) => + `Troca o que veio antes por um resumo: ${bot} volta a ter espaço e cada mensagem gasta menos.`, + compacting: "Compactando…", + later: (bot: string) => `${bot} compacta a conversa quando terminar o que está fazendo.`, + notRunning: (bot: string) => `${bot} não está rodando, então não dá para compactar agora.`, + failed: "Não foi possível compactar a conversa", + }, suggestion: { title: (bot: string) => `${bot} sugere um bot novo`, why: "Por quê", @@ -181,5 +234,9 @@ export const chat: Messages["chat"] = { modelUnavailable: "O modelo deste bot não está disponível: talvez não faça parte do seu plano do Claude. Escolha outro modelo abaixo do chat.", turnFailed: (detail: string) => `O bot não conseguiu terminar isto: ${detail}`, + compacted: + "A conversa foi compactada: o que veio antes agora é um resumo, e há espaço de novo.", + autoCompacted: "A conversa ficou cheia e foi compactada: o que veio antes agora é um resumo.", + compactFailed: (detail: string) => `Não foi possível compactar a conversa: ${detail}`, }, }; diff --git a/app/src/index.css b/app/src/index.css index 6baa10b3..8ce27c46 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -8,6 +8,7 @@ @import "./mascot.css"; @import "./mascot-flame.css"; @import "./panels.css"; +@import "./effort.css"; @import "./motion-less.css"; /* Colors are tokens so the light and dark themes swap in one place diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts index 0db5c99b..5d713f1d 100644 --- a/app/src/lib/fakeChat.ts +++ b/app/src/lib/fakeChat.ts @@ -47,6 +47,14 @@ export function activityLine( tool = body.toolName; break; case "notice": + // A compaction is housekeeping, not news about the conversation. + if ( + body.code === "compacted" || + body.code === "auto_compacted" || + body.code === "compact_failed" + ) { + return null; + } kind = "notice"; text = body.text; break; diff --git a/app/src/lib/format.ts b/app/src/lib/format.ts index b308d054..215f449b 100644 --- a/app/src/lib/format.ts +++ b/app/src/lib/format.ts @@ -76,6 +76,22 @@ export function fileSize(bytes: number): string { return `${mb < 10 ? decimal(mb, 1) : Math.round(mb)} MB`; } +/** Tokens as the owner reads them: "850", "24.3k", "556k", "1M", "1.2M". */ +export function tokens(count: number): string { + if (count < 1000) { + return String(count); + } + const thousands = count / 1000; + if (thousands < 100) { + return `${decimal(thousands, thousands % 1 < 0.05 ? 0 : 1)}k`; + } + if (thousands < 999.5) { + return `${Math.round(thousands)}k`; + } + const millions = count / 1_000_000; + return `${decimal(millions, Math.abs(millions - Math.round(millions)) < 0.05 ? 0 : 1)}M`; +} + /** "0.8 s", "42 s", "3 min 5 s". */ export function duration(ms: number): string { const seconds = ms / 1000; diff --git a/app/src/store/app.ts b/app/src/store/app.ts index eb2af0fe..e7dc1c84 100644 --- a/app/src/store/app.ts +++ b/app/src/store/app.ts @@ -163,6 +163,13 @@ export function applyEvent(state: AppState, event: ServerEvent): Partial", 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". From 930b262200eb1029adab209c95d536be9fed51d3 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:16:43 -0300 Subject: [PATCH 14/15] fix(workspace): instruction files above the crew's folder stay out of the bots Claude Code loads CLAUDE.md, CLAUDE.local.md, .claude/CLAUDE.md and .claude/rules from the working directory and from every folder above it, whatever --setting-sources says. Bot folders sit inside the user profile by default, so the owner's personal .claude/CLAUDE.md reached every bot as project memory. The generated settings.json now names those files in claudeMdExcludes, for each folder above the crew's folder up to the drive root. The bot's own CLAUDE.md and rules, the crew's folder and the work folder keep loading. Checked against Claude Code 2.1.284 (spec 19): patterns need the C:/Users/... form with the letter case of the working directory, the //c/... form of permission rules does not match, and brackets, braces and parentheses in a folder name have to become "?". --- CLAUDE.md | 1 + crates/botloftd/src/workspace/files.rs | 31 +++++- crates/botloftd/src/workspace/memory.rs | 124 ++++++++++++++++++++++++ crates/botloftd/src/workspace/mod.rs | 14 ++- crates/botloftd/tests/rpc_crews_bots.rs | 9 ++ docs/spec.md | 25 ++++- 6 files changed, 193 insertions(+), 11 deletions(-) create mode 100644 crates/botloftd/src/workspace/memory.rs diff --git a/CLAUDE.md b/CLAUDE.md index 9cb7c8a7..e59190e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -66,6 +66,7 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon - Cada bot é `claude -p` com `stream-json` no stdin e no stdout (spec 7.4, 8, 9.2). O processo fica calado até a primeira mensagem; fechar o stdin o encerra. O formato de entrada não é documentado: qualquer mudança passa por teste real e vai para a seção 19. - Bots rodam com `--setting-sources project,local` e `--strict-mcp-config`: nada de hooks, skills, modo de permissão ou MCP pessoais do dono. Aprovações passam pela tool `permission_prompt` do nosso MCP. +- Essa flag não tira os `CLAUDE.md` das pastas acima do workspace, e `%USERPROFILE%\.claude\CLAUDE.md` é um deles: quem os tira é `claudeMdExcludes` no `settings.json` gerado (spec 7.5). Os padrões usam `C:/Users/...` com as maiúsculas do cwd; a forma `//c/...` das permission rules não casa ali. - Servidor MCP por HTTP: o Claude Code aborta a request em 60 s e a chamada em 5 min sem resposta, a menos que o `mcp.json` tenha `timeout` no servidor. A aprovação depende disso. - Só o `claude.exe` nativo roda. O `claude.cmd` do npm é recusado: passar por `cmd.exe` estraga o quoting dos argumentos. - O bot recebe o ambiente padrão do usuário (`CreateEnvironmentBlock`), nunca o do daemon: em dev o daemon herda variáveis da sessão do Claude Code (`CLAUDE_CODE_MESSAGING_SOCKET`, `ANTHROPIC_BASE_URL`...). diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index f3ea73ba..898dcf66 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -7,6 +7,7 @@ use botloft_core::protocol::Crew; use botloft_store::BotRecord; use serde_json::{Value, json}; +use super::memory; use crate::paths::permission_rule_path; /// Longer than the approval timeout, so Claude Code never gives up on a @@ -14,13 +15,16 @@ use crate::paths::permission_rule_path; const MCP_TIMEOUT_MARGIN: Duration = Duration::from_secs(120); /// `.claude/settings.json` (spec 7.5): keeps the bot out of the daemon's -/// secrets. Deny rules apply even in a folder nobody trusted. -pub fn settings_json(botloft_home: &Path) -> Value { +/// secrets, and the owner's own Claude Code memory out of the bot. Deny +/// rules apply even in a folder nobody trusted. +pub fn settings_json(botloft_home: &Path, crew_dir: &Path) -> Value { let secrets = permission_rule_path(&botloft_home.join("secrets")); json!({ "permissions": { "deny": [format!("Read({secrets}/**)")], }, + // Instruction files above the crew's folder are the owner's. + "claudeMdExcludes": memory::excludes_above(crew_dir), }) } @@ -175,10 +179,27 @@ mod tests { #[test] fn settings_deny_the_secrets_folder_and_nothing_else() { let home = Path::new(r"C:\Users\ana\AppData\Local\Botloft"); - let settings = settings_json(home); + let settings = settings_json(home, Path::new("/ws/site")); assert_eq!( - settings, - json!({ "permissions": { "deny": ["Read(//c/Users/ana/AppData/Local/Botloft/secrets/**)"] } }) + settings["permissions"], + json!({ "deny": ["Read(//c/Users/ana/AppData/Local/Botloft/secrets/**)"] }) + ); + } + + #[test] + fn settings_keep_the_owners_memory_out_of_the_bot() { + let settings = settings_json(Path::new("/data"), Path::new("/ws/site")); + assert_eq!( + settings["claudeMdExcludes"], + json!(memory::excludes_above(Path::new("/ws/site"))) + ); + assert_eq!(settings["claudeMdExcludes"][0], "/ws/CLAUDE.md"); + let mut keys: Vec<&String> = settings.as_object().expect("object").keys().collect(); + keys.sort(); + assert_eq!( + keys, + ["claudeMdExcludes", "permissions"], + "a new setting needs a line in spec 7.5" ); } diff --git a/crates/botloftd/src/workspace/memory.rs b/crates/botloftd/src/workspace/memory.rs new file mode 100644 index 00000000..2349c493 --- /dev/null +++ b/crates/botloftd/src/workspace/memory.rs @@ -0,0 +1,124 @@ +//! Which instruction files Claude Code loads into a bot (spec 7.5). +//! +//! Claude Code reads `CLAUDE.md` and its relatives from the working directory +//! and from every directory above it. A bot's folder sits inside the owner's +//! profile by default, so the owner's personal `.claude\CLAUDE.md` would reach +//! every bot. `claudeMdExcludes` skips what is above the crew's folder. + +use std::path::Path; + +/// What Claude Code looks for in each directory above the working directory. +const INSTRUCTION_FILES: [&str; 6] = [ + "CLAUDE.md", + "CLAUDE.local.md", + "AGENTS.md", + ".claude/CLAUDE.md", + ".claude/AGENTS.md", + ".claude/rules/**", +]; + +/// `claudeMdExcludes` patterns for every instruction file in the folders +/// above `crew_dir`. The crew's folder, the bots' folders in it and the work +/// folder are left alone: what is there is for the bots. +pub fn excludes_above(crew_dir: &Path) -> Vec { + crew_dir + .ancestors() + .skip(1) + .flat_map(|dir| { + let dir = glob_dir(dir); + INSTRUCTION_FILES + .iter() + .map(move |file| format!("{dir}/{file}")) + }) + .collect() +} + +/// A directory in the form Claude Code matches the patterns against on +/// Windows: the path it was started with, letter case included, with either +/// separator (`C:/Users/ana`). The `//c/...` form of permission rules does +/// not match here (spec 19). Brackets, braces and parentheses in a folder +/// name would be read as glob syntax, so they become `?`, any one character. +fn glob_dir(dir: &Path) -> String { + let text = dir.to_string_lossy().replace('\\', "/"); + let text = text.strip_prefix("//?/").unwrap_or(&text); + text.trim_end_matches('/') + .chars() + .map(|c| match c { + '[' | ']' | '{' | '}' | '(' | ')' | '*' | '?' => '?', + other => other, + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_folder_above_the_crew_is_covered_and_nothing_below() { + let patterns = excludes_above(Path::new("/ws/site")); + assert_eq!( + patterns, + [ + "/ws/CLAUDE.md", + "/ws/CLAUDE.local.md", + "/ws/AGENTS.md", + "/ws/.claude/CLAUDE.md", + "/ws/.claude/AGENTS.md", + "/ws/.claude/rules/**", + "/CLAUDE.md", + "/CLAUDE.local.md", + "/AGENTS.md", + "/.claude/CLAUDE.md", + "/.claude/AGENTS.md", + "/.claude/rules/**", + ] + ); + } + + #[cfg(windows)] + #[test] + fn windows_paths_keep_the_drive_and_case_and_use_forward_slashes() { + let patterns = excludes_above(Path::new(r"C:\Users\Ana\Botloft\site")); + assert_eq!(patterns.len(), 4 * INSTRUCTION_FILES.len()); + for expected in [ + "C:/Users/Ana/Botloft/CLAUDE.md", + "C:/Users/Ana/.claude/CLAUDE.md", + "C:/Users/Ana/.claude/rules/**", + "C:/Users/CLAUDE.local.md", + "C:/AGENTS.md", + ] { + assert!(patterns.iter().any(|p| p == expected), "{expected} missing"); + } + // The bot's own memory and rules, the crew's folder and its work + // folder must keep loading. + assert!( + patterns + .iter() + .all(|p| !p.starts_with("C:/Users/Ana/Botloft/site")) + ); + + let verbatim = excludes_above(Path::new(r"\\?\D:\bots\site")); + assert_eq!(verbatim[0], "D:/bots/CLAUDE.md"); + assert_eq!( + verbatim.last().map(String::as_str), + Some("D:/.claude/rules/**") + ); + } + + #[test] + fn glob_syntax_in_a_folder_name_matches_as_any_character() { + let patterns = excludes_above(Path::new("/home/ana (work) [x] {y}/Botloft/site")); + assert!( + patterns + .iter() + .any(|p| p == "/home/ana ?work? ?x? ?y?/.claude/rules/**"), + "{patterns:?}" + ); + // Characters a glob reads literally stay as they are. + assert_eq!( + glob_dir(Path::new("/a!b+c@d#e$f, g;h=i~j é")), + "/a!b+c@d#e$f, g;h=i~j é" + ); + } +} diff --git a/crates/botloftd/src/workspace/mod.rs b/crates/botloftd/src/workspace/mod.rs index 07e1fdd4..b667eef7 100644 --- a/crates/botloftd/src/workspace/mod.rs +++ b/crates/botloftd/src/workspace/mod.rs @@ -4,6 +4,7 @@ mod files; pub mod folder; +mod memory; use std::io; use std::path::{Path, PathBuf}; @@ -43,7 +44,7 @@ pub fn prepare_bot(env: WorkspaceEnv<'_>, crew: &Crew, bot: &BotRecord) -> io::R } write_json( &dir.join(".claude").join("settings.json"), - &files::settings_json(&env.paths.home), + &files::settings_json(&env.paths.home, &env.paths.crew_dir(&crew.slug)), )?; write_json( &dir.join(".botloft").join("mcp.json"), @@ -143,6 +144,17 @@ mod tests { serde_json::from_slice(&std::fs::read(ws.join(".claude/settings.json")).expect("read")) .expect("valid json"); assert!(settings["permissions"]["deny"].is_array()); + // Instruction files are skipped from the folder of every crew up, + // never in the crew's own folder. + assert_eq!( + settings["claudeMdExcludes"], + serde_json::json!(memory::excludes_above(&paths.crew_dir("site"))) + ); + let root = paths.workspaces_root.to_string_lossy().replace('\\', "/"); + assert_eq!( + settings["claudeMdExcludes"][0], + format!("{root}/CLAUDE.md").as_str() + ); } #[test] diff --git a/crates/botloftd/tests/rpc_crews_bots.rs b/crates/botloftd/tests/rpc_crews_bots.rs index 44e5ea4d..0c68a8ef 100644 --- a/crates/botloftd/tests/rpc_crews_bots.rs +++ b/crates/botloftd/tests/rpc_crews_bots.rs @@ -63,6 +63,15 @@ async fn create_a_crew_and_a_bot_and_get_a_ready_workspace() { deny.starts_with("Read(//") && deny.ends_with("/secrets/**)"), "{deny}" ); + // The owner's own Claude Code memory stays out of the bot (spec 7.5). + let crew_dir = daemon.paths.crew_dir("site-da-loja"); + let crew_dir = crew_dir.to_string_lossy().replace('\\', "/"); + let excludes = settings["claudeMdExcludes"].as_array().expect("excludes"); + assert!(excludes.len() >= 6, "{excludes:?}"); + assert!(excludes.iter().all(|pattern| { + let pattern = pattern.as_str().expect("pattern"); + !pattern.contains('\\') && !pattern.starts_with(crew_dir.as_str()) + })); let mcp: Value = serde_json::from_str(&read(ws.join(".botloft/mcp.json"))).expect("json"); assert_eq!( mcp["mcpServers"]["botloft"]["url"], diff --git a/docs/spec.md b/docs/spec.md index b39463af..73769776 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -115,7 +115,7 @@ O instalador por usuário do Tauri põe o app em `%LOCALAPPDATA%\`, a m Workspaces ficam num caminho curto e visível para o usuário abrir no Explorer e para reduzir estouro do limite de 260 caracteres (bots rodam `npm install`). -**Pasta de trabalho da crew.** É onde os bots põem o que fazem para o dono e para os outros bots. Por padrão é a `shared\` da crew; o dono pode escolher outra ao criar a crew ou depois (`crews.setWorkFolder`), como uma pasta de projeto que ele já usa. O workspace de cada bot continua onde está, com a memória, as regras e os anexos dele: o daemon nunca grava nada na pasta escolhida. Cada bot recebe a pasta com `--add-dir` (7.4), e o Claude Code a trata como a pasta do bot: lê e, em "Aceitar edições", edita sem perguntar, e carrega o `CLAUDE.md` dela junto com a memória do bot. A pasta escolhida precisa ser um caminho completo e não pode ser um disco inteiro, ficar dentro da pasta de dados do Botloft ou contê-la, nem conter `workspaces_root` (com os workspaces de todos os bots). Se não existe, é criada. Trocar a pasta regrava as regras e reinicia cada bot da crew quando nada estiver em andamento, como a troca de modo (7.4). Arquivar a crew não apaga pasta nenhuma. +**Pasta de trabalho da crew.** É onde os bots põem o que fazem para o dono e para os outros bots. Por padrão é a `shared\` da crew; o dono pode escolher outra ao criar a crew ou depois (`crews.setWorkFolder`), como uma pasta de projeto que ele já usa. O workspace de cada bot continua onde está, com a memória, as regras e os anexos dele: o daemon nunca grava nada na pasta escolhida. Cada bot recebe a pasta com `--add-dir` (7.4), e o Claude Code a trata como a pasta do bot: lê e, em "Aceitar edições", edita sem perguntar, e carrega o `CLAUDE.md` dela junto com a memória do bot. Só o da própria pasta (`CLAUDE.md`, `.claude\CLAUDE.md`, `.claude\rules\` e `CLAUDE.local.md`): numa pasta escolhida dentro de um projeto, os `CLAUDE.md` das pastas acima dela não são carregados pelo Claude Code (19). A pasta escolhida precisa ser um caminho completo e não pode ser um disco inteiro, ficar dentro da pasta de dados do Botloft ou contê-la, nem conter `workspaces_root` (com os workspaces de todos os bots). Se não existe, é criada. Trocar a pasta regrava as regras e reinicia cada bot da crew quando nada estiver em andamento, como a troca de modo (7.4). Arquivar a crew não apaga pasta nenhuma. 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. @@ -128,7 +128,7 @@ O **handle** do bot (`@revisao`) é derivado do nome pela mesma regra, acompanha CLAUDE.md memória viva do bot (o bot edita; o daemon só cria se não existir) attachments\\ arquivos que o dono mandou no chat (9.5) .claude\ - settings.json regra de negação para os segredos (gerado pelo daemon, sobrescrito a cada start) + settings.json regra de negação para os segredos e o que o Claude Code não carrega no bot, 7.5 (gerado pelo daemon, sobrescrito a cada start) rules\botloft.md identidade, papel, crew, pasta de trabalho, guia de uso das tools e, no chefe, como liderar (gerado pelo daemon) .botloft\ mcp.json config MCP do bot (gerado pelo daemon) @@ -243,7 +243,7 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s --allowedTools mcp__botloft ``` - - `--setting-sources project,local` e `--strict-mcp-config` deixam de fora hooks, skills, agents, modo de permissão e servidores MCP pessoais do dono: o bot vê o que o Botloft gera. O login da conta não é uma fonte de settings e continua valendo. + - `--setting-sources project,local` e `--strict-mcp-config` deixam de fora hooks, skills, agents, modo de permissão e servidores MCP pessoais do dono: o bot vê o que o Botloft gera. O login da conta não é uma fonte de settings e continua valendo. As instruções pessoais do dono (`%USERPROFILE%\.claude\CLAUDE.md`) não saem por essa flag: o Claude Code as acha subindo as pastas a partir do cwd. Quem as deixa de fora é o `settings.json` gerado (7.5). - `--allowedTools mcp__botloft` libera as tools da crew sem aprovação. Uma regra `allow` no `settings.json` do projeto não bastaria: em `-p`, numa pasta que nunca passou pelo diálogo de confiança, o Claude Code não aplica as regras `allow` do projeto (documentado em `permissions`). - Qualquer outra ferramenta que peça permissão passa pela tool de aprovação (10.1) e vira um pedido no chat. - `--add-dir` põe a pasta de trabalho da crew (5) entre as pastas do bot. Sem ela, gravar na `shared\`, que fica fora do workspace, pedia aprovação até em "Aceitar edições" (19). @@ -262,13 +262,28 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s { "permissions": { "deny": ["Read(//c/Users//AppData/Local/Botloft/secrets/**)"] - } + }, + "claudeMdExcludes": [ + "C:/Users//Botloft/CLAUDE.md", + "C:/Users//Botloft/CLAUDE.local.md", + "C:/Users//Botloft/AGENTS.md", + "C:/Users//Botloft/.claude/CLAUDE.md", + "C:/Users//Botloft/.claude/AGENTS.md", + "C:/Users//Botloft/.claude/rules/**", + "C:/Users//CLAUDE.md", + "C:/Users//.claude/CLAUDE.md" + ] } ``` +(A lista de verdade repete os seis nomes para cada pasta acima da pasta da crew: `C:/Users//Botloft`, `C:/Users/`, `C:/Users` e `C:/`.) + - Regras `deny` valem mesmo sem confiança na pasta: elas só restringem. - Caminho absoluto em permission rule usa o prefixo `//` e a forma POSIX que o Claude Code aplica no Windows: `C:\Users\ana\...` vira `//c/Users/ana/...` (letra do drive em minúscula). Uma barra só (`/caminho`) é relativa à origem do settings, não à raiz, e não protegeria nada. O daemon converte `BOTLOFT_HOME` para essa forma ao gerar o arquivo. - Em `-p` o diálogo de confiança da pasta nunca aparece (documentado), então bot novo começa a trabalhar sem passo manual. +- **Instruções de fora ficam de fora (`claudeMdExcludes`).** O Claude Code carrega `CLAUDE.md`, `CLAUDE.local.md`, `.claude/CLAUDE.md` e `.claude/rules/` do cwd e de toda pasta acima dele, como instruções de projeto, e `--setting-sources` não muda isso. O workspace fica dentro de `%USERPROFILE%` por padrão, então o `%USERPROFILE%\.claude\CLAUDE.md` do dono, que são as instruções pessoais dele para o Claude Code, chegava a todo bot; com `workspaces_root` dentro de um projeto (um daemon de dev), chegava o `CLAUDE.md` do projeto. O daemon lista, para cada pasta acima da pasta da crew, de `workspaces_root` até a raiz do disco, os seis nomes que o Claude Code procura: `CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, `.claude/CLAUDE.md`, `.claude/AGENTS.md` e `.claude/rules/**`. São caminhos exatos, sem `**` no começo: nenhum padrão alcança outra pasta. +- **O que continua carregando:** o `CLAUDE.md` e as regras do bot (5.1); o `CLAUDE.md` e as regras da pasta de trabalho da crew, a `shared\` ou a que o dono escolheu, que chegam pelo `--add-dir` (7.4); e um `CLAUDE.md` que o dono ponha na pasta da crew (`\CLAUDE.md`), que vale para todos os bots dela. O `CLAUDE.md` de política gerenciada (`C:\Program Files\ClaudeCode\CLAUDE.md`) não pode ser excluído (documentado) e continua valendo. +- **Forma do caminho em `claudeMdExcludes`:** o padrão é comparado com o caminho absoluto como o Claude Code o monta a partir do cwd: `C:/Users/ana/...` (ou com `\`), com a letra do drive e as maiúsculas iguais às do cwd, porque a comparação diferencia maiúsculas. A forma `//c/...` das permission rules **não** casa aqui. O daemon monta os padrões com o mesmo caminho que passa como cwd. Colchetes, chaves e parênteses num nome de pasta são sintaxe de glob e viram `?` (um caractere qualquer); espaço, acento e o resto casam como estão (19). ## 8. Chat @@ -924,7 +939,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | 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 | +| CLAUDE.md do dono nos bots | 5, 7.5 | Confirmado (`memory`, `settings-reference`): o Claude Code carrega `CLAUDE.md`, `CLAUDE.local.md` e `.claude/CLAUDE.md` do cwd e de toda pasta acima dele; `claudeMdExcludes` pula arquivos por caminho absoluto ou glob, vale em qualquer camada de settings e também para `AGENTS.md`; o `CLAUDE.md` gerenciado não pode ser excluído. **Testado com 2.1.284** (`-p --setting-sources project,local`, `memoryFiles` do `get_context_usage`, numa pasta de teste dentro de `%USERPROFILE%`). Sem a setting: o `%USERPROFILE%\.claude\CLAUDE.md` do dono entra como `Project`, e de cada pasta acima entram `CLAUDE.md`, `CLAUDE.local.md`, `.claude/CLAUDE.md` e `.claude/rules/` com as subpastas; as skills são só as embutidas. Forma do padrão: `C:/Users/...` e `C:\Users\...` casam, também misturadas; `/c/Users/...` e `//c/Users/...` não casam; a comparação diferencia maiúsculas, até na letra do drive, e o Claude Code monta os caminhos com o cwd como o recebeu (um cwd em minúsculas aparece em minúsculas). Numa pasta chamada `odd (x) [y] {z}`, o caminho exato de um arquivo ainda casa, mas `.../.claude/rules/**` não; com `?` no lugar de cada um desses seis caracteres, casa. Espaço, acento e ``!+@#$%^&',;=~`` casam como estão. O `AGENTS.md` de uma pasta acima só entra com `instructionFiles: claude-md-and-agents-md` (os bots sempre têm `CLAUDE.md`), e o padrão o tira. `--add-dir` carrega `CLAUDE.md` e `.claude/rules/` só da própria pasta: os das pastas acima de uma pasta de trabalho escolhida não entram, com ou sem a setting. **Pelo daemon de dev** (workspaces dentro de `%USERPROFILE%`, com `CLAUDE.md` de teste em cada pasta acima; Haiku): com o `settings.json` gerado, `memoryFiles` e o próprio bot listaram só o `CLAUDE.md` da pasta da crew, o do bot, as regras e o da `shared\` | feito | | 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. From a192ad6d6eef0fdb3f52ac4dbe4020b49f0501f1 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:17:16 -0300 Subject: [PATCH 15/15] fix(workspace): bots run without Claude Code's auto memory Auto memory is on by default in claude -p. A bot asked to remember something wrote its notes to the owner's profile (.claude/projects//memory), outside its folder and without a permission request, instead of the CLAUDE.md that Botloft gives it as memory. The owner cannot see those notes in the app, and they stay behind when the bot is deleted. With the bot folders inside a git repository, the bot also loaded the notes Claude Code keeps for the owner in that repository. The generated settings.json now sets autoMemoryEnabled to false: the bot's memory is the CLAUDE.md in its folder (spec 5.1, 7.5). Notes written before stay where they are. Checked against Claude Code 2.1.284 (spec 19). --- crates/botloftd/src/workspace/files.rs | 6 +++++- crates/botloftd/tests/rpc_crews_bots.rs | 1 + docs/spec.md | 7 +++++-- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/crates/botloftd/src/workspace/files.rs b/crates/botloftd/src/workspace/files.rs index 898dcf66..8475b560 100644 --- a/crates/botloftd/src/workspace/files.rs +++ b/crates/botloftd/src/workspace/files.rs @@ -25,6 +25,9 @@ pub fn settings_json(botloft_home: &Path, crew_dir: &Path) -> Value { }, // Instruction files above the crew's folder are the owner's. "claudeMdExcludes": memory::excludes_above(crew_dir), + // The bot's memory is the `CLAUDE.md` in its folder. Claude Code's + // own notes live in the owner's profile, outside that folder. + "autoMemoryEnabled": false, }) } @@ -194,11 +197,12 @@ mod tests { json!(memory::excludes_above(Path::new("/ws/site"))) ); assert_eq!(settings["claudeMdExcludes"][0], "/ws/CLAUDE.md"); + assert_eq!(settings["autoMemoryEnabled"], false); let mut keys: Vec<&String> = settings.as_object().expect("object").keys().collect(); keys.sort(); assert_eq!( keys, - ["claudeMdExcludes", "permissions"], + ["autoMemoryEnabled", "claudeMdExcludes", "permissions"], "a new setting needs a line in spec 7.5" ); } diff --git a/crates/botloftd/tests/rpc_crews_bots.rs b/crates/botloftd/tests/rpc_crews_bots.rs index 0c68a8ef..53cd5985 100644 --- a/crates/botloftd/tests/rpc_crews_bots.rs +++ b/crates/botloftd/tests/rpc_crews_bots.rs @@ -72,6 +72,7 @@ async fn create_a_crew_and_a_bot_and_get_a_ready_workspace() { let pattern = pattern.as_str().expect("pattern"); !pattern.contains('\\') && !pattern.starts_with(crew_dir.as_str()) })); + assert_eq!(settings["autoMemoryEnabled"], false); let mcp: Value = serde_json::from_str(&read(ws.join(".botloft/mcp.json"))).expect("json"); assert_eq!( mcp["mcpServers"]["botloft"]["url"], diff --git a/docs/spec.md b/docs/spec.md index 73769776..7894c411 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -243,7 +243,7 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s --allowedTools mcp__botloft ``` - - `--setting-sources project,local` e `--strict-mcp-config` deixam de fora hooks, skills, agents, modo de permissão e servidores MCP pessoais do dono: o bot vê o que o Botloft gera. O login da conta não é uma fonte de settings e continua valendo. As instruções pessoais do dono (`%USERPROFILE%\.claude\CLAUDE.md`) não saem por essa flag: o Claude Code as acha subindo as pastas a partir do cwd. Quem as deixa de fora é o `settings.json` gerado (7.5). + - `--setting-sources project,local` e `--strict-mcp-config` deixam de fora hooks, skills, agents, modo de permissão e servidores MCP pessoais do dono: o bot vê o que o Botloft gera. O login da conta não é uma fonte de settings e continua valendo. As instruções pessoais do dono (`%USERPROFILE%\.claude\CLAUDE.md`) não saem por essa flag: o Claude Code as acha subindo as pastas a partir do cwd. Quem as deixa de fora, junto com a memória automática, é o `settings.json` gerado (7.5). - `--allowedTools mcp__botloft` libera as tools da crew sem aprovação. Uma regra `allow` no `settings.json` do projeto não bastaria: em `-p`, numa pasta que nunca passou pelo diálogo de confiança, o Claude Code não aplica as regras `allow` do projeto (documentado em `permissions`). - Qualquer outra ferramenta que peça permissão passa pela tool de aprovação (10.1) e vira um pedido no chat. - `--add-dir` põe a pasta de trabalho da crew (5) entre as pastas do bot. Sem ela, gravar na `shared\`, que fica fora do workspace, pedia aprovação até em "Aceitar edições" (19). @@ -272,7 +272,8 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s "C:/Users//Botloft/.claude/rules/**", "C:/Users//CLAUDE.md", "C:/Users//.claude/CLAUDE.md" - ] + ], + "autoMemoryEnabled": false } ``` @@ -284,6 +285,7 @@ Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.s - **Instruções de fora ficam de fora (`claudeMdExcludes`).** O Claude Code carrega `CLAUDE.md`, `CLAUDE.local.md`, `.claude/CLAUDE.md` e `.claude/rules/` do cwd e de toda pasta acima dele, como instruções de projeto, e `--setting-sources` não muda isso. O workspace fica dentro de `%USERPROFILE%` por padrão, então o `%USERPROFILE%\.claude\CLAUDE.md` do dono, que são as instruções pessoais dele para o Claude Code, chegava a todo bot; com `workspaces_root` dentro de um projeto (um daemon de dev), chegava o `CLAUDE.md` do projeto. O daemon lista, para cada pasta acima da pasta da crew, de `workspaces_root` até a raiz do disco, os seis nomes que o Claude Code procura: `CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, `.claude/CLAUDE.md`, `.claude/AGENTS.md` e `.claude/rules/**`. São caminhos exatos, sem `**` no começo: nenhum padrão alcança outra pasta. - **O que continua carregando:** o `CLAUDE.md` e as regras do bot (5.1); o `CLAUDE.md` e as regras da pasta de trabalho da crew, a `shared\` ou a que o dono escolheu, que chegam pelo `--add-dir` (7.4); e um `CLAUDE.md` que o dono ponha na pasta da crew (`\CLAUDE.md`), que vale para todos os bots dela. O `CLAUDE.md` de política gerenciada (`C:\Program Files\ClaudeCode\CLAUDE.md`) não pode ser excluído (documentado) e continua valendo. - **Forma do caminho em `claudeMdExcludes`:** o padrão é comparado com o caminho absoluto como o Claude Code o monta a partir do cwd: `C:/Users/ana/...` (ou com `\`), com a letra do drive e as maiúsculas iguais às do cwd, porque a comparação diferencia maiúsculas. A forma `//c/...` das permission rules **não** casa aqui. O daemon monta os padrões com o mesmo caminho que passa como cwd. Colchetes, chaves e parênteses num nome de pasta são sintaxe de glob e viram `?` (um caractere qualquer); espaço, acento e o resto casam como estão (19). +- **Sem memória automática (`autoMemoryEnabled: false`).** O Claude Code tem uma memória própria, ligada por padrão: grava notas sozinho em `%USERPROFILE%\.claude\projects\\memory\` e carrega o índice delas em toda sessão. Nos bots ela fica desligada, porque a memória do bot é o `CLAUDE.md` do workspace (5.1), que o dono vê, edita e apaga junto com o bot. A memória automática fica fora do workspace, no perfil do dono, é gravada sem pedido de permissão mesmo no modo Manual, não aparece no app e sobra quando o bot é apagado; a pasta é escolhida pelo caminho do workspace ou, dentro de um repositório git, pelo repositório, e aí o bot carregava as notas que o Claude Code guardou para o próprio dono naquele repositório (19). Desligada, o prompt de sistema de cada bot também fica uns 3 mil tokens menor. O daemon não apaga nada em `%USERPROFILE%\.claude`: as notas que um bot gravou antes ficam onde estão e só deixam de ser carregadas. ## 8. Chat @@ -940,6 +942,7 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28; esf | Uso de contexto no `stream-json` | 8.6 | Parcialmente documentado: `usage` e `modelUsage` no `result` (`agent-sdk/typescript`), janela de 1 milhão em Fable, Sonnet 5+ e Opus 4.7+ e compactação automática em cerca de 967 mil (`model-config`). **Visto com 2.1.284**: cada `assistant` traz `message.usage` (`input_tokens`, `cache_creation_input_tokens`, `cache_read_input_tokens`, `output_tokens`), e a soma dos três primeiros bate com o `totalTokens` do `get_context_usage` a menos de 1%; `result.modelUsage[modelo]` traz `contextWindow` (200000 em Haiku 4.5, 1000000 em Sonnet 5.5) e `maxOutputTokens`; `result.usage.iterations[]` traz cada pedido do turno; `autoCompactThreshold` é a janela menos 33 mil (167000, 967000; 67000 com `--autocompact 100k`, que também encolhe `maxTokens`); `/context` como mensagem devolve um `assistant` sintético com `context_usage` e abre um turno, por isso o daemon usa o pedido de controle. `total_cost_usd` é o total do processo, não do turno. **Pelo daemon de dev**: o app recebeu o tamanho ao subir o bot, a cada pedido ao modelo e no fim do turno | feito | | Compactar em `-p` | 8.6 | `/compact [instruções]` é documentado (`commands`) sem dizer se vale em `-p`; o custo com cache quente e frio está em `prompt-caching`. **Testado com 2.1.284** (Haiku): `/compact` como mensagem imprime `system/status` (`status: "compacting"`), outro com `status: null` e `compact_result: "success"`, `system/init`, `system/compact_boundary` (`compact_metadata: {trigger: "manual", pre_tokens, post_tokens, duration_ms, ...}`), um `user` com `isSynthetic` e o resumo, dois `user` com `isReplay` (a saída `Compacted` e o comando, com o `uuid` enviado) e um `result` com `local_command: "compact"`, `num_turns: 0` e custo; levou de 12 a 16 s numa conversa pequena, e o bot lembrou depois o que tinha sido dito antes. Escrito durante um turno, roda quando o turno termina. Numa conversa vazia responde um `assistant` sintético "Error: No messages to compact" com `local_command_outcome: {kind: "failed"}` e um `result` sem erro. **Compactação automática** (Haiku, `--autocompact 100k`, quatro arquivos lidos): no meio do turno, os dois `system/status`, o `compact_boundary` com `trigger: "auto"` e o `user` sintético, sem `system/init` nem `result` próprios; o turno seguiu e terminou certo. **Pelo daemon de dev**: `bots.compact` deixou o bot `busy`, o aviso `compacted` entrou no chat, o tamanho caiu e o bot respondeu depois a palavra que tinha guardado; o log de `debug` só teve ids e números. Uma compactação que falha por erro da API não foi vista | feito | | CLAUDE.md do dono nos bots | 5, 7.5 | Confirmado (`memory`, `settings-reference`): o Claude Code carrega `CLAUDE.md`, `CLAUDE.local.md` e `.claude/CLAUDE.md` do cwd e de toda pasta acima dele; `claudeMdExcludes` pula arquivos por caminho absoluto ou glob, vale em qualquer camada de settings e também para `AGENTS.md`; o `CLAUDE.md` gerenciado não pode ser excluído. **Testado com 2.1.284** (`-p --setting-sources project,local`, `memoryFiles` do `get_context_usage`, numa pasta de teste dentro de `%USERPROFILE%`). Sem a setting: o `%USERPROFILE%\.claude\CLAUDE.md` do dono entra como `Project`, e de cada pasta acima entram `CLAUDE.md`, `CLAUDE.local.md`, `.claude/CLAUDE.md` e `.claude/rules/` com as subpastas; as skills são só as embutidas. Forma do padrão: `C:/Users/...` e `C:\Users\...` casam, também misturadas; `/c/Users/...` e `//c/Users/...` não casam; a comparação diferencia maiúsculas, até na letra do drive, e o Claude Code monta os caminhos com o cwd como o recebeu (um cwd em minúsculas aparece em minúsculas). Numa pasta chamada `odd (x) [y] {z}`, o caminho exato de um arquivo ainda casa, mas `.../.claude/rules/**` não; com `?` no lugar de cada um desses seis caracteres, casa. Espaço, acento e ``!+@#$%^&',;=~`` casam como estão. O `AGENTS.md` de uma pasta acima só entra com `instructionFiles: claude-md-and-agents-md` (os bots sempre têm `CLAUDE.md`), e o padrão o tira. `--add-dir` carrega `CLAUDE.md` e `.claude/rules/` só da própria pasta: os das pastas acima de uma pasta de trabalho escolhida não entram, com ou sem a setting. **Pelo daemon de dev** (workspaces dentro de `%USERPROFILE%`, com `CLAUDE.md` de teste em cada pasta acima; Haiku): com o `settings.json` gerado, `memoryFiles` e o próprio bot listaram só o `CLAUDE.md` da pasta da crew, o do bot, as regras e o da `shared\` | feito | +| Memória automática do Claude Code nos bots | 7.5 | Confirmado (`memory`, `settings-reference`): ligada por padrão; o Claude Code grava `MEMORY.md` e um arquivo por nota em `%USERPROFILE%\.claude\projects\\memory\` e carrega o índice em toda sessão; `` vem do repositório git ou, fora de um, do caminho do cwd; `autoMemoryEnabled: false` vale em qualquer settings. **Testado com 2.1.284** (`-p --setting-sources project,local`, Haiku): ligada também assim, com cerca de 3 mil tokens a mais no prompt de sistema (6457 contra 3495). A pasta `memory\` é criada quando a sessão sobe. Pedido para lembrar de uma coisa, o bot gravou a nota e o `MEMORY.md` nessa pasta, fora do workspace, sem pedido de permissão no modo `default`, e não tocou no `CLAUDE.md` dele. O nome de `` é o caminho com `-` no lugar de tudo que não é letra nem número (`C--Users-ana-Botloft-site-revisor`); por essa regra `site-web\dev` e `site\web-dev` dariam a mesma pasta (não testado). Com o cwd dentro de um repositório git, a sessão carregou como `AutoMem` o `MEMORY.md` que o Claude Code guarda para o dono naquele repositório. Com `autoMemoryEnabled: false` no `settings.json` do projeto, o prompt encolhe e nada disso é carregado, nem dentro do repositório. **Pelo daemon de dev** (Haiku, "Aceitar edições"): pedido para lembrar, o bot editou o `CLAUDE.md` do workspace, e nenhuma pasta `memory\` foi criada. Mover a memória automática para dentro do workspace (`autoMemoryDirectory`) não foi testado | feito | | Telas no WebView2 do app | 22.5 | Não visto: a CSP do Tauri com `frame-src http://127.0.0.1:*` e as telas carregando no `iframe` | manual (PR): `pnpm tauri dev`, pedir uma página HTML a um bot e ver a área de design | Itens do runtime anterior (ConPTY, hooks em exec form, `crossSessionInbound`, linha de auth do inbox, diálogo de confiança, `ESC[6n` do ConPTY, consultas do terminal no replay) foram verificados no M2–M4 e deixaram de se aplicar com a ADR 0001; o histórico está no git e na ADR.