+ {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 (
+
+
+ {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.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 }) {