From be804036df5700743e8c4109535714f664ff85fb Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:54:57 -0300 Subject: [PATCH 1/8] docs: bots run headless Claude Code and show as a chat (ADR 0001) The owner wants to talk to each bot like in a messaging app: bubbles, what the bot did at a glance, approvals with buttons, images and files. A terminal cannot give that. Each bot now runs as `claude -p` with stream-json on stdin and stdout; the daemon turns the events into chat items, messages go in through stdin (the owner's with full authority), approvals go through a permission-prompt tool on our MCP server, and attachments land in the bot's folder. Every behavior the design relies on was checked with Claude Code 2.1.284 (spec 19): input shape, several turns per process, queued input, replay with our uuid, image blocks, the permission-prompt tool, and isolation from the owner's settings. M4.1 is the switch. --- CLAUDE.md | 26 +-- docs/adr/0001-headless-chat.md | 51 +++++ docs/spec.md | 404 ++++++++++++++++++++------------- 3 files changed, 309 insertions(+), 172 deletions(-) create mode 100644 docs/adr/0001-headless-chat.md diff --git a/CLAUDE.md b/CLAUDE.md index c5eab9cc..99a3b200 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,9 +17,9 @@ Este projeto é implementação própria. Não copie nem adapte código, testes, ``` crates/botloft-core tipos de domínio, IDs, envelope, tipos do protocolo (exportados via ts-rs) crates/botloft-store SQLite, migrations em migrations/NNNN_nome.sql -crates/botloftd daemon: rpc, supervisor, runtime, terminal, courier, tools, hooks, platform -app/ Tauri v2 + React 19 + TS strict + Tailwind v4 + Zustand + xterm.js -docs/ spec e ADRs +crates/botloftd daemon: rpc, supervisor, runtime, chat, courier, tools, platform +app/ Tauri v2 + React 19 + TS strict + Tailwind v4 + Zustand +docs/ spec e ADRs (docs/adr/0001: bots headless com chat) ``` ## Comandos @@ -50,7 +50,7 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon - Código, identificadores, comentários, commits e README em **inglês**. Docs em `docs/` podem ser em português. - Arquivos com no máximo **~300 linhas**. Passou disso, divida por responsabilidade. - Erros: `thiserror` nos crates de biblioteca, `anyhow` só no binário. -- IDs são ULID com prefixo (`crw_`, `bot_`, `msg_`, `dlv_`, `tsk_`). Tempo em ms Unix. +- IDs são ULID com prefixo (`crw_`, `bot_`, `msg_`, `dlv_`, `tsk_`, `cht_`, `apr_`, `att_`). Tempo em ms Unix. - Tudo que é específico de Windows fica em `crates/botloftd/src/platform/`. O resto do daemon não chama Win32 direto. - A UI depende só da interface `BotloftApi` (`app/src/lib/api.ts`). Testes de componente usam `FakeBotloft`. - Nunca edite `app/src/lib/protocol.gen.ts` à mão. @@ -58,15 +58,13 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon ## Windows: armadilhas conhecidas -- O inbox do Claude Code no Windows é um **named pipe** criado pelo próprio Claude Code. O daemon é cliente. A primeira linha tem que ser o auth com `CLAUDE_CODE_MESSAGING_TOKEN`, senão a conexão é descartada. Abra o pipe só com a mensagem pronta. -- Hooks usam **exec form** (`"args": [...]`) chamando `botloftd.exe hook `. Nada de hook em bash ou PowerShell. O subcomando sai sempre com 0 e nunca escreve no stdout. -- Só o `claude.exe` nativo roda. O `claude.cmd` do npm é recusado: passar por `cmd.exe` estraga o quoting na PTY. -- ConPTY não tem fd para `poll`: leitura da PTY em thread dedicada + canal. Ao fim do processo, feche o pseudoconsole, senão a leitura nunca vê EOF. -- O ConPTY do `portable-pty` (com `PSEUDOCONSOLE_INHERIT_CURSOR`) manda `ESC[6n` ao iniciar e trava o filho até a resposta. O daemon responde `ESC[1;1R` à primeira consulta. +- Cada bot é `claude -p` com `stream-json` no stdin e no stdout (spec 7.4, 8, 9.2). O processo fica calado até a primeira mensagem; fechar o stdin o encerra. O formato de entrada não é documentado: qualquer mudança passa por teste real e vai para a seção 19. +- Bots rodam com `--setting-sources project,local` e `--strict-mcp-config`: nada de hooks, skills, modo de permissão ou MCP pessoais do dono. Aprovações passam pela tool `permission_prompt` do nosso MCP. +- Servidor MCP por HTTP: o Claude Code aborta a request em 60 s e a chamada em 5 min sem resposta, a menos que o `mcp.json` tenha `timeout` no servidor. A aprovação depende disso. +- Só o `claude.exe` nativo roda. O `claude.cmd` do npm é recusado: passar por `cmd.exe` estraga o quoting dos argumentos. - O bot recebe o ambiente padrão do usuário (`CreateEnvironmentBlock`), nunca o do daemon: em dev o daemon herda variáveis da sessão do Claude Code (`CLAUDE_CODE_MESSAGING_SOCKET`, `ANTHROPIC_BASE_URL`...). - PATH real pode ter aspas soltas. Separe só por `;`; `std::env::split_paths` trata aspas como agrupamento e engole o resto. -- Numa sessão interativa, os hooks só rodam depois de aceitar a confiança da pasta. Teste manual de bot novo começa pelo diálogo de confiança no terminal. -- Todo processo de bot entra num Job Object com kill-on-close. +- Todo processo de bot entra num Job Object com kill-on-close e roda sem console (`CREATE_NO_WINDOW`). - Permissão de arquivo é ACL (SID do usuário atual), não modo Unix. - Estado em `%LOCALAPPDATA%`, nunca `%APPDATA%` (roaming). - Cuidado com o limite de 260 caracteres em caminhos de workspace. @@ -74,7 +72,7 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon ## Segurança e privacidade - Daemon só em `127.0.0.1`. -- Nunca logar token, corpo de mensagem ou saída de terminal em nível `info` ou acima. Bots podem lidar com dado pessoal e de saúde. +- Nunca logar token, corpo de mensagem, anexo ou item do chat (resposta, saída de ferramenta) em nível `info` ou acima. Bots podem lidar com dado pessoal e de saúde. - Tokens de bot ficam no banco só como SHA-256. - Nada de telemetria. @@ -83,10 +81,10 @@ Em dev, use `$env:BOTLOFT_HOME = "$PWD\.dev\home"` (caminho absoluto) no daemon Uma tarefa só está pronta quando: 1. `cargo fmt`, `clippy -D warnings` e `cargo test` passam (e `pnpm check`, se mexeu no app); -2. comportamento novo tem teste (supervisor, courier e terminal testados com `FakeRuntime`); +2. comportamento novo tem teste (supervisor, courier e chat testados com `FakeRuntime`); 3. a spec continua verdadeira; 4. o que depende do Claude Code real está listado no PR com o passo de teste manual. ## Ordem de trabalho -Siga os marcos da spec (seção 17): M0 -> M1 -> M2 -> M3 -> M4 -> M5. Não comece rotinas, caixa de perguntas, busca ou acesso remoto antes do M5. +Siga os marcos da spec (seção 17): M0 -> M1 -> M2 -> M3 -> M4 -> M4.1 -> M5. Não comece rotinas, caixa de perguntas, busca ou acesso remoto antes do M5. diff --git a/docs/adr/0001-headless-chat.md b/docs/adr/0001-headless-chat.md new file mode 100644 index 00000000..0dd5d3d1 --- /dev/null +++ b/docs/adr/0001-headless-chat.md @@ -0,0 +1,51 @@ +# ADR 0001: bots em Claude Code headless, com chat + +Status: aceito (2026-09-28). Substitui o desenho de runtime do M2–M4 (ConPTY, terminal e inbox por named pipe). + +## Contexto + +Até o M4 cada bot rodava o Claude Code interativo numa ConPTY. O app mostrava o terminal (xterm.js), e as mensagens entre bots entravam pelo inbox nativo do Claude Code, um named pipe. + +Funcionou, mas o dono quer outra coisa: conversar com cada bot como num app de mensagens. As mensagens dele e do bot aparecem em balões, o que o bot fez aparece resumido, os pedidos de aprovação têm botões e dá para mandar imagens e arquivos. + +Um terminal não dá isso. Da TUI só saem pixels, e o conteúdo estruturado (texto do bot, ferramentas usadas, pedidos de permissão) não está disponível. + +O teste real no M4 também mostrou dois atritos do caminho antigo: + +- **Autoridade do dono:** tudo que entra pelo inbox o Claude Code apresenta como vindo "de outra sessão", inclusive a mensagem do dono, e isso não vale como aprovação. +- **Replay:** reenviar a tela ao reabrir o terminal fazia o xterm responder de novo a consultas antigas do terminal. + +## Decisão + +Cada bot roda como Claude Code headless: `claude -p --input-format stream-json --output-format stream-json`, com stdin e stdout em pipes. Não há PTY, TUI nem inbox. + +- **Tudo entra pelo stdin**, uma mensagem de usuário por linha JSON: o que o dono escreve, o que outros bots mandam e os avisos do daemon. A mensagem do dono vai como o texto dele, com a autoridade de quem digita. Mensagens de bots e avisos levam o envelope (spec 9.3). +- **Tudo sai pelo stdout como eventos.** O daemon transforma os eventos em itens de chat, grava no SQLite e manda ao app: texto da resposta, ferramentas com resultado, fim de turno e erros. O texto parcial vai ao vivo, sem gravar. +- **Permissões:** `--permission-prompt-tool` aponta para uma ferramenta do servidor MCP do próprio daemon. Ela segura a chamada até o dono responder no chat (Permitir ou Negar) ou até vencer o prazo. +- **Estados** saem do fluxo de eventos, sem hooks: turno em andamento, aprovação pendente, erro de limite ou de login. +- **Sessão:** o daemon guarda o `session_id` e reinicia com `--resume`. +- **Isolamento:** `--setting-sources project,local` e `--strict-mcp-config` deixam de fora os hooks, skills, agents, permissões e servidores MCP pessoais do dono. O bot vê só o que o Botloft gera. +- **Anexos** vão para a pasta do bot. Imagens também seguem inline, como blocos `image`. + +## Verificado com Claude Code 2.1.284 + +- Um processo atende vários turnos. Mensagem escrita durante um turno entra na fila e vira o turno seguinte. +- O processo fica calado até a primeira mensagem e sai com 0 quando o stdin fecha. +- `--replay-user-messages` devolve cada mensagem com o `uuid` que o daemon pôs, quando o turno dela começa. +- Um bloco `image` na mensagem de entrada é aceito. +- `--permission-prompt-tool mcp____` chama a ferramenta com `{tool_name, input, tool_use_id}`, espera a resposta (testado com 20 s) e segue `{"behavior":"allow","updatedInput":...}`. +- `--permission-prompts host` sem o aperto de mão do SDK nega tudo (`system/permission_denied`). O protocolo de controle do SDK não é documentado, então não é usado. +- `--setting-sources project,local` tirou os hooks e skills do usuário. +- Login pela assinatura funciona (`apiKeySource: none`). + +## O que se perde + +- **A TUI do Claude Code:** não há `/login`, seletor de `/model` nem `/mcp` interativos. + - Login e troca de conta ficam fora do bot (`claude auth login` num terminal). O app diz isso quando o bot cai em `auth_error`. + - Comandos com barra que o modo `-p` aceita continuam funcionando no chat. +- **O formato de entrada do `stream-json` não é documentado.** Ele foi verificado na prática, e a seção 19 da spec registra a versão testada. +- **O ConPTY, o terminal com replay, o inbox por named pipe e os hooks** do M2–M4 saem do código. + +## Alternativa descartada + +Manter a TUI e montar o chat lendo o transcript `.jsonl` da sessão. A documentação diz que esse formato é interno e muda entre versões. As aprovações e os anexos continuariam dependendo do terminal, e a mensagem do dono continuaria com autoridade de colega. diff --git a/docs/spec.md b/docs/spec.md index f5c672ed..b51bbc0f 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -1,8 +1,8 @@ -# Botloft: especificação v0.1 +# Botloft: especificação v0.2 -**TL;DR:** Botloft mantém "tripulações" de bots Claude Code sempre ligados no Windows. Um daemon Rust (`botloftd`) roda os bots em ConPTY, guarda tudo em SQLite e entrega mensagens entre bots pelo inbox nativo do Claude Code (named pipe). Um app Tauri + React mostra os terminais e controla tudo via JSON-RPC sobre WebSocket. O MVP cobre crews, bots, terminal e mensagens duráveis; rotinas, caixa de perguntas e acesso remoto vêm depois. +**TL;DR:** Botloft mantém "tripulações" de bots Claude Code sempre ligados no Windows. Um daemon Rust (`botloftd`) roda cada bot como Claude Code headless (`stream-json` pelo stdin e pelo stdout), guarda tudo em SQLite e entrega mensagens entre bots de forma durável. Um app Tauri + React mostra cada bot como um chat: o dono conversa, manda imagens e arquivos e aprova o que o bot pede, tudo via JSON-RPC sobre WebSocket. O MVP cobre crews, bots, chat e mensagens duráveis; rotinas, caixa de perguntas e acesso remoto vêm depois. -Status: rascunho para implementação. Este documento é a fonte de verdade do projeto. Quando código e spec divergirem, corrige-se um dos dois no mesmo PR. +Status: rascunho para implementação. Este documento é a fonte de verdade do projeto. Quando código e spec divergirem, corrige-se um dos dois no mesmo PR. Decisões de arquitetura ficam em `docs/adr/` (a ADR 0001 trocou o terminal pelo chat). --- @@ -13,8 +13,8 @@ Botloft é um workspace desktop, Windows-first e open-source, para rodar vários Princípios: 1. **Daemon independente do app.** Fechar o app não derruba bot nenhum. O daemon é a única fonte de verdade. -2. **Claude Code é o runtime.** Nada de SDK próprio de agente. O terminal nativo do Claude Code é preservado, com prompts de permissão e tudo. -3. **Terminal e mensagens em canais separados.** O que o usuário digita vai pela PTY. Mensagens de outros bots chegam pelo inbox do Claude Code, nunca injetadas no terminal. +2. **Claude Code é o runtime.** Cada bot é uma sessão do Claude Code em modo headless (`claude -p` com `stream-json` na entrada e na saída). Nada de SDK próprio de agente nem de loop reimplementado: o daemon só conversa com o processo pelo protocolo de linhas JSON. +3. **Uma conversa por bot.** Tudo que o bot recebe (dono, outros bots, avisos do daemon) entra pelo stdin como mensagem, em ordem. Tudo que ele faz sai pelo stdout como eventos, que o daemon grava e o app mostra como chat. 4. **Durável antes de entregar.** Toda mensagem é gravada antes de sair. Entrega é pelo menos uma vez, com retry. 5. **Sem serviços externos.** SQLite local, sem Redis, sem nuvem, sem telemetria no MVP. 6. **Testável sem gastar token.** Um runtime falso implementa o mesmo contrato do runtime real. @@ -27,33 +27,33 @@ Princípios: | **Crew** | Grupo de bots que podem conversar entre si. Tem pasta compartilhada `shared/`. | | **Bot** | Uma sessão Claude Code persistente com nome, papel e instruções. | | **Workspace** | Pasta de trabalho do bot, onde o Claude Code roda. | -| **Message** | Texto enviado a um bot, pelo usuário ou por outro bot. | -| **Delivery** | Tentativa de entregar uma message a um bot. Tem estado, tentativas e prazo. | +| **Message** | Texto enviado a um bot, pelo dono, por outro bot ou pelo daemon. Pode trazer anexos (só do dono). | +| **Delivery** | Entrega de uma message ao processo do bot. Tem estado, tentativas e prazo. | | **Task** | Message que pede trabalho e espera um resultado de volta. | -| **Generation** | Contador que incrementa a cada vez que o processo de um bot é (re)iniciado. | +| **Turn** | Um processamento do Claude Code: começa numa message e termina no evento `result`. | +| **Chat** | Histórico estruturado de um bot: messages recebidas, respostas, ferramentas usadas, aprovações e avisos. | +| **Approval** | Pedido de permissão de uma ferramenta, esperando o dono no chat. | +| **Generation** | Contador que muda a cada vez que o processo de um bot é (re)iniciado. | | **Owner** | O usuário humano dono da instalação. | ## 3. Arquitetura ``` - App (Tauri + React + xterm.js) + App (Tauri + React) │ JSON-RPC 2.0 sobre WebSocket (127.0.0.1:45710/rpc) ▼ botloftd ───────────────────────────────────────────────────── rpc/ sessões WS, autenticação, métodos e notificações supervisor/ ciclo de vida dos bots, estados, backoff - runtime/ ConPTY (real) e FakeRuntime (testes) - terminal/ ring buffer por bot, cursores de replay - courier/ worker de entrega (lease, retry, dead) - tools/ servidor MCP HTTP (/mcp) com tools dos bots - hooks/ endpoint /hooks + subcomando `botloftd hook` + runtime/ processo claude com pipes (real) e FakeRuntime (testes) + chat/ leitura do stream-json, itens do chat, texto ao vivo + courier/ worker de entrega (escreve no stdin, retry, dead) + tools/ servidor MCP HTTP (/mcp): tools da crew e aprovações store/ SQLite (WAL), migrations - platform/ pipes, ACL, Job Objects, tarefa agendada, keep-awake - │ ConPTY ▲ HTTP /mcp e /hooks (token do bot) - ▼ │ - claude.exe (um processo por bot) ───────┘ - ▲ - └─ named pipe do inbox (criado pelo Claude Code; botloftd é cliente) + platform/ ACL, Job Objects, ambiente do usuário, tarefa agendada, keep-awake + │ stdin (mensagens) ▲ stdout (eventos) ▲ HTTP /mcp (token do bot) + ▼ │ │ + claude.exe -p (um processo por bot) ──────────────┘ ``` ### 3.1 Layout do repositório @@ -63,7 +63,7 @@ botloft/ crates/ botloft-core/ tipos de domínio, IDs, erros, render de envelope, tipos do protocolo botloft-store/ SQLite: conexão, migrations, repositórios - botloftd/ binário do daemon (rpc, supervisor, runtime, courier, tools, hooks, platform) + botloftd/ binário do daemon (rpc, supervisor, runtime, chat, courier, tools, platform) app/ src/ React src-tauri/ shell nativa @@ -81,14 +81,14 @@ Tipos do protocolo ficam em `botloft-core` e são exportados para TypeScript com |---|---|---| | Daemon | Rust stable, Tokio, Axum | Async maduro, WebSocket e HTTP no mesmo servidor | | Banco | `rusqlite` (bundled), WAL, FTS5 depois | Arquivo único, sem servidor | -| PTY | `portable-pty` (ConPTY) | Única opção madura em Rust para ConPTY | -| Win32 | crate `windows` | ACL, Job Objects, keep-awake, pipes | +| Processo do bot | `tokio::process` com pipes | stdin/stdout de linhas JSON, sem PTY | +| Win32 | crate `windows` | ACL, Job Objects, ambiente do usuário, keep-awake | | MCP | servidor Streamable HTTP próprio e mínimo (JSON-RPC) | Poucas tools, sem dependência pesada | | Tempo | `time` + `croner` (rotinas, pós-MVP) | | -| IDs | ULID com prefixo (`bot_`, `crw_`, `msg_`, `dlv_`, `tsk_`) | Ordenável, legível em log | +| IDs | ULID com prefixo (`bot_`, `crw_`, `msg_`, `dlv_`, `tsk_`, `cht_`, `apr_`, `att_`) | Ordenável, legível em log | | App | Tauri v2, React 19, TypeScript strict, Vite, Tailwind v4 | | | Estado no app | Zustand | Leve, sem boilerplate | -| Terminal | `@xterm/xterm` + addons fit e webgl | | +| Markdown no chat | `react-markdown` + `remark-gfm`, sem HTML cru | Resposta do bot é markdown; nada de `dangerouslySetInnerHTML` | | Qualidade | rustfmt, clippy `-D warnings`, Biome, Vitest | Biome faz lint e format do TS numa ferramenta só | ## 5. Estado em disco @@ -116,8 +116,9 @@ O **handle** do bot (`@revisao`) é derivado do nome pela mesma regra, acompanha ``` \ CLAUDE.md memória viva do bot (o bot edita; o daemon só cria se não existir) + attachments\\ arquivos que o dono mandou no chat (9.5) .claude\ - settings.json hooks + permissões (gerado pelo daemon, sobrescrito a cada start) + settings.json regra de negação para os segredos (gerado pelo daemon, sobrescrito a cada start) rules\botloft.md identidade, papel, crew e guia de uso das tools (gerado pelo daemon) .botloft\ mcp.json config MCP do bot (gerado pelo daemon) @@ -146,8 +147,9 @@ max_attempts = 8 retry_backoff_initial_ms = 2000 retry_backoff_max_ms = 120000 -[terminal] -ring_buffer_bytes = 1048576 +[bots] +approval_timeout_minutes = 60 # sem resposta do dono, a ferramenta é negada +attachment_max_mb = 20 # por arquivo; no máximo 10 arquivos por message [tasks] max_hops = 4 @@ -161,141 +163,166 @@ default_deadline_minutes = 120 | Estado | Quando | |---|---| | `offline` | Sem processo e sem restart agendado (crew ou bot pausado) | -| `launching` | Processo criado, aguardando hook `SessionStart` | -| `idle` | Sessão pronta, sem turno em andamento | -| `busy` | Turno em andamento | -| `needs_approval` | Claude Code mostrando prompt de permissão | -| `rate_limited` | Turno falhou por limite de uso | +| `launching` | Processo criado, nos primeiros 1,5 s | +| `idle` | Processo vivo, sem turno em andamento | +| `busy` | Turno em andamento ou na fila do Claude Code | +| `needs_approval` | Uma ferramenta espera o dono aprovar (10.1) | +| `rate_limited` | O limite de uso da conta foi atingido; espera `resetsAt` | | `auth_error` | Claude Code sem autenticação válida | | `backoff` | Processo morreu; relançamento agendado | | `archived` | Bot arquivado; processo parado, token revogado | ### 7.2 Fontes de transição +Os estados saem do próprio fluxo de eventos (8.1); não há hooks. + | Evento | Novo estado | |---|---| | spawn do processo | `launching` | -| hook `SessionStart` | `idle` (e registra o inbox, ver 9.2) | -| hook `UserPromptSubmit` | `busy` | -| hook `Stop` | `idle` | -| hook `StopFailure` com `rate_limit` | `rate_limited` | -| hook `StopFailure` com `authentication_failed` ou `oauth_org_not_allowed` | `auth_error` | -| hook `StopFailure` com outro `error` | `idle` (o turno acabou, a sessão segue) | -| hook `Notification` com `permission_prompt` | `needs_approval` | -| input do usuário depois de `needs_approval` | `busy` | +| processo vivo há 1,5 s | `idle` (o Claude Code fica calado até a primeira mensagem) | +| delivery escrita no stdin | `busy` (conta um turno pendente) | +| `result` | `idle` se não sobrou turno pendente, senão continua `busy` | +| a tool de aprovação é chamada | `needs_approval` | +| aprovação respondida ou vencida | `busy` | +| erro `rate_limit` num turno, ou `rate_limit_event` com status diferente de `allowed` | `rate_limited` até `resetsAt` (5 min se não vier), depois `idle` | +| erro `authentication_failed`, `oauth_org_not_allowed`, `billing_error` ou `account_on_hold` | `auth_error`; o processo é parado | | saída do processo | `backoff` (ou `offline`/`archived` se foi pedido) | -Campos lidos do JSON do stdin (documentados): `error` no `StopFailure`, `notification_type` no `Notification`. Hooks de uma generation antiga são ignorados. Todo processo novo emite `bot.state {botId, state, generation}`, mesmo que o nome do estado não mude, porque a generation mudou. +Eventos de uma generation antiga são ignorados. Todo processo novo emite `bot.state {botId, state, generation}`, mesmo que o nome do estado não mude, porque a generation mudou. ### 7.3 Regras - Bots não pausados sempre rodam. O supervisor reconcilia no boot e a cada 5 s. - Backoff exponencial com jitter entre `restart_backoff_initial_ms` e `restart_backoff_max_ms`; zera após 10 min de sessão estável. -- Relançamento usa `--continue` para retomar a conversa. Se a sessão retomada morrer em menos de `fresh_start_if_dies_within_s`, o próximo start vem sem `--continue`. O primeiro start de um bot não usa `--continue`: o daemon grava `.botloft/started` no workspace no primeiro `SessionStart` e só a partir daí retoma. `bots.restart {fresh: true}` também começa conversa nova. +- **Sessão:** o daemon guarda em `bots.session_id` o id da conversa. O primeiro start usa `--session-id `; os seguintes, `--resume `. Se a sessão retomada morrer em menos de `fresh_start_if_dies_within_s`, o próximo start vem com um id novo. `bots.restart {fresh: true}` também começa conversa nova. O histórico do chat fica no banco do daemon (seção 8) e não depende do transcript do Claude Code. - Mudanças em nome, papel ou instruções regravam as regras na hora, mas o bot só as lê no próximo start; o daemon não reinicia o bot sozinho. -- `auth_error` não entra em loop de restart: fica parado até o owner pedir `bots.restart`. +- `auth_error` não entra em loop de restart: fica parado até o owner pedir `bots.restart`. O login é feito fora do bot (`claude auth login` num terminal), e o app diz isso. - Cada processo de bot entra num **Job Object** com `KILL_ON_JOB_CLOSE`. Se o daemon morrer, a árvore de processos dos bots morre junto e não sobra `claude.exe` órfão. ### 7.4 Spawn -1. Resolver o binário: `claude_path` ou PATH. Só o `claude.exe` nativo roda. O `claude.cmd` do npm é recusado com erro claro: passar por `cmd.exe /d /s /c` estraga o quoting dos argumentos na PTY, e a documentação recomenda o instalador nativo. O PATH é separado só por `;`, como o Windows faz; aspas soltas numa entrada (comum em PATHs reais) não escondem as entradas seguintes. -2. `probe`: `claude --version` e exigir **>= 2.1.234** (inbox via named pipe no Windows nativo). Sem Claude utilizável, os bots ficam `offline`, `system.status.runtimeError` diz o motivo e o daemon tenta de novo a cada 30 s. +1. Resolver o binário: `claude_path` ou PATH. Só o `claude.exe` nativo roda; o `claude.cmd` do npm é recusado com erro claro (passar por `cmd.exe` estraga o quoting dos argumentos). O PATH é separado só por `;`, como o Windows faz; aspas soltas numa entrada (comum em PATHs reais) não escondem as entradas seguintes. +2. `probe`: `claude --version` e exigir **>= 2.1.234**. Sem Claude utilizável, os bots ficam `offline`, `system.status.runtimeError` diz o motivo e o daemon tenta de novo a cada 30 s. 3. Gerar os arquivos da seção 5.1. -4. Comando: `claude [--continue] --settings \.claude\settings.json --mcp-config \.botloft\mcp.json`. O `--settings` aponta para o mesmo arquivo que o Claude Code já lê como settings do projeto; a documentação garante que um hook definido em dois arquivos roda uma vez só, e o `--settings` dá precedência ao `crossSessionInbound` sobre as settings do usuário. -5. Ambiente: o bloco padrão do usuário (`CreateEnvironmentBlock`, o mesmo de um logon novo), **não** o ambiente do daemon. Um daemon iniciado de dentro de uma sessão do Claude Code herda `CLAUDECODE`, `CLAUDE_CODE_MESSAGING_SOCKET`, `ANTHROPIC_BASE_URL` e outras variáveis da sessão, que fariam o bot se achar filho dela. Por cima vão `BOTLOFT_BOT_ID`, `BOTLOFT_BOT_TOKEN`, `BOTLOFT_PORT`, `BOTLOFT_BIN` (caminho absoluto do `botloftd.exe`) e `BOTLOFT_HOME` (onde o hook grava `logs\hook.log`). -6. PTY com cwd no workspace e tamanho vindo do último `terminal.resize` (padrão 120x32). - -### 7.4.1 Primeira execução de cada bot - -Numa sessão interativa, o Claude Code segura **todos** os hooks até o dono aceitar o diálogo de confiança da pasta (documentado; confirmado com 2.1.283). Na primeira execução o bot fica em `launching`, com o diálogo no terminal, e só vai para `idle` quando alguém escolhe "Yes, I trust this folder" (a opção pré-selecionada é "No, exit"). Depois disso a confiança fica gravada para aquela pasta. Avisos de primeira execução vindos da configuração global do usuário também aparecem no terminal (ex.: extensão do Chrome detectada, novo renderizador). - -Confiar na raiz dos workspaces não resolve: a confiança de uma pasta pai cobre as subpastas, mas **não** vale para `permissions.allow` do `.claude/settings.json` do projeto, e o diálogo volta listando essas regras (documentado em `permissions`). Como o `settings.json` gerado libera `mcp__botloft` (7.5), cada bot novo pergunta uma vez. O app explica isso no primeiro uso e o dono responde no terminal do próprio bot, dentro do app. O Botloft não grava `hasTrustDialogAccepted` no `~/.claude.json`: o arquivo é do Claude Code, que o reescreve o tempo todo. +4. Comando, com cwd no workspace: + + ``` + claude -p --input-format stream-json --output-format stream-json --verbose + --include-partial-messages --replay-user-messages + (--session-id | --resume ) + --setting-sources project,local + --mcp-config \.botloft\mcp.json --strict-mcp-config + --permission-mode default + --permission-prompt-tool mcp__botloft__permission_prompt + --allowedTools mcp__botloft + ``` + + - `--setting-sources project,local` e `--strict-mcp-config` deixam de fora hooks, skills, agents, modo de permissão e servidores MCP pessoais do dono: o bot vê o que o Botloft gera. O login da conta não é uma fonte de settings e continua valendo. + - `--allowedTools mcp__botloft` libera as tools da crew sem aprovação. Uma regra `allow` no `settings.json` do projeto não bastaria: em `-p`, numa pasta que nunca passou pelo diálogo de confiança, o Claude Code não aplica as regras `allow` do projeto (documentado em `permissions`). + - Qualquer outra ferramenta que peça permissão passa pela tool de aprovação (10.1) e vira um pedido no chat. +5. Ambiente: o bloco padrão do usuário (`CreateEnvironmentBlock`, o mesmo de um logon novo), **não** o ambiente do daemon. Um daemon iniciado de dentro de uma sessão do Claude Code herda `CLAUDECODE`, `CLAUDE_CODE_MESSAGING_SOCKET`, `ANTHROPIC_BASE_URL` e outras variáveis da sessão, que fariam o bot se achar filho dela. Por cima vão `BOTLOFT_BOT_ID`, `BOTLOFT_BOT_TOKEN` e `BOTLOFT_PORT`. +6. stdin, stdout e stderr em pipes, sem console (`CREATE_NO_WINDOW`). O stdin fica aberto enquanto o processo vive; fechá-lo encerra o Claude Code com código 0. O stderr vai para o log em nível `debug`, sem conteúdo de mensagem. ### 7.5 `settings.json` gerado ```json { - "crossSessionInbound": "accept", - "hooks": { - "SessionStart": [{ "hooks": [{ "type": "command", "command": "", "args": ["hook", "session-start"] }] }], - "UserPromptSubmit": [{ "hooks": [{ "type": "command", "command": "", "args": ["hook", "prompt-submit"] }] }], - "Stop": [{ "hooks": [{ "type": "command", "command": "", "args": ["hook", "stop"] }] }], - "StopFailure": [{ "hooks": [{ "type": "command", "command": "", "args": ["hook", "stop-failure"] }] }], - "Notification": [{ "hooks": [{ "type": "command", "command": "", "args": ["hook", "notification"] }] }], - "SessionEnd": [{ "hooks": [{ "type": "command", "command": "", "args": ["hook", "session-end"] }] }] - }, "permissions": { - "allow": ["mcp__botloft"], "deny": ["Read(//c/Users//AppData/Local/Botloft/secrets/**)"] } } ``` -- Hooks em **exec form** (`args` presente): o Claude Code executa o binário direto, sem Git Bash nem PowerShell. Some o problema de quoting, de `curl` e de path com barra invertida. -- O `command` do exec form precisa ser um `.exe` de verdade; shims `.cmd`/`.bat` exigem shell. `botloftd.exe` atende. -- `mcp__botloft` libera as tools da crew (seção 10) sem prompt de permissão: um bot sozinho não tem quem aprove, e a regra vale só para o servidor `botloft`. -- `crossSessionInbound: accept` é obrigatório: o daemon não é processo filho da sessão, então sem isso a mensagem pode ficar retida esperando aprovação. +- Regras `deny` valem mesmo sem confiança na pasta: elas só restringem. - Caminho absoluto em permission rule usa o prefixo `//` e a forma POSIX que o Claude Code aplica no Windows: `C:\Users\ana\...` vira `//c/Users/ana/...` (letra do drive em minúscula). Uma barra só (`/caminho`) é relativa à origem do settings, não à raiz, e não protegeria nada. O daemon converte `BOTLOFT_HOME` para essa forma ao gerar o arquivo. +- Em `-p` o diálogo de confiança da pasta nunca aparece (documentado), então bot novo começa a trabalhar sem passo manual. + +## 8. Chat -### 7.6 Subcomando `botloftd hook ` +O chat de um bot é a sequência de itens que o daemon monta a partir do que entra no stdin e do que sai no stdout. Ele fica no banco (`chat_items`), é paginado pelo app e é a única visão da conversa: não há terminal. -- Lê o JSON do stdin, lê `BOTLOFT_BOT_TOKEN` e `BOTLOFT_PORT` do ambiente. -- `session-start` também lê `CLAUDE_CODE_MESSAGING_SOCKET` e `CLAUDE_CODE_MESSAGING_TOKEN`. -- Faz `POST http://127.0.0.1:/hooks/` com `Authorization: Bearer `, timeout de 3 s. -- **Sempre** sai com código 0 e **nunca** escreve no stdout (no `SessionStart`, stdout vira contexto do Claude). Erros vão para `logs\hook.log`. +### 8.1 Leitura do stdout -## 8. Terminal +Uma linha JSON por evento, em UTF-8. Linha que não é JSON, ou maior que 8 MiB, é descartada com um aviso em `debug`. Tipos desconhecidos são ignorados (o protocolo cresce entre versões do Claude Code). O que o daemon usa: -- Cada processo tem três threads: leitura da PTY, escrita (a escrita nunca bloqueia quem chama) e espera pelo fim do processo. A leitura manda blocos por canal para a task async. Quando o processo termina, o daemon fecha o pseudoconsole; sem isso o ConPTY nunca entrega EOF. -- Coalescência: junta leituras por até 8 ms ou 32 KiB antes de emitir. -- **Handshake do ConPTY:** o `portable-pty` cria o pseudoconsole com `PSEUDOCONSOLE_INHERIT_CURSOR`, e com isso o ConPTY pergunta a posição do cursor (`ESC[6n`) ao iniciar e segura o processo filho até ouvir a resposta. O daemon responde `ESC[1;1R` à primeira consulta de cada generation; as seguintes ficam com o terminal do app (confirmado com Claude Code real: sem a resposta, a tela fica vazia para sempre). -- Cada bot tem um ring buffer de `ring_buffer_bytes` com cursor `offset` (bytes desde o início da generation). O buffer fica depois que o processo morre, para o dono ver a última tela. -- Generations são únicas entre bots e entre reinícios do daemon (o contador começa no horário de boot em ms), para um cliente nunca confundir um processo novo com um que já viu. -- `terminal.attach {botId, generation?, offset?}`: - - mesma generation e offset ainda no buffer: responde `{generation, offset, reset: false}` e envia só o que falta; - - senão: `{reset: true}` e envia o buffer inteiro; se o buffer já descartou o começo da generation, corta no primeiro `\n` para não começar no meio de uma sequência de escape. -- A resposta traz também `liveOffset`, o fim do replay: dali em diante a saída é ao vivo. **O cliente não responde às consultas do terminal que estão no replay** (posição do cursor, atributos do dispositivo, `XTVERSION`, flags de teclado...). Elas foram feitas no passado, e a resposta chegaria ao bot como teclas digitadas. Visto com Claude Code 2.1.284: o replay de um bot recém-iniciado traz `ESC[6n`, três `ESC[>0q` e um `ESC[?u`, e sem esse cuidado aparecia um caractere solto no prompt. O app descarta o que o xterm.js "digita" enquanto interpreta o replay. -- A resposta do `terminal.attach` sai antes do primeiro `terminal.data`. `terminal.detach {botId}` para o envio. `terminal.write {botId, data}` e `terminal.resize {botId, cols, rows}` (1 a 1000) respondem `null`; escrever num bot sem processo dá `-32003`. -- Dados vão em `terminal.data {botId, generation, offset, data}` com `data` em base64 (bytes crus; o xterm.js recebe `Uint8Array` e resolve UTF-8 quebrado entre blocos). Um `terminal.data` com generation nova é uma tela nova: o cliente limpa antes de escrever. Um cliente que atrasa pode ver o offset pular; ele percebe (offset diferente do esperado) e refaz o attach com o que tem. -- Vários clientes podem assistir ao mesmo bot. Qualquer cliente autenticado pode escrever (MVP só tem o owner). +| Evento | O que vira | +|---|---| +| `system/init` | guarda `session_id` em `bots.session_id` (vem no começo de cada turno) | +| `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 | +| `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) | +| `rate_limit_event` | uso da conta (janelas de 5 h e 7 dias) em `system.status.usage`; status diferente de `allowed` leva a `rate_limited` | +| `result` | item `turn` com duração, custo e erro (se houve); fecha um turno pendente | + +Blocos `thinking` não são gravados nem mostrados. + +### 8.2 Itens + +Todo item tem `id` (`cht_`), `botId`, `kind`, `createdAt` e `updatedAt`. + +| `kind` | Campos | Origem | +|---|---|---| +| `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` | ferramenta usada pelo bot | +| `approval` | `approvalId`, `toolName`, `summary`, `input`, `status` (`pending`, `allowed`, `denied`, `expired`), `note` | pedido de permissão (10.1) | +| `turn` | `durationMs`, `costUsd`, `error` | fim de um turno | +| `notice` | `level` (`info`, `warning`, `error`), `text` | avisos do daemon: limite de uso, login, sessão reiniciada | + +- `summary` é uma frase curta feita pelo daemon a partir da entrada: 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`. Ferramenta desconhecida mostra só o nome. +- `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. +- 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`. + +### 8.3 Ao vivo + +- `chat.item {item}`: item novo ou atualizado (tool que terminou, aprovação respondida). +- `chat.delta {botId, text}`: pedaço do texto que o bot está escrevendo, na ordem. O app junta os pedaços num balão provisório, trocado pelo `reply` quando ele chega. Um app que conecta no meio de um turno não vê o texto parcial já passado, só o que vier depois e o `reply` final. + +### 8.4 Privacidade + +Itens do chat são dado pessoal como o corpo das messages: nunca vão para o log em nível `info` ou acima. O log de `debug` registra só tipos de evento e ids. ## 9. Mensagens e entrega ### 9.1 Fluxo -1. `messages.send` (owner) ou tool `send_message` (bot) grava `message` + `delivery` (`pending`) numa transação. +1. `messages.send` (owner) ou tool `send_message` (bot) grava `message`, `delivery` (`pending`) e o item `inbound` no chat do destinatário, numa transação. Anexos são gravados antes (9.5). 2. O **courier** acorda a cada `poll_interval_ms` (e na hora, via notify, quando entra delivery nova ou termina um envio). 3. Entrega em ordem, uma por vez por bot: de cada bot, só a delivery `pending` mais antiga pode sair, quando `next_attempt_at <= agora` e o bot não tem outra em `sending`. Ela vira `sending` com `lease_until`. -4. Se o bot não está em `idle`/`busy`/`needs_approval` ou o inbox não foi registrado: volta para `pending` com `next_attempt_at` em 5 s, **sem** contar tentativa. Bot ou crew arquivados: a delivery vira `dead` na hora. -5. Renderiza o envelope (9.3) na hora do envio e escreve no pipe (9.2), com timeout de 10 s. -6. Sucesso: `sent`. Falha: `attempts += 1` e nova tentativa em `retry_backoff_initial_ms * 2^(attempts-1)`, limitado a `retry_backoff_max_ms`; ao chegar em `max_attempts`, `dead`. Se o bot reiniciou durante o envio (inbox trocou), a falha não conta tentativa. -7. Lease vencido (daemon caiu no meio): volta a `pending` no boot e a cada ciclo. -8. `deliveries.retry` devolve uma delivery `dead` para `pending`, com as tentativas zeradas. +4. Se o bot não está em `idle`/`busy`/`needs_approval`: volta para `pending` com `next_attempt_at` em 5 s, **sem** contar tentativa. Bot ou crew arquivados: a delivery vira `dead` na hora. +5. Monta a mensagem (9.2) na hora do envio e escreve uma linha no stdin do processo, com timeout de 10 s. +6. Escrita aceita: `sent`, com a generation do processo. Falha de escrita (processo saindo): volta para `pending` sem contar tentativa. +7. Quando o Claude Code começa o turno daquela mensagem, ele a devolve no stdout com o mesmo `uuid` (`--replay-user-messages`), e a delivery ganha `read_at`. O app mostra isso como "lida". +8. **O processo morreu antes de ler:** a delivery ainda está `sent` sem `read_at` e com a generation que acabou. Ela volta para `pending` com `attempts += 1` e o backoff de `retry_backoff_initial_ms * 2^(attempts-1)`, limitado a `retry_backoff_max_ms`. Ao chegar em `max_attempts` vira `dead`: uma mensagem que derruba o processo toda vez não fica em laço. +9. Lease vencido (daemon caiu no meio): volta a `pending` no boot e a cada ciclo. +10. `deliveries.retry` devolve uma delivery `dead` para `pending`, com as tentativas zeradas. -`sent` quer dizer que o Claude Code aceitou a conexão. Não prova que o bot fez o trabalho; para isso existem tasks. +`sent` quer dizer que o processo do bot recebeu a mensagem na fila dele; `read_at`, que o bot começou a trabalhar nela. Nenhum dos dois prova que o trabalho foi feito; para isso existem tasks. Os horários de `next_attempt_at`, `lease_until` e prazos vêm de um relógio injetável do daemon, para os testes controlarem o tempo sem esperar. -### 9.2 Escrita no inbox (Windows) +### 9.2 Entrada pelo stdin + +Uma linha JSON por mensagem, terminada em `\n`, em UTF-8: -- Endereço e token chegam pelo hook `session-start` e ficam só em memória, por generation. O endereço tem a forma `\\.\pipe\LOCAL\cc-msg-<32 hex>` e o token tem 32 caracteres (visto com 2.1.283). -- Abrir o pipe como cliente **somente com a mensagem pronta** (o Claude Code derruba conexão sem linha completa em 30 s). -- Se `ERROR_PIPE_BUSY`, tentar abrir de novo a cada 50 ms por até 2 s, dentro da mesma tentativa. -- Conteúdo, uma linha JSON por item, terminado em `\n`, em UTF-8: - 1. `{"type":"auth","token":""}` (**obrigatório** no Windows) - 2. `{"type":"user","message":{"role":"user","content":""}}` -- Fechar a conexão logo depois de escrever. O Claude Code não responde nada; o que já foi escrito continua legível para ele depois do fechamento. -- A documentação oficial confirma a linha de auth, a obrigatoriedade dela no Windows e o limite de 30 s, mas **não documenta** a linha de mensagem (item 2). Ela foi confirmada com teste real (2.1.283, ver seção 19): - - a mensagem chega como `Another Claude session sent a message:` seguida do texto e de um aviso de que mensagem de outra sessão não aprova prompts nem muda configuração; conteúdo com várias linhas e acentos chega intacto e abre um turno se o bot estiver parado; - - sem a linha de auth, ou com token errado, o Claude Code fecha a conexão e não entrega nada; - - com auth válido, uma linha que não é JSON é ignorada em silêncio e a conexão continua aberta; - - três conexões seguidas, uma mensagem cada, chegaram todas. -- Mensagens do owner chegam com o mesmo aviso de "outra sessão": o envelope (9.3) diz que vieram do owner, mas elas não valem como aprovação de prompt de permissão. -- Limites documentados do lado do Claude Code: mensagem de até ~1 milhão de caracteres, no máximo 50 mensagens aceitas na fila, rajadas recusadas e repetições idênticas descartadas. O courier respeita isso com uma entrega por vez por bot e corpo de no máximo 100 000 caracteres. +```json +{"type":"user","uuid":"","message":{"role":"user","content":[]}} +``` -### 9.3 Envelope +- `uuid` é o ULID da delivery no formato de UUID. O Claude Code o devolve no replay (visto com 2.1.284). +- Blocos: um `text` com o texto (9.3) e, para imagens anexadas, blocos `image` com `source: {type: "base64", media_type, data}`. +- Mensagem escrita durante um turno entra na fila do Claude Code e vira o turno seguinte (visto com 2.1.284). O courier não precisa esperar o bot ficar parado. +- O formato de entrada do `stream-json` **não é documentado**; foi verificado com teste real (seção 19). -O texto que o bot lê é em inglês, como as regras geradas (5.1): +### 9.3 Texto que o bot recebe + +A mensagem do **dono** vai como ele escreveu, sem envelope: é o usuário da sessão falando, com a autoridade de quem digita. + +Mensagens de **outros bots** e **avisos do daemon** levam um envelope em inglês, como as regras geradas (5.1): ``` [botloft] from @revisor · crew Exemplo · task tsk_01J9Z... · due in 2 h @@ -305,11 +332,9 @@ Reply with send_message(to: "revisor"). When the task is done, call complete_tas ``` - Nota de outro bot: primeira linha `[botloft] from @revisor · crew Exemplo` e só a instrução de resposta. -- Mensagem do owner: `from the owner`, sem `@` (um bot pode se chamar "Owner") e sem linha de instrução. - Resultado de task: `· result of task tsk_... · done` (ou `failed`) e a instrução de resposta. - Aviso do daemon (task vencida): `from Botloft` e o texto do aviso, sem instrução. - O prazo é relativo (`due in 45 min`, `due in 2 h`, `overdue`) e calculado na hora do envio: o bot não sabe a hora atual, e o app mostra o horário absoluto a partir de `deadline_at`. -- Tudo que chega pelo inbox, inclusive a mensagem do owner, o Claude Code apresenta como vindo de outra sessão e não digitado pelo usuário. Visto com 2.1.284: depois do envelope vem um aviso de que um par não concede permissão nem aprova prompt pendente. Então uma mensagem pela timeline tem autoridade de colega. Para agir como o usuário do bot (aprovar um prompt, mudar configuração), o owner digita no terminal do bot. ### 9.4 Tasks entre bots @@ -321,16 +346,26 @@ Reply with send_message(to: "revisor"). When the task is done, call complete_tas - Task vencida vira `expired` no ciclo do courier, e o solicitante recebe um aviso do daemon (`from Botloft`). Ela continua aceitando resultado atrasado. - `cancelled` fica reservado: nenhuma tool cancela task no MVP. +### 9.5 Anexos + +- Só o dono manda anexos, pelo `messages.send` (até 10 arquivos, cada um até `attachment_max_mb`). O app manda os bytes em base64; nomes são reduzidos ao nome do arquivo, sem pasta. +- O daemon grava cada arquivo em `\attachments\\` (nome repetido ganha sufixo `-2`) e registra na tabela `attachments`. +- Imagens PNG, JPEG, GIF e WebP de até 5 MB também seguem inline como blocos `image`, para o bot vê-las sem abrir arquivo. +- O texto da mensagem ganha, no fim, a lista do que foi salvo (`Attached files, saved in your folder: attachments/2026-09-28/relatorio.pdf`), para o bot abrir o resto com as ferramentas dele (PDF, planilha, código...). +- Anexos ficam na pasta do bot até alguém apagar; o chat mostra nome, tipo e tamanho, e o app abre a pasta. + ## 10. Tools MCP dos bots (`POST /mcp`) Autenticação: `Authorization: Bearer `, só de uma generation viva; outro token dá HTTP 401. `mcp.json` gerado: ```json { "mcpServers": { "botloft": { "type": "http", "url": "http://127.0.0.1:45710/mcp", - "headers": { "Authorization": "Bearer ${BOTLOFT_BOT_TOKEN}" } } } } + "headers": { "Authorization": "Bearer ${BOTLOFT_BOT_TOKEN}" }, + "timeout": <(approval_timeout_minutes + 2) em ms> } } } ``` -A expansão `${BOTLOFT_BOT_TOKEN}` foi confirmada com o Claude Code real (seção 19), então o token não vai para o disco. +- A expansão `${BOTLOFT_BOT_TOKEN}` foi confirmada com o Claude Code real (seção 19), então o token não vai para o disco. +- `timeout` existe por causa da aprovação (10.1). Um servidor MCP por HTTP tem, por padrão, 60 s por request e 5 min sem resposta antes de o Claude Code abortar a chamada. O `timeout` por servidor (>= 1000) sobe os dois limites (documentado em `env-vars`, `MCP_TOOL_TIMEOUT` e `CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT`). Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão e sem stream. O servidor fala duas eras do MCP, porque o Claude Code 2.1.284 tenta a nova e cai para a antiga: @@ -344,28 +379,47 @@ Transporte: Streamable HTTP, só POST e resposta `application/json`, sem sessão | `send_message` | `to` (handle, com ou sem `@`), `body`, `kind?` (`note` padrão, `task`), `deadline_minutes?` (só task) | `message_id`, `task_id` e `due` (task), e um lembrete de que a resposta chega depois | | `complete_task` | `task_id`, `result`, `status?` (`done` padrão, `failed`) | `task_id`, `status` e quem recebe o resultado | | `my_tasks` | `role?` (`assigned`, `requested`) | tasks `open` e `expired`: id, de, para, status, prazo relativo, hops e o pedido original | +| `permission_prompt` | `tool_name`, `input`, `tool_use_id` | decisão do dono (10.1). Chamada pelo Claude Code, não pelo modelo | - Erro que o modelo pode corrigir (handle desconhecido, argumento inválido, limite de hops, task de outro bot) volta como resultado com `isError: true` e uma frase explicando. Só tool desconhecida ou chamada malformada vira erro JSON-RPC (`-32602`). - Erro interno não expõe detalhes ao bot; vai para o log. Endereçamento só dentro da crew. Bot não enxerga bots nem tasks de outras crews. +### 10.1 Aprovações + +Com `--permission-prompt-tool mcp__botloft__permission_prompt`, toda ferramenta que precisaria de permissão chama a tool `permission_prompt`. A entrada é `{tool_name, input, tool_use_id}` (documentado; visto com 2.1.284). + +1. O daemon grava uma `approval` (`pending`) e o item `approval` no chat, e passa o bot para `needs_approval`. +2. A chamada HTTP fica aberta até o dono responder (`approvals.answer {approvalId, allow, note?}`) ou até `approval_timeout_minutes`. +3. Resposta ao Claude Code, em texto JSON: + - Permitir: `{"behavior":"allow","updatedInput":}`. + - Negar: `{"behavior":"deny","message":"The owner denied this."}`, com a nota do dono se houver. + - Sem resposta no prazo: nega com `"The owner did not answer in time."` e a aprovação vira `expired`. +4. Se o processo do bot morrer ou o daemon reiniciar com a aprovação aberta, ela vira `expired`. +5. O bot volta a `busy` e o item do chat é atualizado. + +"Permitir sempre" (gravar uma regra para o bot) fica para depois do MVP. + +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. + ## 11. Protocolo do app (JSON-RPC 2.0 sobre WebSocket) -Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests com `id`, respostas com `result` ou `error`, notificações do servidor sem `id`. +Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests com `id`, respostas com `result` ou `error`, notificações do servidor sem `id`. O maior frame aceito do app é de 32 MiB, por causa dos anexos. ### 11.1 Sessão -- Primeiro request obrigatório: `session.hello {token, client: {name, version}, protocol: 1}` -> `{daemonVersion, protocol}`. +- Primeiro request obrigatório: `session.hello {token, client: {name, version}, protocol: 2}` -> `{daemonVersion, protocol}`. - Qualquer outro método antes do hello: erro `-32001` e a conexão fecha. Token errado também dá `-32001`; `protocol` diferente dá `-32004`; nos dois casos a conexão fecha. O hello tem que chegar em até 10 s. - `Origin` aceito: `http://tauri.localhost`, `tauri://localhost` e `http://localhost:1420` (dev). Qualquer outro `Origin` recebe HTTP 403 antes do upgrade. Sem `Origin` (cliente nativo, testes) é aceito: navegadores sempre mandam o header, e o token continua obrigatório. - Um cliente lento que deixa acumular mais de 1024 notificações é desconectado e recarrega o estado ao reconectar. +- A versão 2 do protocolo troca `terminal.*` pelo chat (ADR 0001). ### 11.2 Métodos (MVP) | Método | Params | Result | |---|---|---| -| `system.status` | | versão, uptime, versão do claude, `runtimeError` (por que os bots não sobem), backlog de entrega (M3) | +| `system.status` | | versão, uptime, versão do claude, `runtimeError` (por que os bots não sobem), backlog de entrega, `usage` (uso da conta, 8.1) | | `crews.list` | | `Crew[]` | | `crews.create` | `name` | `Crew` | | `crews.rename` | `crewId, name` | `Crew` | @@ -377,19 +431,19 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `bots.setPaused` | `botId, paused` | `Bot` | | `bots.restart` | `botId, fresh?` | `Bot` | | `bots.archive` | `botId` | `Bot` | -| `terminal.attach` | `botId, generation?, offset?` | `{generation, offset, reset, liveOffset}` | -| `terminal.detach` | `botId` | | -| `terminal.write` | `botId, data` (base64) | | -| `terminal.resize` | `botId, cols, rows` | | -| `messages.send` | `botId, body` | `Message` | +| `chat.history` | `botId, before?, limit?` | `ChatItem[]`, mais novo primeiro; `limit` de 1 a 200, 50 se ausente | +| `approvals.answer` | `approvalId, allow, note?` | `Approval` | +| `messages.send` | `botId, body, attachments?` (`[{name, mediaType, data}]`, data em base64) | `Message` | | `messages.list` | `crewId?, botId?, before?, limit?` | `Message[]` | | `deliveries.list` | `state?, botId?` | `Delivery[]` | | `deliveries.retry` | `deliveryId` | `Delivery` | | `tasks.list` | `crewId?, status?` | `Task[]` | +`Bot` traz também `lastActivity`: o último item do chat resumido em uma linha (`text`, `at`), para a lista de conversas. + ### 11.3 Notificações do servidor -`bot.state`, `bot.changed`, `crew.changed`, `terminal.data`, `message.created`, `delivery.changed`, `task.changed`. +`bot.state`, `bot.changed`, `crew.changed`, `chat.item`, `chat.delta`, `message.created`, `delivery.changed`, `task.changed`. ### 11.4 Erros @@ -397,7 +451,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co |---|---| | `-32001` | não autenticado | | `-32002` | não encontrado | -| `-32003` | conflito de estado (ex.: bot arquivado) | +| `-32003` | conflito de estado (ex.: bot arquivado, aprovação já respondida) | | `-32004` | validação | | `-32005` | runtime indisponível (claude ausente ou versão antiga) | @@ -410,21 +464,25 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations | Tabela | Colunas principais | |---|---| | `crews` | `id, name, slug, paused, created_at, archived_at` | -| `bots` | `id, crew_id, name, handle, slug, role, instructions, color, paused, token_hash, created_at, archived_at` | +| `bots` | `id, crew_id, name, handle, slug, role, instructions, color, paused, token_hash, session_id, created_at, archived_at` | | `messages` | `id, crew_id, from_kind (owner/bot/system), from_bot_id, to_bot_id, kind (note/task/result/system), body, task_id, created_at` | -| `deliveries` | `id, message_id, bot_id, state, attempts, next_attempt_at, lease_until, last_error, updated_at` | +| `attachments` | `id, message_id, name, media_type, size, path, created_at` | +| `deliveries` | `id, message_id, bot_id, state, attempts, next_attempt_at, lease_until, last_error, sent_generation, read_at, updated_at` | | `tasks` | `id, crew_id, requester_bot_id, assignee_bot_id, status, deadline_at, hops, origin_task_id, result, created_at, updated_at` | +| `chat_items` | `id, bot_id, kind, data (JSON), created_at, updated_at` | +| `approvals` | `id, bot_id, tool_use_id, tool_name, input, status, note, created_at, answered_at` | | `settings` | `key, value` | -Índices mínimos: `deliveries(state, next_attempt_at)`, `messages(crew_id, created_at)`, `tasks(assignee_bot_id, status)`, `bots(crew_id)`. +Índices mínimos: `deliveries(state, next_attempt_at)`, `messages(crew_id, created_at)`, `tasks(assignee_bot_id, status)`, `bots(crew_id)`, `chat_items(bot_id, id)`, `attachments(message_id)`. ## 13. Segurança e privacidade - Daemon escuta **só em 127.0.0.1** no MVP. - Token do owner: 32 bytes aleatórios em `secrets\owner.token`, ACL com acesso só para o SID do usuário atual (DACL protegida, sem herança). - Token de bot: 32 bytes aleatórios, novo a cada generation. O valor cru existe apenas no ambiente do processo do bot; o daemon guarda só o **SHA-256**, e só em memória: todo processo de bot morre junto com o daemon (Job Object), então nenhum token sobrevive a um reinício e não há motivo para gravá-lo. A coluna `bots.token_hash` fica sem uso. -- Logs nunca registram tokens, conteúdo de mensagens nem saída de terminal em nível `info`. Bots podem manipular dados sensíveis (inclusive de saúde); o daemon trata corpo de mensagem como dado pessoal. +- Logs nunca registram tokens, conteúdo de mensagens, anexos nem itens do chat em nível `info`. Bots podem manipular dados sensíveis (inclusive de saúde); o daemon trata corpo de mensagem, anexo e saída de ferramenta como dado pessoal. - `log_level` vale só para os crates do Botloft; dependências ficam em `warn`, porque em `debug`/`trace` a pilha de WebSocket registra frames, que podem conter mensagens. `RUST_LOG` sobrepõe tudo e é só para depuração local. +- Nome de anexo vira só o nome do arquivo (sem `..`, sem pasta, sem caracteres proibidos no Windows) antes de ir para o disco. - Isolamento entre bots é cooperativo (mesmo usuário do Windows). Documentar isso no README sem prometer sandbox. ## 14. Integração com o Windows @@ -451,10 +509,10 @@ app/src/ shell/ janela, barra de título, layout, atalhos features/ onboarding/ checagens (claude instalado e versão, daemon rodando) e instalação do serviço - crews/ lista, criar, renomear, pausar - bots/ lista, criar, editar, estado - terminal/ view xterm.js, attach/replay, input, resize - messages/ timeline da crew e do bot, enviar mensagem, estado de entrega, deliveries com falha (botão na barra de título, retry) + crews/ lista, criar, renomear, pausar; página da crew com timeline e tasks + bots/ conversas na barra lateral, criar, editar, estado, detalhes + chat/ conversa com o bot: itens, texto ao vivo, aprovações, compositor com anexos + messages/ timeline da crew, estado de entrega, deliveries com falha (botão na barra de título, retry) tasks/ tarefas da crew (abertas por padrão, todas sob demanda) settings/ lib/ @@ -470,7 +528,20 @@ app/src/ dev/ prévia: `pnpm dev` num navegador comum usa FakeBotloft, ou um daemon de dev real com `?live=` (só em dev) ``` -Componentes dependem só de `BotloftApi` e `Host`, nunca do cliente concreto nem do Tauri. O store recarrega crews, bots e `system.status` a cada (re)conexão e depois segue as notificações; `system.status` não tem notificação e é relido a cada 15 s. Deliveries entram no store pela message (cada message tem uma): as 500 atualizadas mais recentemente e todas as mortas, depois cada `delivery.changed`; tasks, todas, depois cada `task.changed`. Mensagens não ficam no store: cada timeline carrega uma página (50) do que mostra, pede as anteriores sob demanda com `before` e acrescenta as novas por `message.created`. +Componentes dependem só de `BotloftApi` e `Host`, nunca do cliente concreto nem do Tauri. + +O store recarrega crews, bots e `system.status` a cada (re)conexão e depois segue as notificações. `system.status` não tem notificação e é relido a cada 15 s. + +- **Deliveries:** entram no store pela message (cada message tem uma). Vêm as 500 atualizadas mais recentemente e todas as mortas, depois cada `delivery.changed`. +- **Tasks:** todas, depois cada `task.changed`. +- **Chat e timeline** não ficam no store. Cada um carrega uma página (50) do que mostra, pede as anteriores sob demanda com `before` e acrescenta o que chega por notificação (`chat.item`, `chat.delta`, `message.created`). + +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. +- **Á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. +- **Área principal com uma crew:** a timeline (messages entre os bots e do dono) e as tasks. +- **Detalhes do bot** (pasta, instruções, sessão) ficam num painel, fora do caminho da conversa. ### 15.2 Comandos Tauri @@ -482,13 +553,21 @@ Componentes dependem só de `BotloftApi` e `Host`, nunca do cliente concreto nem | `daemon_restart` | `botloftd service restart` | | `read_owner_token` | lê `secrets\owner.token` (só no app local) | | `open_path` | abre uma pasta no Explorer; recusa arquivos, que o Explorer executaria | -| overlay na taskbar | não é comando próprio: o app usa `setOverlayIcon` da janela (permissão `core:window:allow-set-overlay-icon`) e marca o ícone com um ponto enquanto algo espera o dono: bot em `needs_approval` ou `auth_error`, ou mensagem não entregue a um bot ativo. Entregas mortas para bot arquivado não contam: foram abandonadas de propósito | +| overlay na taskbar | não é comando próprio: o app usa `setOverlayIcon` da janela (permissão `core:window:allow-set-overlay-icon`) e marca o ícone com um ponto enquanto algo espera o dono: aprovação pendente, bot em `auth_error`, ou mensagem não entregue a um bot ativo. Entregas mortas para bot arquivado não contam: foram abandonadas de propósito | O app acha o daemon como o daemon acha a si mesmo (seção 5): `BOTLOFT_HOME` ou `%LOCALAPPDATA%\Botloft`, com a porta lida do `config.toml` dessa pasta (45710 se ausente). Um daemon de dev com seu próprio `BOTLOFT_HOME` é encontrado sem configuração extra. A CSP libera `ws://127.0.0.1:*` pelo mesmo motivo. ### 15.3 Direção visual -Ferramenta de trabalho densa, estilo painel de operação: tipografia forte, grid firme, estados dos bots legíveis de longe (cor + ícone + texto, nunca só cor). Sem gradiente, sem sombra pesada, sem visual de template. Tema escuro e claro. Barra de título própria (`decorations: false`) com controles de janela do Windows. +Ferramenta de trabalho densa e calma: tipografia forte, grid firme, estados dos bots legíveis de longe (cor + ícone + texto, nunca só cor). Sem gradiente, sem sombra pesada, sem visual de template. Tema escuro e claro. Barra de título própria (`decorations: false`) com controles de janela do Windows. + +No chat: + +- O dono fala em balões à direita; o bot, à esquerda, com markdown. +- Mensagens de outros bots aparecem à esquerda, com o avatar e o nome de quem mandou. +- O que o bot faz com as ferramentas aparece em linhas compactas (ícone, ferramenta, resumo e estado), agrupadas por turno, que abrem para mostrar entrada e saída. +- Pedido de aprovação é um cartão com o que o bot quer fazer e os botões Permitir e Negar. +- Anexos aparecem como miniatura (imagem) ou cartão com nome, tipo e tamanho. Identidade: o mascote do Botloft é uma chama com olhos, desenhada em vetor em `app/app-icon.svg`. O ícone do app é o mascote branco sobre fundo preto. Cada bot usa o mesmo personagem como avatar, com uma cor própria escolhida na criação. A cor do avatar identifica o bot e não comunica estado: estado continua sendo cor + ícone + texto, como descrito acima. @@ -497,7 +576,7 @@ Identidade: o mascote do Botloft é uma chama com olhos, desenhada em vetor em ` - Rust: `cargo fmt --check`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`. - TS: `tsc --noEmit`, `biome check`, `vitest run`. - Limite **flexível de 300 linhas** por arquivo; passou disso, dividir por responsabilidade. -- Toda lógica de supervisor, courier e terminal testada com `FakeRuntime` (sem Claude real). +- Toda lógica de supervisor, courier e chat testada com `FakeRuntime` (sem Claude real). O `FakeRuntime` fala `stream-json` de verdade: recebe as linhas do stdin e o teste escreve os eventos do stdout. - CI: GitHub Actions em `windows-latest` (principal) e `ubuntu-latest` para `botloft-core` e `botloft-store`. ## 17. Marcos do MVP @@ -506,20 +585,24 @@ Identidade: o mascote do Botloft é uma chama com olhos, desenhada em vetor em ` |---|---|---| | **M0** Fundação | Workspace Cargo, app Tauri vazio, CI, LICENSE, README | CI verde nos dois runners | | **M1** Daemon base | config, store + migrations, `/health`, `/rpc` com hello, CRUD de crews e bots, geração de workspace | teste de integração cria crew e bot via RPC | -| **M2** Runtime | ConPTY, supervisor com estados e backoff, Job Objects, hooks, terminal com replay, `FakeRuntime` | bot real abre no Windows, estados mudam pelos hooks, reattach sem perder tela | -| **M3** Mensagens | courier, inbox via pipe, tools MCP, tasks com hops e prazo | dois bots trocam task e resultado sem intervenção | -| **M4** App | onboarding, crews, bots, terminal, timeline, deliveries com falha | fluxo completo pelo app sem abrir terminal | +| **M2** Runtime | supervisor com estados e backoff, Job Objects, `FakeRuntime` | bot real sobe no Windows e os estados mudam | +| **M3** Mensagens | courier, tools MCP, tasks com hops e prazo | dois bots trocam task e resultado sem intervenção | +| **M4** App | onboarding, crews, bots, timeline, deliveries com falha | fluxo completo pelo app sem abrir terminal | +| **M4.1** Chat | runtime headless (ADR 0001), chat com texto ao vivo, aprovações pelo chat, anexos, lista de conversas | conversar com um bot pelo app, mandar uma imagem, aprovar uma ferramenta e ver o resultado; dois bots trocam task e resultado pelo stdin | | **M5** Distribuição | tarefa agendada, keep-awake, instalador NSIS com sidecar, updater | instalar em máquina limpa e bots voltarem sozinhos após reboot | +M2 a M4 foram entregues com ConPTY, terminal com replay, inbox por named pipe e hooks. A ADR 0001 troca tudo isso pelo runtime headless no M4.1, antes do M5. + ## 18. Fora do MVP (ordem sugerida) 1. Rotinas (cron com timezone, intervalo) com política de sobreposição. 2. Caixa de perguntas ao owner (bot pergunta, owner responde, resposta volta como mensagem). -3. Busca FTS5 em mensagens. -4. Histórico de versões das instruções do bot. -5. Acesso remoto com token por dispositivo (Tailscale). -6. Sinais entre bots disparando rotinas. -7. Suporte Linux/macOS. +3. "Permitir sempre" nas aprovações, gravado como regra do bot. +4. Busca FTS5 em mensagens e no chat. +5. Histórico de versões das instruções do bot. +6. Acesso remoto com token por dispositivo (Tailscale). +7. Sinais entre bots disparando rotinas. +8. Suporte Linux/macOS. ## 19. Pontos a verificar na versão alvo do Claude Code @@ -527,17 +610,22 @@ Conferência na documentação oficial (code.claude.com/docs) em 2026-09-28. "Co | Item | Seção | Resultado | Teste real | |---|---|---|---| -| Exec form de hooks (`args`) no Windows | 7.5 | Confirmado (`hooks`) e **testado com 2.1.283**: `botloftd.exe hook session-start` rodou direto, sem shell | feito (M2) | -| Linha de auth, 30 s e variáveis do inbox | 9.2 | Confirmado (`cross-session-messaging`): `CLAUDE_CODE_MESSAGING_SOCKET`, `CLAUDE_CODE_MESSAGING_TOKEN`, auth obrigatória no Windows, >= 2.1.234. **Testado com 2.1.283**: as duas variáveis chegam ao hook `SessionStart`; sem auth ou com token errado a conexão é fechada | feito (M3) | -| Formato da linha de mensagem no inbox | 9.2 | **Não documentado**; **testado com 2.1.283**: `{"type":"user","message":{"role":"user","content":...}}` é entregue e abre um turno (detalhes em 9.2) | feito (M3) | -| `crossSessionInbound` | 7.5 | Confirmado: valores `accept`, `hold`, `refuse`; `refuse` em settings de projeto vence tudo. **Testado com 2.1.283**: com `accept` a mensagem entrou direto, sem diálogo | feito (M3) | -| Carregamento de `.claude/rules/*.md` sem frontmatter | 5.1 | Confirmado (`memory`) e **testado com 2.1.283**: o bot respondeu nome, handle e crew tirados das regras | feito (M2) | +| Formato de entrada `--input-format stream-json` | 9.2 | **Não documentado**. **Testado com 2.1.284**: `{"type":"user","uuid":...,"message":{"role":"user","content":[blocos]}}` abre um turno; bloco `image` em base64 é aceito | feito (M4.1) | +| Vários turnos num processo; mensagem durante um turno | 9.2 | **Testado com 2.1.284**: dois turnos seguidos no mesmo processo; a mensagem escrita durante um turno virou o turno seguinte. Cada turno começa com `system/init` e termina com `result` | feito (M4.1) | +| Processo sem entrada | 7.2 | **Testado com 2.1.284**: fica calado e vivo até a primeira mensagem; sai com 0 quando o stdin fecha | feito (M4.1) | +| `--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}`, esperou 20 s pela resposta e seguiu `{"behavior":"allow","updatedInput":...}` | tempo longo (minutos) pelo servidor HTTP do daemon: M4.1 | +| `--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 | 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) | +| Regras `allow` do projeto em `-p` sem confiança | 7.4 | Confirmado (`permissions`): não são aplicadas numa pasta nunca confiada; `deny` vale sempre. Por isso `--allowedTools mcp__botloft` | M4.1 | +| `--session-id`, `--resume` em `-p` | 7.3 | Confirmado (`cli-reference`, `sessions`): a sessão retoma histórico e modelo; flags como `--mcp-config` têm de ser passadas de novo | retomar depois de reiniciar o daemon: M4.1 | +| Transcript `.jsonl` | 7.3 | Confirmado (`sessions`): formato interno, muda entre versões. O chat do Botloft não depende dele | não se aplica | +| Inbox entre sessões em `-p` | 9 | Documentado como indisponível em `-p`; o courier escreve no stdin | não se aplica | +| Carregamento de `.claude/rules/*.md` sem frontmatter | 5.1 | Confirmado (`memory`) e **testado com 2.1.283** (modo interativo): o bot respondeu nome, handle e crew tirados das regras | repetir em `-p`: M4.1 | | Expansão `${VAR}` em headers do `mcp.json` | 10 | Confirmado (`mcp`); alguns nomes de credencial conhecidos são lidos vazios, `BOTLOFT_BOT_TOKEN` não é um deles. **Testado com 2.1.284**: o header chegou com o valor da variável de ambiente | feito (M3) | -| Revisão do MCP que o Claude Code usa em servidor HTTP | 10 | Especificação MCP 2026-07-28 (sem `initialize`) e versões antigas. **Visto com 2.1.284**: manda `server/discover` com os headers de 2026-07-28 e, se falhar, `initialize` com 2025-11-25 (e depois tenta o transporte SSE antigo). Teste feito sem gastar tokens: `claude -p` com modelo inexistente conecta os servidores MCP antes de falhar | feito (M3) | -| Sintaxe de caminho Windows em permission rules | 7.5 | Confirmado (`permissions`) e **testado com 2.1.283**: ler `secrets\owner.token` deu "File is in a directory that is denied by your permission settings" | feito (M2) | -| Flags `--continue`, `--mcp-config` | 7.4 | Confirmado (`cli-reference`); aceitas pelo 2.1.283. **Testado**: depois de reiniciar o daemon, o bot voltou com `--continue` e a conversa anterior na tela | feito (M3) | -| Flag `--settings ` | 7.4 | Ausente da tabela de CLI, mas aceita pelo 2.1.283 e os hooks do arquivo rodaram | feito (M2) | -| stdout do `SessionStart` vira contexto | 7.6 | Confirmado (`hooks`); o subcomando nunca escreve no stdout | feito (M2) | -| `StopFailure` (`rate_limit`, `authentication_failed`) e `Notification` (`permission_prompt`) | 7.2 | Campos documentados: `error` e `notification_type`. `SessionStart`, `UserPromptSubmit` e `Stop` **testados com 2.1.283** (`launching` -> `idle` -> `busy` -> `idle`) | disparar `StopFailure` e `permission_prompt` reais: pendente | -| Confiança da pasta segura os hooks | 7.4.1 | Confirmado (`hooks`, `permissions`) e **visto com 2.1.283**: diálogo na primeira execução, bot em `launching` até aceitar | feito (M2) | -| Confiança da pasta pai e regras `allow` do projeto | 7.4.1 | Confirmado (`permissions`): fora de git a confiança vale para as subpastas, mas `permissions.allow` do projeto só vale depois de aceitar o diálogo da própria pasta; `-p` nunca mostra o diálogo. Não há flag nem setting para pré-aceitar; o manual é `hasTrustDialogAccepted` no `~/.claude.json`. **Visto com 2.1.284**: o diálogo de um bot novo lista "This folder pre-approves 1 tool permission: mcp__botloft" e aparece no terminal do app; Down e Enter no xterm aceitaram e o bot foi a `idle` | feito (M4) | +| Revisão do MCP que o Claude Code usa | 10 | Especificação MCP 2026-07-28 (sem `initialize`) e versões antigas. **Visto com 2.1.284**: manda `server/discover` com os headers de 2026-07-28 e, se falhar, `initialize` com 2025-11-25; o mesmo num servidor stdio | feito (M3) | +| Sintaxe de caminho Windows em permission rules | 7.5 | Confirmado (`permissions`) e **testado com 2.1.283**: ler `secrets\owner.token` deu "File is in a directory that is denied by your permission settings" | repetir em `-p`: M4.1 | + +Itens do runtime anterior (ConPTY, hooks em exec form, `crossSessionInbound`, linha de auth do inbox, diálogo de confiança, `ESC[6n` do ConPTY, consultas do terminal no replay) foram verificados no M2–M4 e deixaram de se aplicar com a ADR 0001; o histórico está no git e na ADR. From 7f0574542d5cac6f1e34481216a3fd6feb5792b9 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:31:56 -0300 Subject: [PATCH 2/8] feat(daemon): run bots as headless Claude Code and keep their chat Bots now run `claude -p` with stream-json on stdin and stdout instead of the interactive TUI in a PTY (ADR 0001). The daemon turns what each bot prints into chat items (replies, tool calls, turns, notices) and live text deltas, and the courier writes messages into stdin, with read receipts from the replayed user message. - protocol 2: chat.history, approvals.answer, chat.item and chat.delta; terminal.* is gone - messages can carry attachments, saved in the bot's folder; images also go inline - tool permissions go through the permission_prompt MCP tool and wait for the owner in the chat - bot state comes from the stream: busy, needs_approval, rate_limited and auth_error - the PTY runtime, hooks and the named-pipe inbox are removed --- Cargo.lock | 96 +----- app/src/lib/protocol.gen.ts | 262 +++++++++++++--- crates/botloft-core/Cargo.toml | 2 +- crates/botloft-core/src/chat.rs | 161 ++++++++++ crates/botloft-core/src/ids.rs | 39 +++ crates/botloft-core/src/lib.rs | 1 + crates/botloft-core/src/protocol/chat.rs | 227 ++++++++++++++ crates/botloft-core/src/protocol/export.rs | 64 ++-- crates/botloft-core/src/protocol/messaging.rs | 102 +++--- crates/botloft-core/src/protocol/methods.rs | 30 +- crates/botloft-core/src/protocol/mod.rs | 65 +++- crates/botloft-core/src/protocol/model.rs | 5 +- crates/botloft-core/src/protocol/terminal.rs | 65 ---- crates/botloft-core/src/slug.rs | 61 ++++ crates/botloft-core/src/validate.rs | 2 + crates/botloft-store/Cargo.toml | 1 + crates/botloft-store/migrations/0003_chat.sql | 56 ++++ crates/botloft-store/src/approvals.rs | 196 ++++++++++++ crates/botloft-store/src/bots.rs | 22 ++ crates/botloft-store/src/chat.rs | 261 +++++++++++++++ crates/botloft-store/src/deliveries.rs | 90 +++++- crates/botloft-store/src/deliveries/tests.rs | 90 +++++- crates/botloft-store/src/lib.rs | 11 + crates/botloft-store/src/messages.rs | 167 +++++----- crates/botloft-store/src/messages/tests.rs | 117 +++++++ crates/botloft-store/src/migrate.rs | 1 + crates/botloft-store/src/tasks.rs | 20 +- crates/botloft-store/src/tasks/tests.rs | 3 +- crates/botloftd/Cargo.toml | 1 - crates/botloftd/src/approvals.rs | 284 +++++++++++++++++ crates/botloftd/src/chat/events.rs | 270 ++++++++++++++++ crates/botloftd/src/chat/items.rs | 55 ++++ crates/botloftd/src/chat/mod.rs | 77 +++++ crates/botloftd/src/config.rs | 16 +- crates/botloftd/src/courier/fake.rs | 145 --------- crates/botloftd/src/courier/inbox.rs | 66 ---- crates/botloftd/src/courier/mod.rs | 218 +++++++------ crates/botloftd/src/courier/render.rs | 207 ++++++++++++ crates/botloftd/src/hooks/client.rs | 164 ---------- crates/botloftd/src/hooks/mod.rs | 169 ---------- crates/botloftd/src/lib.rs | 4 +- crates/botloftd/src/main.rs | 28 +- crates/botloftd/src/platform/mod.rs | 4 +- crates/botloftd/src/platform/unix.rs | 9 - crates/botloftd/src/platform/windows.rs | 5 +- crates/botloftd/src/platform/windows/pipe.rs | 148 --------- crates/botloftd/src/rpc/dispatch.rs | 7 +- crates/botloftd/src/rpc/mod.rs | 38 +-- crates/botloftd/src/rpc/terminal.rs | 122 -------- crates/botloftd/src/runtime/fake.rs | 60 +++- crates/botloftd/src/runtime/mod.rs | 32 +- crates/botloftd/src/runtime/pipe.rs | 190 +++++++++++ crates/botloftd/src/runtime/pty.rs | 161 ---------- crates/botloftd/src/server.rs | 15 +- crates/botloftd/src/service/attachments.rs | 182 +++++++++++ crates/botloftd/src/service/bots.rs | 27 +- crates/botloftd/src/service/chat.rs | 22 ++ crates/botloftd/src/service/crews.rs | 4 +- crates/botloftd/src/service/messages.rs | 39 ++- crates/botloftd/src/service/mod.rs | 4 +- crates/botloftd/src/service/tasks.rs | 6 +- crates/botloftd/src/service/tasks/expire.rs | 5 +- crates/botloftd/src/service/terminal.rs | 58 ---- crates/botloftd/src/state.rs | 64 +++- crates/botloftd/src/supervisor/mod.rs | 98 ++---- crates/botloftd/src/supervisor/reconcile.rs | 22 +- crates/botloftd/src/supervisor/settings.rs | 6 +- crates/botloftd/src/supervisor/slot.rs | 157 +++------- crates/botloftd/src/supervisor/spawn.rs | 222 ++++++------- crates/botloftd/src/supervisor/turns.rs | 110 +++++++ crates/botloftd/src/terminal.rs | 296 ------------------ crates/botloftd/src/tools/catalog.rs | 17 + crates/botloftd/src/tools/era.rs | 110 +++++++ crates/botloftd/src/tools/mod.rs | 157 ++++------ crates/botloftd/src/workspace/files.rs | 80 +++-- crates/botloftd/src/workspace/mod.rs | 15 +- crates/botloftd/tests/chat.rs | 280 +++++++++++++++++ crates/botloftd/tests/common/bots.rs | 77 +++-- crates/botloftd/tests/common/mod.rs | 29 +- crates/botloftd/tests/common/stream.rs | 84 +++++ crates/botloftd/tests/common/supervised.rs | 87 +++++ crates/botloftd/tests/courier.rs | 176 +++++++---- crates/botloftd/tests/mcp.rs | 10 +- crates/botloftd/tests/rpc_crews_bots.rs | 7 +- crates/botloftd/tests/rpc_session.rs | 8 +- crates/botloftd/tests/rpc_terminal.rs | 193 ------------ crates/botloftd/tests/supervisor.rs | 242 +++++--------- crates/botloftd/tests/supervisor_turns.rs | 99 ++++++ crates/botloftd/tests/tasks.rs | 37 +-- docs/spec.md | 6 +- 90 files changed, 4764 insertions(+), 2984 deletions(-) create mode 100644 crates/botloft-core/src/chat.rs create mode 100644 crates/botloft-core/src/protocol/chat.rs delete mode 100644 crates/botloft-core/src/protocol/terminal.rs create mode 100644 crates/botloft-store/migrations/0003_chat.sql create mode 100644 crates/botloft-store/src/approvals.rs create mode 100644 crates/botloft-store/src/chat.rs create mode 100644 crates/botloft-store/src/messages/tests.rs create mode 100644 crates/botloftd/src/approvals.rs create mode 100644 crates/botloftd/src/chat/events.rs create mode 100644 crates/botloftd/src/chat/items.rs create mode 100644 crates/botloftd/src/chat/mod.rs delete mode 100644 crates/botloftd/src/courier/fake.rs delete mode 100644 crates/botloftd/src/courier/inbox.rs create mode 100644 crates/botloftd/src/courier/render.rs delete mode 100644 crates/botloftd/src/hooks/client.rs delete mode 100644 crates/botloftd/src/hooks/mod.rs delete mode 100644 crates/botloftd/src/platform/windows/pipe.rs delete mode 100644 crates/botloftd/src/rpc/terminal.rs create mode 100644 crates/botloftd/src/runtime/pipe.rs delete mode 100644 crates/botloftd/src/runtime/pty.rs create mode 100644 crates/botloftd/src/service/attachments.rs create mode 100644 crates/botloftd/src/service/chat.rs delete mode 100644 crates/botloftd/src/service/terminal.rs create mode 100644 crates/botloftd/src/supervisor/turns.rs delete mode 100644 crates/botloftd/src/terminal.rs create mode 100644 crates/botloftd/src/tools/era.rs create mode 100644 crates/botloftd/tests/chat.rs create mode 100644 crates/botloftd/tests/common/stream.rs create mode 100644 crates/botloftd/tests/common/supervised.rs delete mode 100644 crates/botloftd/tests/rpc_terminal.rs create mode 100644 crates/botloftd/tests/supervisor_turns.rs diff --git a/Cargo.lock b/Cargo.lock index 98ed8371..411b6a79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -292,6 +292,7 @@ version = "0.1.0" dependencies = [ "botloft-core", "rusqlite", + "serde_json", "tempfile", "thiserror 2.0.21", ] @@ -311,7 +312,6 @@ dependencies = [ "futures-util", "getrandom 0.4.3", "hex", - "portable-pty", "serde", "serde_json", "sha2 0.11.0", @@ -494,12 +494,6 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" -[[package]] -name = "cfg_aliases" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" - [[package]] name = "chacha20" version = "0.10.2" @@ -946,12 +940,6 @@ dependencies = [ "tendril", ] -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - [[package]] name = "dpi" version = "0.1.2" @@ -999,7 +987,7 @@ dependencies = [ "rustc_version", "toml 1.1.6+spec-1.1.0", "vswhom", - "winreg 0.55.0", + "winreg", ] [[package]] @@ -1072,17 +1060,6 @@ dependencies = [ "rustc_version", ] -[[package]] -name = "filedescriptor" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" -dependencies = [ - "libc", - "thiserror 1.0.69", - "winapi", -] - [[package]] name = "find-msvc-tools" version = "0.1.14" @@ -2255,18 +2232,6 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" -[[package]] -name = "nix" -version = "0.28.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" -dependencies = [ - "bitflags 2.13.2", - "cfg-if", - "cfg_aliases", - "libc", -] - [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -2700,27 +2665,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "portable-pty" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" -dependencies = [ - "anyhow", - "bitflags 1.3.2", - "downcast-rs", - "filedescriptor", - "lazy_static", - "libc", - "log", - "nix", - "serial2", - "shared_library", - "shell-words", - "winapi", - "winreg 0.10.1", -] - [[package]] name = "potential_utf" version = "0.1.6" @@ -3308,17 +3252,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "serial2" -version = "0.2.38" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730" -dependencies = [ - "cfg-if", - "libc", - "windows-sys 0.61.2", -] - [[package]] name = "serialize-to-javascript" version = "0.1.2" @@ -3403,22 +3336,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shared_library" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" -dependencies = [ - "lazy_static", - "libc", -] - -[[package]] -name = "shell-words" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" - [[package]] name = "shlex" version = "2.0.1" @@ -5078,15 +4995,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" -dependencies = [ - "winapi", -] - [[package]] name = "winreg" version = "0.55.0" diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index bb54ce76..942ea8f5 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1,17 +1,18 @@ // Generated by `cargo test -p botloft-core export_bindings`. Do not edit by hand. // Source: crates/botloft-core/src/protocol. -export const PROTOCOL_VERSION = 1; +export const PROTOCOL_VERSION = 2; /** Avatar colors the daemon assigns in order (spec 15.3). */ export const AVATAR_PALETTE = ["#FF7A59", "#5EC8FF", "#A48BFF", "#9BE564", "#FFC857", "#FF7EB6", "#3DD9C1", "#E8D5B0"] as const; -/** Longest values the daemon accepts, in characters. */ +/** Longest values the daemon accepts, in characters (files, for attachments). */ export const FIELD_LIMITS = { name: 64, role: 200, instructions: 32000, message: 100000, + attachments: 10, } as const; /** @@ -39,6 +40,21 @@ export type DeliveryId = string; */ export type TaskId = string; +/** + * Identifies an item of a bot's chat. + */ +export type ChatItemId = string; + +/** + * Identifies a permission request waiting for the owner. + */ +export type ApprovalId = string; + +/** + * Identifies a file the owner attached to a message. + */ +export type AttachmentId = string; + /** * A group of bots that can message each other and share a folder. */ @@ -61,6 +77,19 @@ archivedAt: number | null, }; */ export type BotState = "offline" | "launching" | "idle" | "busy" | "needs_approval" | "rate_limited" | "auth_error" | "backoff" | "archived"; +/** + * The last thing in a bot's chat, for the conversation list. + */ +export type Activity = { +/** + * One line, at most 120 characters. + */ +text: string, +/** + * Unix time in milliseconds. + */ +at: number, }; + /** * A persistent Claude Code session with a name, a role and instructions. */ @@ -80,13 +109,17 @@ slug: string, role: string, instructions: string, color: string, paused: boolean, state: BotState, /** * Current process generation; `null` if the bot has not started since - * the daemon did. Changes on every (re)start (spec 8). + * the daemon did. Changes on every (re)start. */ generation: number | null, /** * Absolute path of the bot's workspace folder. */ workspace: string, +/** + * The last item of the bot's chat, in one line; `null` for a new bot. + */ +lastActivity: Activity | null, /** * Unix time in milliseconds. */ @@ -121,6 +154,37 @@ export type DeliveryBacklog = { */ pending: number, dead: number, }; +export type UsageWindow = { +/** + * `five_hour`, `seven_day`... + */ +name: string, +/** + * Share used, 0 to 1. + */ +utilization: number, +/** + * Unix time in milliseconds. + */ +resetsAt: number | null, }; + +/** + * The account's usage, from Claude Code's `rate_limit_event` (spec 8.1). + */ +export type AccountUsage = { +/** + * As Claude Code says it: `allowed`, `allowed_warning`, `rejected`... + */ +status: string, +/** + * Unix time in milliseconds when the current limit resets. + */ +resetsAt: number | null, windows: Array, +/** + * Unix time in milliseconds. + */ +observedAt: number, }; + export type SystemStatus = { daemonVersion: string, protocol: number, uptimeMs: number, /** * Version reported by `claude --version`; `null` until the runtime probe runs. @@ -129,7 +193,11 @@ claudeVersion: string | null, /** * Why bots cannot start (Claude Code missing or too old); `null` when fine. */ -runtimeError: string | null, deliveries: DeliveryBacklog, }; +runtimeError: string | null, deliveries: DeliveryBacklog, +/** + * The Claude account's usage as last reported; `null` before any turn. + */ +usage: AccountUsage | null, }; export type CrewsCreateParams = { name: string, }; @@ -172,60 +240,47 @@ fresh?: boolean, }; export type BotStateChanged = { botId: BotId, state: BotState, generation: number | null, }; /** - * Starts streaming a bot's terminal. With the `generation` and `offset` the - * client last saw, only the missing bytes are replayed. + * Who wrote a message. */ -export type TerminalAttachParams = { botId: BotId, generation?: number, offset?: number, }; +export type SenderKind = "owner" | "bot" | "system"; -export type TerminalAttachResult = { generation: number, -/** - * Offset of the first byte the following `terminal.data` carries. - */ -offset: number, /** - * True when the client must clear its screen before writing the replay. - */ -reset: boolean, -/** - * Offset where live output starts. The bytes before it replay past - * output: a terminal must not answer the queries in them (cursor - * position, device attributes...), which were asked long ago. + * What a message is for. */ -liveOffset: number, }; +export type MessageKind = "note" | "task" | "result" | "system"; -export type TerminalWriteParams = { botId: BotId, /** - * Raw bytes, base64. + * Where a delivery is in the courier (spec 9.1). */ -data: string, }; +export type DeliveryState = "pending" | "sending" | "sent" | "dead"; -export type TerminalResizeParams = { botId: BotId, cols: number, rows: number, }; +export type TaskStatus = "open" | "done" | "failed" | "cancelled" | "expired"; /** - * Terminal output. `offset` counts bytes since the start of `generation`. + * A file attached to a message, saved in the bot's folder. */ -export type TerminalData = { botId: BotId, generation: number, offset: number, +export type Attachment = { id: AttachmentId, /** - * Raw bytes, base64. May end in the middle of a UTF-8 sequence. + * File name as saved, without folders. */ -data: string, }; - +name: string, mediaType: string, /** - * Who wrote a message. + * Bytes. */ -export type SenderKind = "owner" | "bot" | "system"; - +size: number, /** - * What a message is for. + * Path relative to the bot's workspace, with forward slashes. */ -export type MessageKind = "note" | "task" | "result" | "system"; +path: string, }; /** - * Where a delivery is in the courier (spec 9.1). + * A file the owner sends with `messages.send`. */ -export type DeliveryState = "pending" | "sending" | "sent" | "dead"; - -export type TaskStatus = "open" | "done" | "failed" | "cancelled" | "expired"; +export type AttachmentUpload = { name: string, mediaType: string, +/** + * The file's bytes, base64. + */ +data: string, }; /** * Text sent to a bot, by the owner, another bot or the daemon. @@ -239,13 +294,17 @@ fromBotId: BotId | null, toBotId: BotId, kind: MessageKind, body: string, * The task this message asks for, answers or reports on. */ taskId: TaskId | null, +/** + * Files the owner attached (spec 9.5); empty for everything else. + */ +attachments: Array, /** * Unix time in milliseconds. */ createdAt: number, }; /** - * Getting one message into one bot's inbox. + * Getting one message into one bot's process. */ export type Delivery = { id: DeliveryId, messageId: MessageId, botId: BotId, state: DeliveryState, /** @@ -260,6 +319,10 @@ nextAttemptAt: number, * Why the last attempt failed; never contains the message body. */ lastError: string | null, +/** + * Unix time in milliseconds the bot began the turn for this message. + */ +readAt: number | null, /** * Unix time in milliseconds. */ @@ -293,7 +356,11 @@ updatedAt: number, }; /** * The owner writes to a bot. */ -export type MessagesSendParams = { botId: BotId, body: string, }; +export type MessagesSendParams = { botId: BotId, body: string, +/** + * Up to 10 files (spec 9.5). + */ +attachments?: Array, }; /** * Newest first. Page back with `before` set to the oldest id received. @@ -324,6 +391,112 @@ export type DeliveryIdParams = { deliveryId: DeliveryId, }; */ export type TasksListParams = { crewId?: CrewId, status?: TaskStatus, }; +/** + * Where a tool call the bot made stands. + */ +export type ToolStatus = "running" | "done" | "failed"; + +export type ApprovalStatus = "pending" | "allowed" | "denied" | "expired"; + +export type NoticeLevel = "info" | "warning" | "error"; + +export type InboundItem = { message: Message, }; + +export type ReplyItem = { text: string, }; + +export type ToolItem = { toolUseId: string, name: string, +/** + * One line about what the call does, e.g. the command or the file. + */ +summary: string, +/** + * The call's input as JSON, cut at 4 KB. + */ +input: string, status: ToolStatus, +/** + * The start of what the tool returned, cut at 8 KB. + */ +output: string | null, }; + +export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, input: string, status: ApprovalStatus, +/** + * What the owner wrote with a denial. + */ +note: string | null, }; + +export type TurnItem = { durationMs: number, +/** + * As Claude Code reports it; `null` when it did not. + */ +costUsd: number | null, +/** + * Why the turn failed, e.g. `rate_limit`; `null` when it worked. + */ +error: string | null, }; + +export type NoticeItem = { level: NoticeLevel, text: string, }; + +/** + * What a chat item holds, by `kind`. + */ +export type ChatBody = { "kind": "inbound" } & InboundItem | { "kind": "reply" } & ReplyItem | { "kind": "tool" } & ToolItem | { "kind": "approval" } & ApprovalItem | { "kind": "turn" } & TurnItem | { "kind": "notice" } & NoticeItem; + +/** + * One entry of a bot's chat. + */ +export type ChatItem = { id: ChatItemId, botId: BotId, body: ChatBody, +/** + * Unix time in milliseconds. + */ +createdAt: number, +/** + * Unix time in milliseconds; changes when a tool ends or an approval is + * answered. + */ +updatedAt: number, }; + +/** + * Params of the `chat.item` notification: an item that is new or changed. + */ +export type ChatItemChanged = { item: ChatItem, +/** + * The bot's new conversation-list line; `null` when it did not change. + */ +activity: Activity | null, }; + +/** + * A permission request (spec 10.1). + */ +export type Approval = { id: ApprovalId, botId: BotId, toolName: string, summary: string, input: string, status: ApprovalStatus, note: string | null, +/** + * Unix time in milliseconds. + */ +createdAt: number, +/** + * Unix time in milliseconds; `null` while pending. + */ +answeredAt: number | null, }; + +/** + * Text the bot is writing right now, in order (spec 8.3). + */ +export type ChatDelta = { botId: BotId, text: string, }; + +/** + * Newest first. Page back with `before` set to the oldest id received. + */ +export type ChatHistoryParams = { botId: BotId, before?: ChatItemId, +/** + * 1 to 200; 50 when absent. + */ +limit?: number, }; + +export type ApprovalsAnswerParams = { approvalId: ApprovalId, allow: boolean, +/** + * Passed to the bot with a denial. + */ +note?: string, }; + /** Params and result of every request method. */ export interface RpcMethods { "session.hello": { params: HelloParams; result: HelloResult }; @@ -339,10 +512,8 @@ export interface RpcMethods { "bots.setPaused": { params: BotsSetPausedParams; result: Bot }; "bots.archive": { params: BotIdParams; result: Bot }; "bots.restart": { params: BotsRestartParams; result: Bot }; - "terminal.attach": { params: TerminalAttachParams; result: TerminalAttachResult }; - "terminal.detach": { params: BotIdParams; result: null }; - "terminal.write": { params: TerminalWriteParams; result: null }; - "terminal.resize": { params: TerminalResizeParams; result: null }; + "chat.history": { params: ChatHistoryParams; result: Array }; + "approvals.answer": { params: ApprovalsAnswerParams; result: Approval }; "messages.send": { params: MessagesSendParams; result: Message }; "messages.list": { params: MessagesListParams; result: Array }; "deliveries.list": { params: DeliveriesListParams; result: Array }; @@ -355,7 +526,8 @@ export interface RpcNotifications { "crew.changed": Crew; "bot.changed": Bot; "bot.state": BotStateChanged; - "terminal.data": TerminalData; + "chat.item": ChatItemChanged; + "chat.delta": ChatDelta; "message.created": Message; "delivery.changed": Delivery; "task.changed": Task; diff --git a/crates/botloft-core/Cargo.toml b/crates/botloft-core/Cargo.toml index 32e29129..6a15e381 100644 --- a/crates/botloft-core/Cargo.toml +++ b/crates/botloft-core/Cargo.toml @@ -10,6 +10,7 @@ authors.workspace = true [dependencies] deunicode = "1.6.2" serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" thiserror = "2.0.21" ulid = "3.0.0" @@ -17,5 +18,4 @@ ulid = "3.0.0" workspace = true [dev-dependencies] -serde_json = "1.0.151" ts-rs = "12.0.1" diff --git a/crates/botloft-core/src/chat.rs b/crates/botloft-core/src/chat.rs new file mode 100644 index 00000000..5e0b2f2d --- /dev/null +++ b/crates/botloft-core/src/chat.rs @@ -0,0 +1,161 @@ +//! How chat items read (spec 8.2): one-line summaries of tool calls, text +//! cut to size, and the line the conversation list shows. + +use serde_json::Value; + +use crate::protocol::{ChatBody, SenderKind}; + +/// Longest tool input kept, in bytes. +pub const TOOL_INPUT_MAX: usize = 4 * 1024; +/// Longest tool output kept, in bytes. +pub const TOOL_OUTPUT_MAX: usize = 8 * 1024; +/// Longest activity line, in characters. +pub const ACTIVITY_MAX_CHARS: usize = 120; +/// Longest summary, in characters. +const SUMMARY_MAX_CHARS: usize = 160; + +/// `text` cut to at most `max` bytes on a character boundary, with an +/// ellipsis when something was cut. +pub fn clip(text: &str, max: usize) -> String { + if text.len() <= max { + return text.to_owned(); + } + let mut end = max.saturating_sub('…'.len_utf8()); + while !text.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &text[..end]) +} + +/// `text` on one line, at most `max` characters. +pub fn one_line(text: &str, max: usize) -> String { + let flat = text.split_whitespace().collect::>().join(" "); + if flat.chars().count() <= max { + return flat; + } + let cut: String = flat.chars().take(max.saturating_sub(1)).collect(); + format!("{}…", cut.trim_end()) +} + +fn field<'a>(input: &'a Value, name: &str) -> Option<&'a str> { + input.get(name).and_then(Value::as_str) +} + +/// A one-line description of a tool call, from its input. +pub fn tool_summary(name: &str, input: &Value) -> String { + let path = || { + field(input, "file_path") + .or_else(|| field(input, "notebook_path")) + .or_else(|| field(input, "path")) + .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)), + "Read" | "Edit" | "MultiEdit" | "Write" | "NotebookEdit" => path(), + "Grep" | "Glob" => field(input, "pattern").map(str::to_owned), + "WebFetch" => field(input, "url").map(str::to_owned), + "WebSearch" => field(input, "query").map(str::to_owned), + "Task" | "Agent" => field(input, "description").map(str::to_owned), + "TodoWrite" => Some("Updated the plan".to_owned()), + "mcp__botloft__send_message" => { + field(input, "to").map(|to| format!("to @{}", to.trim_start_matches('@'))) + } + "mcp__botloft__complete_task" => field(input, "task_id").map(|id| format!("task {id}")), + _ => None, + }; + one_line(&summary.unwrap_or_default(), SUMMARY_MAX_CHARS) +} + +/// The last part of a path, whichever slash it uses. +fn file_name(path: &str) -> String { + path.rsplit(['/', '\\']) + .find(|part| !part.is_empty()) + .unwrap_or(path) + .to_owned() +} + +/// A readable name for a tool: `mcp__botloft__send_message` reads +/// `send_message`. +pub fn tool_label(name: &str) -> &str { + name.rsplit("__").next().unwrap_or(name) +} + +/// The conversation-list line for a chat item; `None` for items that do not +/// change it (turn ends). +pub fn activity_line(body: &ChatBody) -> Option { + let text = match body { + ChatBody::Inbound(item) => match item.message.from_kind { + SenderKind::Owner => format!("You: {}", item.message.body), + _ => item.message.body.clone(), + }, + ChatBody::Reply(item) => item.text.clone(), + ChatBody::Tool(item) => match item.summary.as_str() { + "" => tool_label(&item.name).to_owned(), + summary => format!("{} · {summary}", tool_label(&item.name)), + }, + ChatBody::Approval(item) => { + format!("Waiting for approval: {}", tool_label(&item.tool_name)) + } + ChatBody::Notice(item) => item.text.clone(), + ChatBody::Turn(_) => return None, + }; + Some(one_line(&text, ACTIVITY_MAX_CHARS)) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::*; + + #[test] + fn clip_keeps_whole_characters() { + assert_eq!(clip("hello", 10), "hello"); + let cut = clip("olá mundo", 5); + assert!(cut.len() <= 5, "{cut}"); + assert!(cut.ends_with('…')); + // Six bytes hold one two-byte letter and the three-byte ellipsis. + assert_eq!(clip("ééééé", 6), "é…"); + } + + #[test] + fn summaries_name_what_the_tool_touches() { + assert_eq!( + tool_summary( + "Bash", + &json!({ "command": "npm test", "description": "Run the tests" }) + ), + "Run the tests" + ); + assert_eq!( + tool_summary("Bash", &json!({ "command": "ls\n-la" })), + "ls -la" + ); + assert_eq!( + tool_summary("Edit", &json!({ "file_path": "C:\\work\\src\\main.rs" })), + "main.rs" + ); + assert_eq!(tool_summary("Grep", &json!({ "pattern": "TODO" })), "TODO"); + assert_eq!( + tool_summary( + "mcp__botloft__send_message", + &json!({ "to": "@writer", "body": "x" }) + ), + "to @writer" + ); + assert_eq!(tool_summary("SomethingNew", &json!({ "a": 1 })), ""); + assert_eq!(tool_label("mcp__botloft__send_message"), "send_message"); + assert_eq!(tool_label("Bash"), "Bash"); + } + + #[test] + fn long_lines_are_flattened_and_cut() { + let text = format!("first line\nsecond {}", "x".repeat(300)); + let line = one_line(&text, 20); + assert_eq!(line.chars().count(), 20); + assert!(line.starts_with("first line second")); + assert!(line.ends_with('…')); + } +} diff --git a/crates/botloft-core/src/ids.rs b/crates/botloft-core/src/ids.rs index c1891790..9f318cf3 100644 --- a/crates/botloft-core/src/ids.rs +++ b/crates/botloft-core/src/ids.rs @@ -88,6 +88,36 @@ prefixed_id!( /// Identifies a task between bots. TaskId, "task", "tsk_" ); +prefixed_id!( + /// Identifies an item of a bot's chat. + ChatItemId, "chat item", "cht_" +); +prefixed_id!( + /// Identifies a permission request waiting for the owner. + ApprovalId, "approval", "apr_" +); +prefixed_id!( + /// Identifies a file the owner attached to a message. + AttachmentId, "attachment", "att_" +); + +/// A random version 4 UUID, for Claude Code session ids and the uuid of +/// each message written to a bot (spec 7.3, 9.2). +pub fn random_uuid() -> String { + let mut value = Ulid::generate().0; + // Version 4 and the RFC 4122 variant, as UUID validators expect. + value = (value & !(0xf << 76)) | (0x4 << 76); + value = (value & !(0x3 << 62)) | (0x2 << 62); + let hex = format!("{value:032x}"); + format!( + "{}-{}-{}-{}-{}", + &hex[0..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..32] + ) +} #[cfg(test)] mod tests { @@ -124,6 +154,15 @@ mod tests { assert!(first < second); } + #[test] + fn random_uuids_are_version_4() { + let uuid = random_uuid(); + assert_eq!(uuid.len(), 36); + assert_eq!(&uuid[14..15], "4"); + assert!(matches!(&uuid[19..20], "8" | "9" | "a" | "b"), "{uuid}"); + assert_ne!(random_uuid(), uuid); + } + #[test] fn serde_uses_the_plain_string_and_validates_it() { let id = CrewId::generate(); diff --git a/crates/botloft-core/src/lib.rs b/crates/botloft-core/src/lib.rs index 1a1e0047..d29b5529 100644 --- a/crates/botloft-core/src/lib.rs +++ b/crates/botloft-core/src/lib.rs @@ -2,6 +2,7 @@ //! the daemon and the app. pub mod avatar; +pub mod chat; 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 new file mode 100644 index 00000000..02c76780 --- /dev/null +++ b/crates/botloft-core/src/protocol/chat.rs @@ -0,0 +1,227 @@ +//! A bot's chat (spec 8) and the approvals shown in it (spec 10.1). + +use serde::{Deserialize, Serialize}; + +use super::Message; +use crate::ids::{ApprovalId, BotId, ChatItemId}; + +text_enum!( + /// Where a tool call the bot made stands. + ToolStatus, "tool status" { + Running => "running", + Done => "done", + Failed => "failed", + } +); + +text_enum!( + ApprovalStatus, "approval status" { + /// Waiting for the owner. + Pending => "pending", + Allowed => "allowed", + Denied => "denied", + /// Nobody answered in time, or the bot's process ended first. + Expired => "expired", + } +); + +text_enum!( + NoticeLevel, "notice level" { + Info => "info", + Warning => "warning", + Error => "error", + } +); + +/// One entry of a bot's chat. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ChatItem { + pub id: ChatItemId, + pub bot_id: BotId, + pub body: ChatBody, + /// Unix time in milliseconds. + pub created_at: i64, + /// Unix time in milliseconds; changes when a tool ends or an approval is + /// answered. + pub updated_at: i64, +} + +/// What a chat item holds, by `kind`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub enum ChatBody { + /// A message to the bot: from the owner, another bot or the daemon. + Inbound(InboundItem), + /// Text the bot wrote, markdown. + Reply(ReplyItem), + Tool(ToolItem), + Approval(ApprovalItem), + /// The end of a turn. + Turn(TurnItem), + Notice(NoticeItem), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct InboundItem { + pub message: Message, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ReplyItem { + pub text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ToolItem { + pub tool_use_id: String, + pub name: String, + /// One line about what the call does, e.g. the command or the file. + pub summary: String, + /// The call's input as JSON, cut at 4 KB. + pub input: String, + pub status: ToolStatus, + /// The start of what the tool returned, cut at 8 KB. + pub output: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ApprovalItem { + pub approval_id: ApprovalId, + pub tool_name: String, + pub summary: String, + pub input: String, + pub status: ApprovalStatus, + /// What the owner wrote with a denial. + pub note: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct TurnItem { + pub duration_ms: u64, + /// As Claude Code reports it; `null` when it did not. + pub cost_usd: Option, + /// Why the turn failed, e.g. `rate_limit`; `null` when it worked. + pub error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct NoticeItem { + pub level: NoticeLevel, + pub text: String, +} + +/// A permission request (spec 10.1). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct Approval { + pub id: ApprovalId, + pub bot_id: BotId, + pub tool_name: String, + pub summary: String, + pub input: String, + pub status: ApprovalStatus, + pub note: Option, + /// Unix time in milliseconds. + pub created_at: i64, + /// Unix time in milliseconds; `null` while pending. + pub answered_at: Option, +} + +/// Params of the `chat.item` notification: an item that is new or changed. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ChatItemChanged { + pub item: ChatItem, + /// The bot's new conversation-list line; `null` when it did not change. + pub activity: Option, +} + +/// Text the bot is writing right now, in order (spec 8.3). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ChatDelta { + pub bot_id: BotId, + pub text: String, +} + +/// Newest first. Page back with `before` set to the oldest id received. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ChatHistoryParams { + pub bot_id: BotId, + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub before: Option, + /// 1 to 200; 50 when absent. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub limit: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct ApprovalsAnswerParams { + pub approval_id: ApprovalId, + pub allow: bool, + /// Passed to the bot with a denial. + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub note: Option, +} + +/// The last thing in a bot's chat, for the conversation list. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct Activity { + /// One line, at most 120 characters. + pub text: String, + /// Unix time in milliseconds. + pub at: i64, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn items_carry_their_kind() { + let body = ChatBody::Reply(ReplyItem { + text: "done".into(), + }); + let json = serde_json::to_value(&body).expect("serialize"); + assert_eq!(json, serde_json::json!({ "kind": "reply", "text": "done" })); + let tool = ChatBody::Tool(ToolItem { + tool_use_id: "toolu_1".into(), + name: "Bash".into(), + summary: "npm test".into(), + input: "{}".into(), + status: ToolStatus::Running, + output: None, + }); + let json = serde_json::to_value(&tool).expect("serialize"); + assert_eq!(json["kind"], "tool"); + assert_eq!(json["toolUseId"], "toolu_1"); + assert_eq!(serde_json::from_value::(json).ok(), Some(tool)); + } +} diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 30ce3625..21ecf0c2 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -9,7 +9,9 @@ use ts_rs::{Config, TS}; use super::*; use crate::avatar::PALETTE; -use crate::ids::{BotId, CrewId, DeliveryId, MessageId, TaskId}; +use crate::ids::{ + ApprovalId, AttachmentId, BotId, ChatItemId, CrewId, DeliveryId, MessageId, TaskId, +}; use crate::validate; const HEADER: &str = "\ @@ -60,14 +62,16 @@ fn export_bindings() { "export const AVATAR_PALETTE = [{}] as const;\n", palette.join(", ") ); - out.text - .push_str("/** Longest values the daemon accepts, in characters. */\n"); + out.text.push_str( + "/** Longest values the daemon accepts, in characters (files, for attachments). */\n", + ); out.text.push_str("export const FIELD_LIMITS = {\n"); for (name, max) in [ ("name", validate::NAME_MAX_CHARS), ("role", validate::ROLE_MAX_CHARS), ("instructions", validate::INSTRUCTIONS_MAX_CHARS), ("message", validate::MESSAGE_MAX_CHARS), + ("attachments", validate::ATTACHMENTS_MAX), ] { let _ = writeln!(out.text, " {name}: {max},"); } @@ -78,13 +82,19 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); out.decl::(); out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -97,15 +107,12 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); - out.decl::(); - out.decl::(); - out.decl::(); - out.decl::(); - out.decl::(); out.decl::(); out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -114,6 +121,22 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); + out.decl::(); let crew = out.name::(); let bot = out.name::(); @@ -160,22 +183,15 @@ fn export_bindings() { ); out.method(method::BOTS_ARCHIVE, &out.name::(), &bot); out.method(method::BOTS_RESTART, &out.name::(), &bot); - let attach = out.name::(); - out.method( - method::TERMINAL_ATTACH, - &out.name::(), - &attach, - ); - out.method(method::TERMINAL_DETACH, &out.name::(), "null"); out.method( - method::TERMINAL_WRITE, - &out.name::(), - "null", + method::CHAT_HISTORY, + &out.name::(), + &out.name::>(), ); out.method( - method::TERMINAL_RESIZE, - &out.name::(), - "null", + method::APPROVALS_ANSWER, + &out.name::(), + &out.name::(), ); let message = out.name::(); let delivery = out.name::(); @@ -212,9 +228,11 @@ fn export_bindings() { let _ = writeln!(out.text, " \"{}\": {crew};", notification::CREW_CHANGED); let _ = writeln!(out.text, " \"{}\": {bot};", notification::BOT_CHANGED); let state = out.name::(); - let data = out.name::(); + let item = out.name::(); + let delta = out.name::(); let _ = writeln!(out.text, " \"{}\": {state};", notification::BOT_STATE); - let _ = writeln!(out.text, " \"{}\": {data};", notification::TERMINAL_DATA); + let _ = writeln!(out.text, " \"{}\": {item};", notification::CHAT_ITEM); + let _ = writeln!(out.text, " \"{}\": {delta};", notification::CHAT_DELTA); let task = out.name::(); let _ = writeln!( out.text, diff --git a/crates/botloft-core/src/protocol/messaging.rs b/crates/botloft-core/src/protocol/messaging.rs index 3a268f25..b067b461 100644 --- a/crates/botloft-core/src/protocol/messaging.rs +++ b/crates/botloft-core/src/protocol/messaging.rs @@ -1,59 +1,9 @@ //! Messages, deliveries and tasks (spec 9 and 12), with the params of the //! `messages.*`, `deliveries.*` and `tasks.*` methods. -use std::fmt; -use std::str::FromStr; - use serde::{Deserialize, Serialize}; -use crate::ids::{BotId, CrewId, DeliveryId, MessageId, TaskId}; - -/// A stored value that is not one of the enum's names. -#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[error("unknown {kind} `{value}`")] -pub struct UnknownVariant { - kind: &'static str, - value: String, -} - -/// Enums stored as text: `as_str` for writing, `FromStr` for reading back. -/// The names match the serde names the app sees. -macro_rules! text_enum { - ($(#[$doc:meta])* $name:ident, $kind:literal { $($(#[$vdoc:meta])* $variant:ident => $text:literal),+ $(,)? }) => { - $(#[$doc])* - #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] - #[serde(rename_all = "snake_case")] - #[cfg_attr(test, derive(ts_rs::TS))] - pub enum $name { - $($(#[$vdoc])* $variant),+ - } - - impl $name { - pub fn as_str(self) -> &'static str { - match self { - $(Self::$variant => $text),+ - } - } - } - - impl FromStr for $name { - type Err = UnknownVariant; - - fn from_str(s: &str) -> Result { - match s { - $($text => Ok(Self::$variant),)+ - _ => Err(UnknownVariant { kind: $kind, value: s.to_owned() }), - } - } - } - - impl fmt::Display for $name { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.as_str()) - } - } - }; -} +use crate::ids::{AttachmentId, BotId, CrewId, DeliveryId, MessageId, TaskId}; text_enum!( /// Who wrote a message. @@ -82,8 +32,8 @@ text_enum!( DeliveryState, "delivery state" { Pending => "pending", Sending => "sending", - /// Claude Code accepted the connection. It does not prove the bot did - /// anything; tasks do. + /// Written to the bot's process; `readAt` says when its turn began. + /// Neither proves the work was done; tasks do. Sent => "sent", /// Gave up after `max_attempts`, or the bot was archived. Dead => "dead", @@ -116,11 +66,49 @@ pub struct Message { pub body: String, /// The task this message asks for, answers or reports on. pub task_id: Option, + /// Files the owner attached (spec 9.5); empty for everything else. + pub attachments: Vec, /// Unix time in milliseconds. pub created_at: i64, } -/// Getting one message into one bot's inbox. +/// A file attached to a message, saved in the bot's folder. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct Attachment { + pub id: AttachmentId, + /// File name as saved, without folders. + pub name: String, + pub media_type: String, + /// Bytes. + pub size: u64, + /// Path relative to the bot's workspace, with forward slashes. + pub path: String, +} + +/// A file the owner sends with `messages.send`. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct AttachmentUpload { + pub name: String, + pub media_type: String, + /// The file's bytes, base64. + pub data: String, +} + +impl std::fmt::Debug for AttachmentUpload { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AttachmentUpload") + .field("name", &self.name) + .field("media_type", &self.media_type) + .field("data", &format_args!("<{} base64 chars>", self.data.len())) + .finish() + } +} + +/// Getting one message into one bot's process. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(test, derive(ts_rs::TS))] @@ -135,6 +123,8 @@ pub struct Delivery { pub next_attempt_at: i64, /// Why the last attempt failed; never contains the message body. pub last_error: Option, + /// Unix time in milliseconds the bot began the turn for this message. + pub read_at: Option, /// Unix time in milliseconds. pub updated_at: i64, } @@ -179,6 +169,10 @@ pub struct DeliveryBacklog { pub struct MessagesSendParams { pub bot_id: BotId, pub body: String, + /// Up to 10 files (spec 9.5). + #[serde(default)] + #[cfg_attr(test, ts(optional))] + pub attachments: Option>, } /// Newest first. Page back with `before` set to the oldest id received. diff --git a/crates/botloft-core/src/protocol/methods.rs b/crates/botloft-core/src/protocol/methods.rs index 8cc34e94..522b10b9 100644 --- a/crates/botloft-core/src/protocol/methods.rs +++ b/crates/botloft-core/src/protocol/methods.rs @@ -45,7 +45,7 @@ pub struct HelloResult { pub protocol: u32, } -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] #[cfg_attr(test, derive(ts_rs::TS))] pub struct SystemStatus { @@ -57,6 +57,34 @@ pub struct SystemStatus { /// Why bots cannot start (Claude Code missing or too old); `null` when fine. pub runtime_error: Option, pub deliveries: DeliveryBacklog, + /// The Claude account's usage as last reported; `null` before any turn. + pub usage: Option, +} + +/// The account's usage, from Claude Code's `rate_limit_event` (spec 8.1). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct AccountUsage { + /// As Claude Code says it: `allowed`, `allowed_warning`, `rejected`... + pub status: String, + /// Unix time in milliseconds when the current limit resets. + pub resets_at: Option, + pub windows: Vec, + /// Unix time in milliseconds. + pub observed_at: i64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct UsageWindow { + /// `five_hour`, `seven_day`... + pub name: String, + /// Share used, 0 to 1. + pub utilization: f64, + /// Unix time in milliseconds. + pub resets_at: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index d4d6dfc5..0d0a06e2 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -2,20 +2,68 @@ //! receives them as TypeScript in `app/src/lib/protocol.gen.ts`, generated by //! `cargo test -p botloft-core export_bindings`. +/// A stored value that is not one of the enum's names. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("unknown {kind} `{value}`")] +pub struct UnknownVariant { + kind: &'static str, + value: String, +} + +/// Enums stored as text: `as_str` for writing, `FromStr` for reading back. +/// The names match the serde names the app sees. +macro_rules! text_enum { + ($(#[$doc:meta])* $name:ident, $kind:literal { $($(#[$vdoc:meta])* $variant:ident => $text:literal),+ $(,)? }) => { + $(#[$doc])* + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, ::serde::Serialize, ::serde::Deserialize)] + #[serde(rename_all = "snake_case")] + #[cfg_attr(test, derive(ts_rs::TS))] + pub enum $name { + $($(#[$vdoc])* $variant),+ + } + + impl $name { + pub fn as_str(self) -> &'static str { + match self { + $(Self::$variant => $text),+ + } + } + } + + impl ::std::str::FromStr for $name { + type Err = $crate::protocol::UnknownVariant; + + fn from_str(s: &str) -> Result { + match s { + $($text => Ok(Self::$variant),)+ + _ => Err($crate::protocol::UnknownVariant { kind: $kind, value: s.to_owned() }), + } + } + } + + impl ::std::fmt::Display for $name { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + f.write_str(self.as_str()) + } + } + }; +} + +mod chat; #[cfg(test)] mod export; mod messaging; mod methods; mod model; -mod terminal; +pub use chat::*; pub use messaging::*; pub use methods::*; pub use model::*; -pub use terminal::*; -/// Version negotiated in `session.hello`. -pub const PROTOCOL_VERSION: u32 = 1; +/// Version negotiated in `session.hello`. 2 replaced the terminal with the +/// chat (ADR 0001). +pub const PROTOCOL_VERSION: u32 = 2; /// Request method names. pub mod method { @@ -32,10 +80,8 @@ pub mod method { pub const BOTS_SET_PAUSED: &str = "bots.setPaused"; pub const BOTS_ARCHIVE: &str = "bots.archive"; pub const BOTS_RESTART: &str = "bots.restart"; - pub const TERMINAL_ATTACH: &str = "terminal.attach"; - pub const TERMINAL_DETACH: &str = "terminal.detach"; - pub const TERMINAL_WRITE: &str = "terminal.write"; - pub const TERMINAL_RESIZE: &str = "terminal.resize"; + pub const CHAT_HISTORY: &str = "chat.history"; + pub const APPROVALS_ANSWER: &str = "approvals.answer"; pub const MESSAGES_SEND: &str = "messages.send"; pub const MESSAGES_LIST: &str = "messages.list"; pub const DELIVERIES_LIST: &str = "deliveries.list"; @@ -48,7 +94,8 @@ pub mod notification { pub const CREW_CHANGED: &str = "crew.changed"; pub const BOT_CHANGED: &str = "bot.changed"; pub const BOT_STATE: &str = "bot.state"; - pub const TERMINAL_DATA: &str = "terminal.data"; + pub const CHAT_ITEM: &str = "chat.item"; + pub const CHAT_DELTA: &str = "chat.delta"; pub const MESSAGE_CREATED: &str = "message.created"; pub const DELIVERY_CHANGED: &str = "delivery.changed"; pub const TASK_CHANGED: &str = "task.changed"; diff --git a/crates/botloft-core/src/protocol/model.rs b/crates/botloft-core/src/protocol/model.rs index cb17d2b0..5311dd63 100644 --- a/crates/botloft-core/src/protocol/model.rs +++ b/crates/botloft-core/src/protocol/model.rs @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize}; +use super::Activity; use crate::ids::{BotId, CrewId}; /// A group of bots that can message each other and share a folder. @@ -56,10 +57,12 @@ pub struct Bot { pub paused: bool, pub state: BotState, /// Current process generation; `null` if the bot has not started since - /// the daemon did. Changes on every (re)start (spec 8). + /// the daemon did. Changes on every (re)start. pub generation: Option, /// Absolute path of the bot's workspace folder. pub workspace: String, + /// The last item of the bot's chat, in one line; `null` for a new bot. + pub last_activity: Option, /// Unix time in milliseconds. pub created_at: i64, /// Unix time in milliseconds; `null` while the bot is active. diff --git a/crates/botloft-core/src/protocol/terminal.rs b/crates/botloft-core/src/protocol/terminal.rs deleted file mode 100644 index 8333cd84..00000000 --- a/crates/botloft-core/src/protocol/terminal.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! `terminal.*` methods and the `terminal.data` notification (spec 8). - -use serde::{Deserialize, Serialize}; - -use crate::ids::BotId; - -/// Starts streaming a bot's terminal. With the `generation` and `offset` the -/// client last saw, only the missing bytes are replayed. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(test, derive(ts_rs::TS))] -pub struct TerminalAttachParams { - pub bot_id: BotId, - #[serde(default)] - #[cfg_attr(test, ts(optional))] - pub generation: Option, - #[serde(default)] - #[cfg_attr(test, ts(optional))] - pub offset: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(test, derive(ts_rs::TS))] -pub struct TerminalAttachResult { - pub generation: u64, - /// Offset of the first byte the following `terminal.data` carries. - pub offset: u64, - /// True when the client must clear its screen before writing the replay. - pub reset: bool, - /// Offset where live output starts. The bytes before it replay past - /// output: a terminal must not answer the queries in them (cursor - /// position, device attributes...), which were asked long ago. - pub live_offset: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(test, derive(ts_rs::TS))] -pub struct TerminalWriteParams { - pub bot_id: BotId, - /// Raw bytes, base64. - pub data: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(test, derive(ts_rs::TS))] -pub struct TerminalResizeParams { - pub bot_id: BotId, - pub cols: u16, - pub rows: u16, -} - -/// Terminal output. `offset` counts bytes since the start of `generation`. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -#[cfg_attr(test, derive(ts_rs::TS))] -pub struct TerminalData { - pub bot_id: BotId, - pub generation: u64, - pub offset: u64, - /// Raw bytes, base64. May end in the middle of a UTF-8 sequence. - pub data: String, -} diff --git a/crates/botloft-core/src/slug.rs b/crates/botloft-core/src/slug.rs index f1961067..1e50ffa7 100644 --- a/crates/botloft-core/src/slug.rs +++ b/crates/botloft-core/src/slug.rs @@ -60,6 +60,45 @@ pub fn unique(base: &str, mut taken: impl FnMut(&str) -> bool) -> String { } } +/// Longest attachment file name kept, in characters. +const FILE_NAME_MAX: usize = 100; + +/// A file name that is safe to write in a folder on Windows: no folders, +/// no reserved characters or device names, no trailing dots or spaces +/// (spec 9.5). Keeps the extension when it has to shorten the name. +pub fn file_name(name: &str) -> String { + let last = name.rsplit(['/', '\\']).next().unwrap_or(name); + let cleaned: String = last + .chars() + .filter(|ch| !ch.is_control() && !r#"<>:"/\|?*"#.contains(*ch)) + .collect(); + let cleaned = cleaned + .trim() + .trim_end_matches(['.', ' ']) + .trim_start_matches('.'); + let mut name = if cleaned.is_empty() { + "file".to_owned() + } else { + cleaned.to_owned() + }; + let (stem, extension) = match name.rfind('.') { + Some(dot) if dot > 0 => (name[..dot].to_owned(), name[dot..].to_owned()), + _ => (name.clone(), String::new()), + }; + if WINDOWS_RESERVED.contains(&stem.to_ascii_lowercase().as_str()) { + name = format!("{stem}-file{extension}"); + } + if name.chars().count() > FILE_NAME_MAX { + let (stem, extension) = match name.rfind('.') { + Some(dot) if dot > 0 && name.len() - dot <= 12 => (&name[..dot], &name[dot..]), + _ => (name.as_str(), ""), + }; + let keep = FILE_NAME_MAX.saturating_sub(extension.chars().count()); + name = format!("{}{extension}", stem.chars().take(keep).collect::()); + } + name +} + /// Cuts an ASCII slug to `max` characters without leaving a trailing hyphen. fn truncate(slug: &str, max: usize) -> String { let cut = &slug[..slug.len().min(max)]; @@ -111,4 +150,26 @@ mod tests { assert_eq!(slug.len(), MAX_LEN); assert!(slug.ends_with("-2")); } + + #[test] + fn file_names_lose_folders_and_forbidden_characters() { + assert_eq!(file_name("relatório final.pdf"), "relatório final.pdf"); + assert_eq!(file_name(r"C:\Users\ana\notes.txt"), "notes.txt"); + assert_eq!(file_name("../../etc/passwd"), "passwd"); + assert_eq!(file_name("a:c?.png"), "abc.png"); + assert_eq!(file_name("trailing. . "), "trailing"); + assert_eq!(file_name(".hidden"), "hidden"); + assert_eq!(file_name(""), "file"); + assert_eq!(file_name("..."), "file"); + assert_eq!(file_name("CON.txt"), "CON-file.txt"); + assert_eq!(file_name("nul"), "nul-file"); + } + + #[test] + fn long_file_names_keep_their_extension() { + let name = format!("{}.xlsx", "n".repeat(300)); + let short = file_name(&name); + assert_eq!(short.chars().count(), FILE_NAME_MAX); + assert!(short.ends_with(".xlsx")); + } } diff --git a/crates/botloft-core/src/validate.rs b/crates/botloft-core/src/validate.rs index 5ed0d4d2..d7d3b668 100644 --- a/crates/botloft-core/src/validate.rs +++ b/crates/botloft-core/src/validate.rs @@ -11,6 +11,8 @@ pub const ROLE_MAX_CHARS: usize = 200; pub const INSTRUCTIONS_MAX_CHARS: usize = 32_000; /// Well under the ~1 million characters Claude Code accepts per message. pub const MESSAGE_MAX_CHARS: usize = 100_000; +/// Files per message (spec 9.5). +pub const ATTACHMENTS_MAX: usize = 10; /// Trims a crew or bot name and checks it is a non-empty single line. pub fn name(field: &str, value: &str) -> Result { diff --git a/crates/botloft-store/Cargo.toml b/crates/botloft-store/Cargo.toml index fa94ff98..154d49d9 100644 --- a/crates/botloft-store/Cargo.toml +++ b/crates/botloft-store/Cargo.toml @@ -10,6 +10,7 @@ authors.workspace = true [dependencies] botloft-core.workspace = true rusqlite = { version = "0.40.2", features = ["bundled"] } +serde_json = "1.0.151" thiserror = "2.0.21" [lints] diff --git a/crates/botloft-store/migrations/0003_chat.sql b/crates/botloft-store/migrations/0003_chat.sql new file mode 100644 index 00000000..eff8b5a6 --- /dev/null +++ b/crates/botloft-store/migrations/0003_chat.sql @@ -0,0 +1,56 @@ +-- Headless bots with a chat (ADR 0001; spec 8, 9.1, 9.5, 10.1 and 12). +-- Times are Unix milliseconds; rowid keeps the insertion order. + +-- The Claude Code conversation a bot resumes (spec 7.3). +ALTER TABLE bots ADD COLUMN session_id TEXT; + +-- Which process a delivery was written to, the uuid it went with, and when +-- the bot began the turn for it (spec 9.1). +ALTER TABLE deliveries ADD COLUMN sent_generation INTEGER; +ALTER TABLE deliveries ADD COLUMN turn_uuid TEXT; +ALTER TABLE deliveries ADD COLUMN read_at INTEGER; + +CREATE INDEX deliveries_turn_uuid ON deliveries (turn_uuid) WHERE turn_uuid IS NOT NULL; +CREATE INDEX deliveries_unread ON deliveries (bot_id, sent_generation) + WHERE state = 'sent' AND read_at IS NULL; + +CREATE TABLE attachments ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL REFERENCES messages (id), + name TEXT NOT NULL, + media_type TEXT NOT NULL, + size INTEGER NOT NULL, + -- Relative to the bot's workspace, with forward slashes. + path TEXT NOT NULL, + created_at INTEGER NOT NULL +); + +CREATE INDEX attachments_message ON attachments (message_id); + +CREATE TABLE chat_items ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL REFERENCES bots (id), + kind TEXT NOT NULL, + -- The item's body (protocol ChatBody) as JSON. + data TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE INDEX chat_items_bot ON chat_items (bot_id); + +CREATE TABLE approvals ( + id TEXT PRIMARY KEY, + bot_id TEXT NOT NULL REFERENCES bots (id), + chat_item_id TEXT NOT NULL REFERENCES chat_items (id), + tool_use_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + summary TEXT NOT NULL, + input TEXT NOT NULL, + status TEXT NOT NULL, + note TEXT, + created_at INTEGER NOT NULL, + answered_at INTEGER +); + +CREATE INDEX approvals_pending ON approvals (bot_id) WHERE status = 'pending'; diff --git a/crates/botloft-store/src/approvals.rs b/crates/botloft-store/src/approvals.rs new file mode 100644 index 00000000..ff0a3704 --- /dev/null +++ b/crates/botloft-store/src/approvals.rs @@ -0,0 +1,196 @@ +//! `approvals` table: permission requests waiting for the owner (spec 10.1). + +use botloft_core::ids::{ApprovalId, BotId, ChatItemId}; +use botloft_core::protocol::{Approval, ApprovalStatus}; +use rusqlite::{OptionalExtension, Row, params}; + +use crate::{Result, Store, parse_column}; + +const COLUMNS: &str = + "id, bot_id, tool_name, summary, input, status, note, created_at, answered_at, chat_item_id"; + +/// An approval and the chat item that shows it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ApprovalRecord { + pub approval: Approval, + pub chat_item_id: ChatItemId, + pub tool_use_id: String, +} + +fn from_row(row: &Row<'_>) -> rusqlite::Result { + Ok(ApprovalRecord { + approval: Approval { + id: parse_column(row, 0)?, + bot_id: parse_column(row, 1)?, + tool_name: row.get(2)?, + summary: row.get(3)?, + input: row.get(4)?, + status: parse_column(row, 5)?, + note: row.get(6)?, + created_at: row.get(7)?, + answered_at: row.get(8)?, + }, + chat_item_id: parse_column(row, 9)?, + tool_use_id: row.get(10)?, + }) +} + +impl Store { + pub fn insert_approval(&self, record: &ApprovalRecord) -> Result<()> { + let approval = &record.approval; + self.conn.execute( + &format!( + "INSERT INTO approvals ({COLUMNS}, tool_use_id) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)" + ), + params![ + approval.id.as_str(), + approval.bot_id.as_str(), + approval.tool_name, + approval.summary, + approval.input, + approval.status.as_str(), + approval.note, + approval.created_at, + approval.answered_at, + record.chat_item_id.as_str(), + record.tool_use_id, + ], + )?; + Ok(()) + } + + pub fn approval(&self, id: &ApprovalId) -> Result> { + Ok(self + .conn + .query_row( + &format!("SELECT {COLUMNS}, tool_use_id FROM approvals WHERE id = ?1"), + [id.as_str()], + from_row, + ) + .optional()?) + } + + /// Answers a pending approval. `None` if it was already settled. + pub fn settle_approval( + &self, + id: &ApprovalId, + status: ApprovalStatus, + note: Option<&str>, + now: i64, + ) -> Result> { + Ok(self + .conn + .query_row( + &format!( + "UPDATE approvals SET status = ?2, note = ?3, answered_at = ?4 \ + WHERE id = ?1 AND status = 'pending' RETURNING {COLUMNS}, tool_use_id" + ), + params![id.as_str(), status.as_str(), note, now], + from_row, + ) + .optional()?) + } + + /// Expires every pending approval, of one bot or of all: their process + /// is gone, so nobody waits for the answer. + pub fn expire_approvals(&self, bot: Option<&BotId>, now: i64) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "UPDATE approvals SET status = 'expired', answered_at = ?2 \ + WHERE status = 'pending' AND (?1 IS NULL OR bot_id = ?1) \ + RETURNING {COLUMNS}, tool_use_id" + ))?; + let rows = stmt.query_map(params![bot.map(BotId::as_str), now], from_row)?; + Ok(rows.collect::>()?) + } +} + +#[cfg(test)] +mod tests { + use botloft_core::protocol::{ApprovalItem, ChatBody, ChatItem}; + + use super::*; + use crate::tests::Fixture; + + fn pending(fx: &Fixture, bot: usize) -> ApprovalRecord { + let bot_id = fx.bots[bot].id.clone(); + let approval = Approval { + id: ApprovalId::generate(), + bot_id: bot_id.clone(), + tool_name: "Bash".into(), + summary: "rm -rf build".into(), + input: r#"{"command":"rm -rf build"}"#.into(), + status: ApprovalStatus::Pending, + note: None, + created_at: 10, + answered_at: None, + }; + let item = ChatItem { + id: ChatItemId::generate(), + bot_id, + body: ChatBody::Approval(ApprovalItem { + approval_id: approval.id.clone(), + tool_name: approval.tool_name.clone(), + summary: approval.summary.clone(), + input: approval.input.clone(), + status: ApprovalStatus::Pending, + note: None, + }), + created_at: 10, + updated_at: 10, + }; + fx.store.insert_chat_item(&item).expect("item"); + let record = ApprovalRecord { + approval, + chat_item_id: item.id, + tool_use_id: "toolu_1".into(), + }; + fx.store.insert_approval(&record).expect("insert"); + record + } + + #[test] + fn an_approval_is_answered_once() { + let fx = Fixture::new(); + let record = pending(&fx, 0); + assert_eq!( + fx.store.approval(&record.approval.id).expect("read"), + Some(record.clone()) + ); + let denied = fx + .store + .settle_approval( + &record.approval.id, + ApprovalStatus::Denied, + Some("not now"), + 20, + ) + .expect("settle") + .expect("was pending"); + assert_eq!(denied.approval.status, ApprovalStatus::Denied); + assert_eq!(denied.approval.note.as_deref(), Some("not now")); + assert_eq!(denied.approval.answered_at, Some(20)); + let again = fx + .store + .settle_approval(&record.approval.id, ApprovalStatus::Allowed, None, 30) + .expect("settle"); + assert_eq!(again, None); + } + + #[test] + fn a_bots_pending_approvals_expire_together() { + let fx = Fixture::new(); + let first = pending(&fx, 0); + let other = pending(&fx, 1); + let expired = fx + .store + .expire_approvals(Some(&fx.bots[0].id), 40) + .expect("expire"); + assert_eq!(expired.len(), 1); + assert_eq!(expired[0].approval.id, first.approval.id); + assert_eq!(expired[0].approval.status, ApprovalStatus::Expired); + let rest = fx.store.expire_approvals(None, 50).expect("expire all"); + assert_eq!(rest.len(), 1); + assert_eq!(rest[0].approval.id, other.approval.id); + } +} diff --git a/crates/botloft-store/src/bots.rs b/crates/botloft-store/src/bots.rs index b1f77099..e26804fe 100644 --- a/crates/botloft-store/src/bots.rs +++ b/crates/botloft-store/src/bots.rs @@ -143,6 +143,28 @@ impl Store { .map_err(|err| unique_as_duplicate(err, "bot handle"))?; Ok(()) } + + /// The Claude Code conversation the bot resumes (spec 7.3). + pub fn session_id(&self, id: &BotId) -> Result> { + Ok(self + .conn + .query_row( + "SELECT session_id FROM bots WHERE id = ?1", + [id.as_str()], + |row| row.get(0), + ) + .optional()? + .flatten()) + } + + /// `None` makes the next start a new conversation. + pub fn set_session_id(&self, id: &BotId, session: Option<&str>) -> Result<()> { + self.conn.execute( + "UPDATE bots SET session_id = ?2 WHERE id = ?1", + params![id.as_str(), session], + )?; + Ok(()) + } } #[cfg(test)] diff --git a/crates/botloft-store/src/chat.rs b/crates/botloft-store/src/chat.rs new file mode 100644 index 00000000..48445d2f --- /dev/null +++ b/crates/botloft-store/src/chat.rs @@ -0,0 +1,261 @@ +//! `chat_items` table: each bot's chat (spec 8). The body is stored as the +//! protocol's JSON, so new item kinds need no migration. + +use botloft_core::chat::activity_line; +use botloft_core::ids::{BotId, ChatItemId}; +use botloft_core::protocol::{Activity, ChatBody, ChatItem}; +use rusqlite::types::Type; +use rusqlite::{Connection, OptionalExtension, Row, params}; + +use crate::{Result, Store, parse_column}; + +const COLUMNS: &str = "id, bot_id, data, created_at, updated_at"; + +fn from_row(row: &Row<'_>) -> rusqlite::Result { + let data: String = row.get(2)?; + let body = serde_json::from_str(&data) + .map_err(|err| rusqlite::Error::FromSqlConversionFailure(2, Type::Text, Box::new(err)))?; + Ok(ChatItem { + id: parse_column(row, 0)?, + bot_id: parse_column(row, 1)?, + body, + created_at: row.get(3)?, + updated_at: row.get(4)?, + }) +} + +/// The `kind` column: the body's tag. +fn kind(body: &ChatBody) -> &'static str { + match body { + ChatBody::Inbound(_) => "inbound", + ChatBody::Reply(_) => "reply", + ChatBody::Tool(_) => "tool", + ChatBody::Approval(_) => "approval", + ChatBody::Turn(_) => "turn", + ChatBody::Notice(_) => "notice", + } +} + +fn json(body: &ChatBody) -> Result { + Ok(serde_json::to_string(body) + .map_err(|err| rusqlite::Error::ToSqlConversionFailure(Box::new(err)))?) +} + +impl Store { + pub fn insert_chat_item(&self, item: &ChatItem) -> Result<()> { + Self::insert_chat_item_in(&self.conn, item) + } + + pub(crate) fn insert_chat_item_in(conn: &Connection, item: &ChatItem) -> Result<()> { + conn.execute( + "INSERT INTO chat_items (id, bot_id, kind, data, created_at, updated_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![ + item.id.as_str(), + item.bot_id.as_str(), + kind(&item.body), + json(&item.body)?, + item.created_at, + item.updated_at, + ], + )?; + Ok(()) + } + + /// Replaces an item's body. `None` if there is no such item. + pub fn update_chat_item( + &self, + id: &ChatItemId, + body: &ChatBody, + now: i64, + ) -> Result> { + Ok(self + .conn + .query_row( + &format!( + "UPDATE chat_items SET kind = ?2, data = ?3, updated_at = ?4 \ + WHERE id = ?1 RETURNING {COLUMNS}" + ), + params![id.as_str(), kind(body), json(body)?, now], + from_row, + ) + .optional()?) + } + + pub fn chat_item(&self, id: &ChatItemId) -> Result> { + Ok(self + .conn + .query_row( + &format!("SELECT {COLUMNS} FROM chat_items WHERE id = ?1"), + [id.as_str()], + from_row, + ) + .optional()?) + } + + /// Newest first, in the order items were stored; `before` pages back. + pub fn chat_history( + &self, + bot: &BotId, + before: Option<&ChatItemId>, + limit: u32, + ) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "SELECT {COLUMNS} FROM chat_items \ + WHERE bot_id = ?1 \ + AND (?2 IS NULL OR rowid < (SELECT rowid FROM chat_items WHERE id = ?2)) \ + ORDER BY rowid DESC LIMIT ?3" + ))?; + let rows = stmt.query_map( + params![bot.as_str(), before.map(ChatItemId::as_str), limit], + from_row, + )?; + Ok(rows.collect::>()?) + } + + /// The bot's newest tool item for `tool_use_id`, to update it when the + /// tool returns. + pub fn tool_item(&self, bot: &BotId, tool_use_id: &str) -> Result> { + Ok(self + .conn + .query_row( + &format!( + "SELECT {COLUMNS} FROM chat_items \ + WHERE bot_id = ?1 AND kind = 'tool' \ + AND json_extract(data, '$.toolUseId') = ?2 \ + ORDER BY rowid DESC LIMIT 1" + ), + params![bot.as_str(), tool_use_id], + from_row, + ) + .optional()?) + } + + /// The conversation-list line: the newest item that has one. + pub fn last_activity(&self, bot: &BotId) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "SELECT {COLUMNS} FROM chat_items WHERE bot_id = ?1 AND kind != 'turn' \ + ORDER BY rowid DESC LIMIT 1" + ))?; + let item = stmt.query_row([bot.as_str()], from_row).optional()?; + Ok(item.and_then(|item| { + activity_line(&item.body).map(|text| Activity { + text, + at: item.updated_at, + }) + })) + } +} + +#[cfg(test)] +mod tests { + use botloft_core::protocol::{ReplyItem, ToolItem, ToolStatus, TurnItem}; + + use super::*; + use crate::tests::Fixture; + + fn item(bot: &BotId, body: ChatBody, at: i64) -> ChatItem { + ChatItem { + id: ChatItemId::generate(), + bot_id: bot.clone(), + body, + created_at: at, + updated_at: at, + } + } + + fn reply(text: &str) -> ChatBody { + ChatBody::Reply(ReplyItem { text: text.into() }) + } + + #[test] + fn items_page_back_newest_first() { + let fx = Fixture::new(); + let bot = &fx.bots[0].id; + let other = &fx.bots[1].id; + let ids: Vec<_> = (0..3) + .map(|n| { + let entry = item(bot, reply(&format!("r{n}")), 10 + n); + fx.store.insert_chat_item(&entry).expect("insert"); + entry.id + }) + .collect(); + fx.store + .insert_chat_item(&item(other, reply("elsewhere"), 20)) + .expect("insert"); + let newest = fx.store.chat_history(bot, None, 2).expect("history"); + assert_eq!( + newest.iter().map(|i| i.id.clone()).collect::>(), + [ids[2].clone(), ids[1].clone()] + ); + let older = fx + .store + .chat_history(bot, Some(&ids[1]), 10) + .expect("older"); + assert_eq!(older.len(), 1); + assert_eq!(older[0].body, reply("r0")); + } + + #[test] + fn a_tool_item_is_found_and_updated() { + let fx = Fixture::new(); + let bot = &fx.bots[0].id; + let running = ToolItem { + tool_use_id: "toolu_7".into(), + name: "Bash".into(), + summary: "npm test".into(), + input: "{}".into(), + status: ToolStatus::Running, + output: None, + }; + let entry = item(bot, ChatBody::Tool(running.clone()), 5); + fx.store.insert_chat_item(&entry).expect("insert"); + let found = fx + .store + .tool_item(bot, "toolu_7") + .expect("find") + .expect("some"); + assert_eq!(found.id, entry.id); + assert!(fx.store.tool_item(bot, "toolu_8").expect("find").is_none()); + + let done = ChatBody::Tool(ToolItem { + status: ToolStatus::Done, + output: Some("ok".into()), + ..running + }); + let updated = fx + .store + .update_chat_item(&entry.id, &done, 9) + .expect("update") + .expect("some"); + assert_eq!( + (updated.body, updated.created_at, updated.updated_at), + (done, 5, 9) + ); + } + + #[test] + fn the_last_activity_skips_turn_ends() { + let fx = Fixture::new(); + let bot = &fx.bots[0].id; + assert_eq!(fx.store.last_activity(bot).expect("none"), None); + fx.store + .insert_chat_item(&item(bot, reply("All done.\nDetails below"), 7)) + .expect("insert"); + let turn = ChatBody::Turn(TurnItem { + duration_ms: 1000, + cost_usd: Some(0.01), + error: None, + }); + fx.store + .insert_chat_item(&item(bot, turn, 8)) + .expect("insert"); + assert_eq!( + fx.store.last_activity(bot).expect("activity"), + Some(Activity { + text: "All done. Details below".into(), + at: 7 + }) + ); + } +} diff --git a/crates/botloft-store/src/deliveries.rs b/crates/botloft-store/src/deliveries.rs index 20bd03e7..513b837e 100644 --- a/crates/botloft-store/src/deliveries.rs +++ b/crates/botloft-store/src/deliveries.rs @@ -6,10 +6,10 @@ use botloft_core::ids::{BotId, DeliveryId}; use botloft_core::protocol::{Delivery, DeliveryBacklog, DeliveryState}; use rusqlite::{Connection, OptionalExtension, Row, params}; -use crate::{Result, Store, parse_column}; +use crate::{Result, Store, parse_column, to_sql_int}; const COLUMNS: &str = - "id, message_id, bot_id, state, attempts, next_attempt_at, last_error, updated_at"; + "id, message_id, bot_id, state, attempts, next_attempt_at, last_error, read_at, updated_at"; /// Longest list `deliveries` returns. const LIST_LIMIT: u32 = 500; @@ -23,33 +23,33 @@ fn from_row(row: &Row<'_>) -> rusqlite::Result { attempts: row.get(4)?, next_attempt_at: row.get(5)?, last_error: row.get(6)?, - updated_at: row.get(7)?, + read_at: row.get(7)?, + updated_at: row.get(8)?, }) } /// How an attempt, or a decision not to try, ended. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DeliveryOutcome<'a> { - Sent, + /// Written to the process of `generation` with `turn_uuid` (spec 9.2). + Sent { generation: u64, turn_uuid: &'a str }, /// The bot is not ready: try again at `until`, without counting an attempt. - Defer { - until: i64, - }, + Defer { until: i64 }, /// Counts an attempt. `retry_at: None` gives up. Failed { error: &'a str, retry_at: Option, }, /// Gives up without counting an attempt, e.g. the bot was archived. - Dead { - error: &'a str, - }, + Dead { error: &'a str }, } impl Store { pub(crate) fn insert_delivery_in(conn: &Connection, delivery: &Delivery) -> Result<()> { conn.execute( - &format!("INSERT INTO deliveries ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"), + &format!( + "INSERT INTO deliveries ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)" + ), params![ delivery.id.as_str(), delivery.message_id.as_str(), @@ -58,6 +58,7 @@ impl Store { delivery.attempts, delivery.next_attempt_at, delivery.last_error, + delivery.read_at, delivery.updated_at, ], )?; @@ -157,9 +158,13 @@ impl Store { }; let id = id.as_str(); let row = match outcome { - DeliveryOutcome::Sent => update( - "state = 'sent', last_error = NULL, updated_at = ?2", - params![id, now], + DeliveryOutcome::Sent { + generation, + turn_uuid, + } => update( + "state = 'sent', last_error = NULL, updated_at = ?2, sent_generation = ?3, \ + turn_uuid = ?4, read_at = NULL", + params![id, now, to_sql_int(generation), turn_uuid], ), // Waiting is not news: updated_at stays, so lists do not churn. DeliveryOutcome::Defer { until } => update( @@ -189,6 +194,63 @@ impl Store { Ok(row?) } + /// The bot began the turn for the delivery sent with `turn_uuid` + /// (spec 9.1). `None` if no unread delivery went with that uuid. + pub fn mark_read(&self, turn_uuid: &str, now: i64) -> Result> { + Ok(self + .conn + .query_row( + &format!( + "UPDATE deliveries SET read_at = ?2, updated_at = ?2 \ + WHERE turn_uuid = ?1 AND state = 'sent' AND read_at IS NULL \ + RETURNING {COLUMNS}" + ), + params![turn_uuid, now], + from_row, + ) + .optional()?) + } + + /// Deliveries written to the process of `generation` that it never + /// began, oldest first. + pub fn unread_deliveries(&self, bot: &BotId, generation: u64) -> Result> { + let mut stmt = self.conn.prepare(&format!( + "SELECT {COLUMNS} FROM deliveries \ + WHERE bot_id = ?1 AND sent_generation = ?2 AND state = 'sent' AND read_at IS NULL \ + ORDER BY rowid" + ))?; + let rows = stmt.query_map(params![bot.as_str(), to_sql_int(generation)], from_row)?; + Ok(rows.collect::>()?) + } + + /// Puts a sent but unread delivery back in the queue, counting an + /// attempt; `retry_at: None` gives up. `None` if it was read meanwhile. + pub fn reopen_unread( + &self, + id: &DeliveryId, + error: &str, + retry_at: Option, + now: i64, + ) -> Result> { + let (state, next) = match retry_at { + Some(at) => ("pending", at), + None => ("dead", now), + }; + Ok(self + .conn + .query_row( + &format!( + "UPDATE deliveries SET state = ?2, attempts = attempts + 1, \ + next_attempt_at = ?3, last_error = ?4, updated_at = ?5, \ + sent_generation = NULL, turn_uuid = NULL \ + WHERE id = ?1 AND state = 'sent' AND read_at IS NULL RETURNING {COLUMNS}" + ), + params![id.as_str(), state, next, error, now], + from_row, + ) + .optional()?) + } + /// Returns deliveries whose sender died mid-send to `pending`. pub fn recover_leases(&self, now: i64) -> Result> { let mut stmt = self.conn.prepare(&format!( diff --git a/crates/botloft-store/src/deliveries/tests.rs b/crates/botloft-store/src/deliveries/tests.rs index b3216e0c..a717822a 100644 --- a/crates/botloft-store/src/deliveries/tests.rs +++ b/crates/botloft-store/src/deliveries/tests.rs @@ -34,7 +34,14 @@ fn each_bot_sends_its_oldest_delivery_one_at_a_time() { let sent = fx .store - .finish_delivery(&a1.id, DeliveryOutcome::Sent, 60) + .finish_delivery( + &a1.id, + DeliveryOutcome::Sent { + generation: 1, + turn_uuid: "u-1", + }, + 60, + ) .expect("finish") .expect("row"); assert_eq!(sent.state, DeliveryState::Sent); @@ -134,3 +141,84 @@ fn expired_leases_go_back_to_pending() { 1 ); } + +fn send(fx: &Fixture, delivery: &Delivery, generation: u64, uuid: &str) -> Delivery { + fx.store + .claim_delivery(&delivery.id, 10, 1_000) + .expect("claim"); + fx.store + .finish_delivery( + &delivery.id, + DeliveryOutcome::Sent { + generation, + turn_uuid: uuid, + }, + 20, + ) + .expect("finish") + .expect("was sending") +} + +#[test] +fn the_replay_marks_a_sent_delivery_read_once() { + let fx = Fixture::new(); + let a1 = queue(&fx, 0, "a1", 0); + let sent = send(&fx, &a1, 7, "uuid-a1"); + assert_eq!((sent.state, sent.read_at), (DeliveryState::Sent, None)); + let read = fx + .store + .mark_read("uuid-a1", 30) + .expect("read") + .expect("unread"); + assert_eq!((read.read_at, read.updated_at), (Some(30), 30)); + assert_eq!(fx.store.mark_read("uuid-a1", 40).expect("again"), None); + assert_eq!(fx.store.mark_read("uuid-other", 40).expect("unknown"), None); +} + +#[test] +fn deliveries_a_dead_process_never_began_go_back_or_give_up() { + let fx = Fixture::new(); + let bot = &fx.bots[0].id; + let a1 = queue(&fx, 0, "a1", 0); + let a2 = queue(&fx, 0, "a2", 0); + let read = send(&fx, &a1, 7, "u1"); + fx.store.mark_read("u1", 25).expect("read"); + send(&fx, &a2, 7, "u2"); + let unread = fx.store.unread_deliveries(bot, 7).expect("unread"); + assert_eq!(unread.iter().map(|d| &d.id).collect::>(), [&a2.id]); + assert!( + fx.store + .unread_deliveries(bot, 8) + .expect("other generation") + .is_empty() + ); + + let reopened = fx + .store + .reopen_unread(&a2.id, "the bot's process ended", Some(500), 40) + .expect("reopen") + .expect("was unread"); + assert_eq!( + (reopened.state, reopened.attempts, reopened.next_attempt_at), + (DeliveryState::Pending, 1, 500) + ); + assert_eq!( + reopened.last_error.as_deref(), + Some("the bot's process ended") + ); + // The read one stays sent. + assert_eq!( + fx.store + .reopen_unread(&read.id, "x", Some(1), 50) + .expect("noop"), + None + ); + + send(&fx, &a2, 9, "u3"); + let dead = fx + .store + .reopen_unread(&a2.id, "the bot's process ended", None, 60) + .expect("give up") + .expect("was unread"); + assert_eq!((dead.state, dead.attempts), (DeliveryState::Dead, 2)); +} diff --git a/crates/botloft-store/src/lib.rs b/crates/botloft-store/src/lib.rs index 273bf300..e89ea935 100644 --- a/crates/botloft-store/src/lib.rs +++ b/crates/botloft-store/src/lib.rs @@ -4,7 +4,9 @@ //! One [`Store`] wraps one connection. The daemon keeps a single store behind //! a mutex; queries are short, so there is no connection pool. +mod approvals; mod bots; +mod chat; mod crews; mod deliveries; mod messages; @@ -18,6 +20,7 @@ use std::time::Duration; use rusqlite::types::Type; use rusqlite::{Connection, Row, ffi}; +pub use approvals::ApprovalRecord; pub use bots::BotRecord; pub use deliveries::DeliveryOutcome; pub use messages::MessageFilter; @@ -78,6 +81,12 @@ where .map_err(|err| rusqlite::Error::FromSqlConversionFailure(idx, Type::Text, Box::new(err))) } +/// Generations and sizes are `u64` in the protocol; SQLite stores `i64`. +/// Neither comes near `i64::MAX`. +fn to_sql_int(value: u64) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} + /// Turns a UNIQUE or PRIMARY KEY failure into [`StoreError::Duplicate`]; /// other constraint failures (a missing foreign key, say) stay as they are. fn unique_as_duplicate(err: rusqlite::Error, what: &'static str) -> StoreError { @@ -153,6 +162,7 @@ pub(crate) mod tests { kind: MessageKind::Note, body: body.to_owned(), task_id: None, + attachments: Vec::new(), created_at: 0, }; let delivery = Delivery { @@ -163,6 +173,7 @@ pub(crate) mod tests { attempts: 0, next_attempt_at: 0, last_error: None, + read_at: None, updated_at: 0, }; (message, delivery) diff --git a/crates/botloft-store/src/messages.rs b/crates/botloft-store/src/messages.rs index e815b48d..28124a8d 100644 --- a/crates/botloft-store/src/messages.rs +++ b/crates/botloft-store/src/messages.rs @@ -1,11 +1,14 @@ -//! `messages` table. A message is saved together with its delivery, and -//! with the task it creates, in one transaction (spec 9.1). - -use botloft_core::ids::{BotId, CrewId, MessageId, TaskId}; -use botloft_core::protocol::{Delivery, Message, Task}; +//! `messages` and `attachments` tables. A message is saved together with +//! its attachments, its delivery, the task it creates and the recipient's +//! chat item, in one transaction (spec 9.1). + +use botloft_core::ids::{BotId, ChatItemId, CrewId, MessageId, TaskId}; +use botloft_core::protocol::{ + Attachment, ChatBody, ChatItem, Delivery, InboundItem, Message, Task, +}; use rusqlite::{Connection, OptionalExtension, Row, params}; -use crate::{Result, Store, parse_column}; +use crate::{Result, Store, parse_column, to_sql_int}; const COLUMNS: &str = "id, crew_id, from_kind, from_bot_id, to_bot_id, kind, body, task_id, created_at"; @@ -20,10 +23,34 @@ fn from_row(row: &Row<'_>) -> rusqlite::Result { kind: parse_column(row, 5)?, body: row.get(6)?, task_id: optional_column(row, 7)?, + attachments: Vec::new(), created_at: row.get(8)?, }) } +fn attachment_from_row(row: &Row<'_>) -> rusqlite::Result { + Ok(Attachment { + id: parse_column(row, 0)?, + name: row.get(1)?, + media_type: row.get(2)?, + size: u64::try_from(row.get::<_, i64>(3)?).unwrap_or(0), + path: row.get(4)?, + }) +} + +/// Fills in each message's attachments. +fn with_attachments(conn: &Connection, mut messages: Vec) -> Result> { + let mut stmt = conn.prepare( + "SELECT id, name, media_type, size, path FROM attachments \ + WHERE message_id = ?1 ORDER BY rowid", + )?; + for message in &mut messages { + let rows = stmt.query_map([message.id.as_str()], attachment_from_row)?; + message.attachments = rows.collect::>()?; + } + Ok(messages) +} + /// Reads a nullable text column into an optional parsed type. pub(crate) fn optional_column(row: &Row<'_>, idx: usize) -> rusqlite::Result> where @@ -48,28 +75,30 @@ pub struct MessageFilter<'a> { } impl Store { - /// Saves a message, its delivery and the task it creates, atomically. + /// Saves a message with its attachments, its delivery, the task it + /// creates and the recipient's chat item, atomically. Returns the item. pub fn insert_message( &self, message: &Message, delivery: &Delivery, task: Option<&Task>, - ) -> Result<()> { + ) -> Result { let tx = self.conn.unchecked_transaction()?; if let Some(task) = task { Self::insert_task_in(&tx, task)?; } - Self::insert_message_in(&tx, message, delivery)?; + let item = Self::insert_message_in(&tx, message, delivery)?; tx.commit()?; - Ok(()) + Ok(item) } - /// The message and its delivery, inside the caller's transaction. + /// Everything [`Store::insert_message`] saves but the task, inside the + /// caller's transaction. pub(crate) fn insert_message_in( conn: &Connection, message: &Message, delivery: &Delivery, - ) -> Result<()> { + ) -> Result { conn.execute( &format!( "INSERT INTO messages ({COLUMNS}) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)" @@ -86,7 +115,33 @@ impl Store { message.created_at, ], )?; - Self::insert_delivery_in(conn, delivery) + for attachment in &message.attachments { + conn.execute( + "INSERT INTO attachments (id, message_id, name, media_type, size, path, created_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", + params![ + attachment.id.as_str(), + message.id.as_str(), + attachment.name, + attachment.media_type, + to_sql_int(attachment.size), + attachment.path, + message.created_at, + ], + )?; + } + Self::insert_delivery_in(conn, delivery)?; + let item = ChatItem { + id: ChatItemId::generate(), + bot_id: message.to_bot_id.clone(), + body: ChatBody::Inbound(InboundItem { + message: message.clone(), + }), + created_at: message.created_at, + updated_at: message.created_at, + }; + Self::insert_chat_item_in(conn, &item)?; + Ok(item) } /// The message that asked for `task`. @@ -105,14 +160,15 @@ impl Store { } pub fn message(&self, id: &MessageId) -> Result> { - Ok(self + let message = self .conn .query_row( &format!("SELECT {COLUMNS} FROM messages WHERE id = ?1"), [id.as_str()], from_row, ) - .optional()?) + .optional()?; + Ok(with_attachments(&self.conn, message.into_iter().collect())?.pop()) } /// Newest first, in the order they were stored. @@ -133,86 +189,9 @@ impl Store { ], from_row, )?; - Ok(rows.collect::>()?) + with_attachments(&self.conn, rows.collect::>()?) } } #[cfg(test)] -mod tests { - use botloft_core::protocol::{MessageKind, SenderKind}; - - use super::*; - use crate::tests::{Fixture, message_to}; - - #[test] - fn a_message_is_saved_with_its_delivery_and_read_back() { - let fx = Fixture::new(); - let (message, delivery) = message_to(&fx.crew.id, &fx.bots[0].id, "hello"); - fx.store - .insert_message(&message, &delivery, None) - .expect("insert"); - assert_eq!( - fx.store.message(&message.id).expect("read"), - Some(message.clone()) - ); - assert_eq!( - fx.store.delivery(&delivery.id).expect("read"), - Some(delivery) - ); - assert_eq!(message.from_kind, SenderKind::Owner); - assert_eq!(message.kind, MessageKind::Note); - } - - #[test] - fn a_failed_insert_leaves_nothing_behind() { - let fx = Fixture::new(); - let (message, mut delivery) = message_to(&fx.crew.id, &fx.bots[0].id, "hello"); - delivery.bot_id = BotId::generate(); // no such bot: the FK fails - assert!(fx.store.insert_message(&message, &delivery, None).is_err()); - assert_eq!(fx.store.message(&message.id).expect("read"), None); - } - - #[test] - fn listing_filters_by_bot_and_pages_back_newest_first() { - let fx = Fixture::new(); - let mut ids = Vec::new(); - for (i, bot) in [&fx.bots[0], &fx.bots[1], &fx.bots[0]].iter().enumerate() { - let (message, delivery) = message_to(&fx.crew.id, &bot.id, &format!("m{i}")); - fx.store - .insert_message(&message, &delivery, None) - .expect("insert"); - ids.push(message.id); - } - let all = fx - .store - .messages(MessageFilter { - limit: 10, - ..MessageFilter::default() - }) - .expect("list"); - let bodies: Vec<_> = all.iter().map(|m| m.body.as_str()).collect(); - assert_eq!(bodies, ["m2", "m1", "m0"]); - - let first_bot = fx - .store - .messages(MessageFilter { - bot: Some(&fx.bots[0].id), - limit: 10, - ..MessageFilter::default() - }) - .expect("list"); - assert_eq!(first_bot.len(), 2); - - let older = fx - .store - .messages(MessageFilter { - crew: Some(&fx.crew.id), - before: Some(&ids[2]), - limit: 1, - ..MessageFilter::default() - }) - .expect("page"); - assert_eq!(older.len(), 1); - assert_eq!(older[0].body, "m1"); - } -} +mod tests; diff --git a/crates/botloft-store/src/messages/tests.rs b/crates/botloft-store/src/messages/tests.rs new file mode 100644 index 00000000..e8585d60 --- /dev/null +++ b/crates/botloft-store/src/messages/tests.rs @@ -0,0 +1,117 @@ +use botloft_core::protocol::{MessageKind, SenderKind}; + +use super::*; +use crate::tests::{Fixture, message_to}; + +#[test] +fn a_message_is_saved_with_its_delivery_and_read_back() { + let fx = Fixture::new(); + let (message, delivery) = message_to(&fx.crew.id, &fx.bots[0].id, "hello"); + fx.store + .insert_message(&message, &delivery, None) + .expect("insert"); + assert_eq!( + fx.store.message(&message.id).expect("read"), + Some(message.clone()) + ); + assert_eq!( + fx.store.delivery(&delivery.id).expect("read"), + Some(delivery) + ); + assert_eq!(message.from_kind, SenderKind::Owner); + assert_eq!(message.kind, MessageKind::Note); +} + +#[test] +fn attachments_and_the_chat_item_come_with_the_message() { + let fx = Fixture::new(); + let (mut message, delivery) = message_to(&fx.crew.id, &fx.bots[0].id, "see attached"); + message.attachments = vec![Attachment { + id: botloft_core::ids::AttachmentId::generate(), + name: "plan.pdf".into(), + media_type: "application/pdf".into(), + size: 2048, + path: "attachments/2026-09-28/plan.pdf".into(), + }]; + let item = fx + .store + .insert_message(&message, &delivery, None) + .expect("insert"); + assert_eq!(item.bot_id, fx.bots[0].id); + assert_eq!( + item.body, + ChatBody::Inbound(InboundItem { + message: message.clone() + }) + ); + assert_eq!( + fx.store.message(&message.id).expect("read"), + Some(message.clone()) + ); + let history = fx + .store + .chat_history(&fx.bots[0].id, None, 10) + .expect("history"); + assert_eq!(history, vec![item]); + let listed = fx + .store + .messages(MessageFilter { + limit: 5, + ..MessageFilter::default() + }) + .expect("list"); + assert_eq!(listed[0].attachments, message.attachments); +} + +#[test] +fn a_failed_insert_leaves_nothing_behind() { + let fx = Fixture::new(); + let (message, mut delivery) = message_to(&fx.crew.id, &fx.bots[0].id, "hello"); + delivery.bot_id = BotId::generate(); // no such bot: the FK fails + assert!(fx.store.insert_message(&message, &delivery, None).is_err()); + assert_eq!(fx.store.message(&message.id).expect("read"), None); +} + +#[test] +fn listing_filters_by_bot_and_pages_back_newest_first() { + let fx = Fixture::new(); + let mut ids = Vec::new(); + for (i, bot) in [&fx.bots[0], &fx.bots[1], &fx.bots[0]].iter().enumerate() { + let (message, delivery) = message_to(&fx.crew.id, &bot.id, &format!("m{i}")); + fx.store + .insert_message(&message, &delivery, None) + .expect("insert"); + ids.push(message.id); + } + let all = fx + .store + .messages(MessageFilter { + limit: 10, + ..MessageFilter::default() + }) + .expect("list"); + let bodies: Vec<_> = all.iter().map(|m| m.body.as_str()).collect(); + assert_eq!(bodies, ["m2", "m1", "m0"]); + + let first_bot = fx + .store + .messages(MessageFilter { + bot: Some(&fx.bots[0].id), + limit: 10, + ..MessageFilter::default() + }) + .expect("list"); + assert_eq!(first_bot.len(), 2); + + let older = fx + .store + .messages(MessageFilter { + crew: Some(&fx.crew.id), + before: Some(&ids[2]), + limit: 1, + ..MessageFilter::default() + }) + .expect("page"); + assert_eq!(older.len(), 1); + assert_eq!(older[0].body, "m1"); +} diff --git a/crates/botloft-store/src/migrate.rs b/crates/botloft-store/src/migrate.rs index abfaacfc..5b8a68a1 100644 --- a/crates/botloft-store/src/migrate.rs +++ b/crates/botloft-store/src/migrate.rs @@ -9,6 +9,7 @@ use crate::{Result, StoreError}; const MIGRATIONS: &[(u32, &str)] = &[ (1, include_str!("../migrations/0001_init.sql")), (2, include_str!("../migrations/0002_messages.sql")), + (3, include_str!("../migrations/0003_chat.sql")), ]; /// Schema version after every migration has run. diff --git a/crates/botloft-store/src/tasks.rs b/crates/botloft-store/src/tasks.rs index fd0f5293..fee43285 100644 --- a/crates/botloft-store/src/tasks.rs +++ b/crates/botloft-store/src/tasks.rs @@ -3,7 +3,7 @@ //! message that reports the outcome. use botloft_core::ids::{BotId, CrewId, TaskId}; -use botloft_core::protocol::{Delivery, Message, Task, TaskStatus}; +use botloft_core::protocol::{ChatItem, Delivery, Message, Task, TaskStatus}; use rusqlite::{Connection, OptionalExtension, Row, params}; use crate::messages::optional_column; @@ -118,8 +118,9 @@ impl Store { } /// Moves a task from one of `from` to `to`, keeps `result` when given - /// and saves the message that reports it, all or nothing. `None`, with - /// nothing saved, if the task was in no state of `from`. + /// and saves the message that reports it, all or nothing. Returns the + /// task and the recipient's chat item; `None`, with nothing saved, if + /// the task was in no state of `from`. pub fn settle_task( &self, id: &TaskId, @@ -128,7 +129,7 @@ impl Store { result: Option<&str>, now: i64, report: (&Message, &Delivery), - ) -> Result> { + ) -> Result> { let tx = self.conn.unchecked_transaction()?; let settled = tx .query_row( @@ -140,11 +141,12 @@ impl Store { from_row, ) .optional()?; - if settled.is_some() { - Self::insert_message_in(&tx, report.0, report.1)?; - tx.commit()?; - } - Ok(settled) + let Some(settled) = settled else { + return Ok(None); + }; + let item = Self::insert_message_in(&tx, report.0, report.1)?; + tx.commit()?; + Ok(Some((settled, item))) } } diff --git a/crates/botloft-store/src/tasks/tests.rs b/crates/botloft-store/src/tasks/tests.rs index f07be09b..f0cba9b0 100644 --- a/crates/botloft-store/src/tasks/tests.rs +++ b/crates/botloft-store/src/tasks/tests.rs @@ -71,7 +71,7 @@ fn settling_updates_the_task_and_saves_the_report_together() { let task = ask(&fx, 100); let from = [TaskStatus::Open, TaskStatus::Expired]; let (report, delivery) = message_to(&fx.crew.id, &fx.bots[0].id, "done: 42"); - let done = fx + let (done, item) = fx .store .settle_task( &task.id, @@ -83,6 +83,7 @@ fn settling_updates_the_task_and_saves_the_report_together() { ) .expect("settle") .expect("was open"); + assert_eq!(item.bot_id, report.to_bot_id); assert_eq!(done.status, TaskStatus::Done); assert_eq!(done.result.as_deref(), Some("42")); assert_eq!(done.updated_at, 500); diff --git a/crates/botloftd/Cargo.toml b/crates/botloftd/Cargo.toml index 4e27d49e..4c48caad 100644 --- a/crates/botloftd/Cargo.toml +++ b/crates/botloftd/Cargo.toml @@ -19,7 +19,6 @@ dirs = "7.0.0" futures-util = "0.3.34" getrandom = "0.4.3" hex = "0.4.3" -portable-pty = "0.9.0" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" sha2 = "0.11.0" diff --git a/crates/botloftd/src/approvals.rs b/crates/botloftd/src/approvals.rs new file mode 100644 index 00000000..0674a880 --- /dev/null +++ b/crates/botloftd/src/approvals.rs @@ -0,0 +1,284 @@ +//! Permission requests (spec 10.1). Claude Code calls the MCP tool +//! `permission_prompt`; the call waits here until the owner answers in the +//! chat, the request times out, or the bot's process ends. + +use std::collections::HashMap; +use std::sync::{Mutex, MutexGuard}; +use std::time::Duration; + +use botloft_core::chat::{TOOL_INPUT_MAX, clip, tool_summary}; +use botloft_core::ids::{ApprovalId, BotId, ChatItemId}; +use botloft_core::protocol::{ + Approval, ApprovalItem, ApprovalStatus, ApprovalsAnswerParams, ChatBody, ToolStatus, +}; +use botloft_store::ApprovalRecord; +use serde_json::{Value, json}; +use tokio::sync::oneshot; +use tracing::{debug, warn}; + +use crate::chat::items; +use crate::service::{ApiError, ApiResult}; +use crate::state::Daemon; + +/// How long to wait for the `tool_use` event that the request is about; +/// it can arrive a moment after the MCP call. +const MATCH_WINDOW: Duration = Duration::from_secs(2); +const MATCH_POLL: Duration = Duration::from_millis(50); + +/// Requests waiting for an answer, in memory: they cannot outlive the +/// daemon, since the bots' processes do not either. +#[derive(Default)] +pub struct Approvals { + waiting: Mutex>>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Decision { + allow: bool, + note: Option, +} + +impl Approvals { + fn lock(&self) -> MutexGuard<'_, HashMap>> { + self.waiting + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + } +} + +/// Arguments Claude Code sends to the permission tool. +#[derive(Debug, Clone, serde::Deserialize)] +pub struct PromptArgs { + pub tool_name: String, + #[serde(default)] + pub input: Value, + #[serde(default)] + pub tool_use_id: String, +} + +/// Handles one `permission_prompt` call and returns the text of the tool +/// result: `{"behavior": "allow" | "deny", ...}`. +pub async fn prompt(daemon: &Daemon, bot: &BotId, generation: u64, args: PromptArgs) -> String { + if !matches_running_tool(daemon, bot, &args.tool_use_id).await { + debug!(bot = %bot, "permission request for no running tool; denied"); + return deny("There is no pending tool call with that id."); + } + let Some(pending) = open(daemon, bot, &args) else { + return deny("Botloft could not ask the owner."); + }; + let (answer, waiting) = oneshot::channel(); + daemon + .approvals + .lock() + .insert(pending.record.approval.id.clone(), answer); + daemon.supervisor.approval_opened(bot, generation); + let mut guard = Guard { + daemon, + bot, + generation, + record: &pending.record, + settled: false, + }; + let decision = tokio::time::timeout(daemon.bots.approval_timeout, waiting).await; + guard.settled = true; + daemon.approvals.lock().remove(&pending.record.approval.id); + daemon.supervisor.approval_closed(bot, generation); + match decision { + Ok(Ok(Decision { allow: true, .. })) => { + json!({ "behavior": "allow", "updatedInput": args.input }).to_string() + } + Ok(Ok(Decision { allow: false, note })) => match note { + Some(note) => deny(&format!("The owner denied this: {note}")), + None => deny("The owner denied this."), + }, + // Timed out, or the answer was dropped (the process ended). + Ok(Err(_)) | Err(_) => { + settle(daemon, &pending.record, ApprovalStatus::Expired, None); + deny("The owner did not answer in time.") + } + } +} + +/// The owner's answer (`approvals.answer`). +pub fn answer(daemon: &Daemon, params: ApprovalsAnswerParams) -> ApiResult { + let note = params + .note + .map(|note| note.trim().to_owned()) + .filter(|note| !note.is_empty()); + let status = if params.allow { + ApprovalStatus::Allowed + } else { + ApprovalStatus::Denied + }; + let existing = daemon + .store() + .approval(¶ms.approval_id)? + .ok_or_else(|| ApiError::NotFound(format!("approval {}", params.approval_id)))?; + let Some(record) = settle(daemon, &existing, status, note.as_deref()) else { + return Err(ApiError::Conflict(format!( + "approval {} was already answered or expired", + params.approval_id + ))); + }; + if let Some(waiter) = daemon.approvals.lock().remove(¶ms.approval_id) { + let _ = waiter.send(Decision { + allow: params.allow, + note, + }); + } + Ok(record.approval) +} + +/// Expires the bot's open requests: its process is gone (spec 10.1). +pub fn expire_for_bot(daemon: &Daemon, bot: &BotId) { + let now = daemon.clock.now_ms(); + let expired = match daemon.store().expire_approvals(Some(bot), now) { + Ok(expired) => expired, + Err(err) => { + warn!(bot = %bot, "could not expire approvals: {err}"); + return; + } + }; + for record in expired { + daemon.approvals.lock().remove(&record.approval.id); + show(daemon, &record); + } +} + +/// Expires every open request, at daemon start: no process survived. +pub fn expire_all(daemon: &Daemon) { + let now = daemon.clock.now_ms(); + match daemon.store().expire_approvals(None, now) { + Ok(expired) if !expired.is_empty() => { + debug!( + count = expired.len(), + "expired approvals left by the last run" + ); + } + Ok(_) => {} + Err(err) => warn!("could not expire old approvals: {err}"), + } +} + +fn deny(message: &str) -> String { + json!({ "behavior": "deny", "message": message }).to_string() +} + +/// Whether the bot's chat shows `tool_use_id` running, waiting briefly for +/// the event if it has not been read yet. +async fn matches_running_tool(daemon: &Daemon, bot: &BotId, tool_use_id: &str) -> bool { + if tool_use_id.is_empty() { + return false; + } + let deadline = tokio::time::Instant::now() + MATCH_WINDOW; + loop { + let found = daemon.store().tool_item(bot, tool_use_id); + if let Ok(Some(item)) = found + && matches!(&item.body, ChatBody::Tool(tool) if tool.status == ToolStatus::Running) + { + return true; + } + if tokio::time::Instant::now() >= deadline { + return false; + } + tokio::time::sleep(MATCH_POLL).await; + } +} + +struct Pending { + record: ApprovalRecord, +} + +/// Saves the request and puts it in the chat. +fn open(daemon: &Daemon, bot: &BotId, args: &PromptArgs) -> Option { + let now = daemon.clock.now_ms(); + let approval = Approval { + id: ApprovalId::generate(), + bot_id: bot.clone(), + tool_name: args.tool_name.clone(), + summary: tool_summary(&args.tool_name, &args.input), + input: clip(&args.input.to_string(), TOOL_INPUT_MAX), + status: ApprovalStatus::Pending, + note: None, + created_at: now, + answered_at: None, + }; + let item = items::add(daemon, bot, ChatBody::Approval(item_of(&approval)))?; + let record = ApprovalRecord { + approval, + chat_item_id: item.id, + tool_use_id: args.tool_use_id.clone(), + }; + if let Err(err) = daemon.store().insert_approval(&record) { + warn!(bot = %bot, "could not save an approval: {err}"); + return None; + } + Some(Pending { record }) +} + +fn item_of(approval: &Approval) -> ApprovalItem { + ApprovalItem { + approval_id: approval.id.clone(), + tool_name: approval.tool_name.clone(), + summary: approval.summary.clone(), + input: approval.input.clone(), + status: approval.status, + note: approval.note.clone(), + } +} + +/// Settles a pending request and updates its chat item. `None` if it was +/// no longer pending. +fn settle( + daemon: &Daemon, + record: &ApprovalRecord, + status: ApprovalStatus, + note: Option<&str>, +) -> Option { + let now = daemon.clock.now_ms(); + let settled = daemon + .store() + .settle_approval(&record.approval.id, status, note, now); + match settled { + Ok(Some(settled)) => { + show(daemon, &settled); + Some(settled) + } + Ok(None) => None, + Err(err) => { + warn!(approval = %record.approval.id, "could not settle an approval: {err}"); + None + } + } +} + +fn show(daemon: &Daemon, record: &ApprovalRecord) { + let item: &ChatItemId = &record.chat_item_id; + items::update(daemon, item, ChatBody::Approval(item_of(&record.approval))); +} + +/// Expires the request if the MCP call goes away before it is settled, +/// e.g. Claude Code dropped the connection. +struct Guard<'a> { + daemon: &'a Daemon, + bot: &'a BotId, + generation: u64, + record: &'a ApprovalRecord, + settled: bool, +} + +impl Drop for Guard<'_> { + fn drop(&mut self) { + if self.settled { + return; + } + self.daemon + .approvals + .lock() + .remove(&self.record.approval.id); + settle(self.daemon, self.record, ApprovalStatus::Expired, None); + self.daemon + .supervisor + .approval_closed(self.bot, self.generation); + } +} diff --git a/crates/botloftd/src/chat/events.rs b/crates/botloftd/src/chat/events.rs new file mode 100644 index 00000000..0e571af3 --- /dev/null +++ b/crates/botloftd/src/chat/events.rs @@ -0,0 +1,270 @@ +//! What each stream-json event means for the chat and the bot's state +//! (spec 8.1). Fields are read defensively: the format is not documented +//! and grows between Claude Code versions. + +use botloft_core::chat::{TOOL_INPUT_MAX, TOOL_OUTPUT_MAX, clip, tool_summary}; +use botloft_core::ids::BotId; +use botloft_core::protocol::{ + AccountUsage, ChatBody, ChatDelta, NoticeItem, NoticeLevel, ReplyItem, ToolItem, ToolStatus, + TurnItem, UsageWindow, +}; +use serde_json::Value; +use tracing::{debug, warn}; + +use super::items; +use crate::state::{Daemon, Event}; + +/// Errors that restarting cannot fix: Claude Code needs the owner. +const SIGN_IN_ERRORS: &[&str] = &[ + "authentication_failed", + "oauth_org_not_allowed", + "billing_error", + "account_on_hold", +]; +/// When a rate limit gives no reset time. +const DEFAULT_LIMIT_MS: i64 = 5 * 60 * 1000; + +pub(super) fn apply(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { + // Lines from a process that was replaced say nothing about the new one. + if !daemon.supervisor.is_current(bot, generation) { + return; + } + let kind = event["type"].as_str().unwrap_or_default(); + let subtype = event["subtype"].as_str(); + // Subagents work inside a tool call; their traffic stays out of the chat. + let from_subagent = !event["parent_tool_use_id"].is_null(); + match kind { + "system" if subtype == Some("init") => session(daemon, bot, event), + "stream_event" if !from_subagent => delta(daemon, bot, event), + "assistant" if !from_subagent => assistant(daemon, bot, generation, event), + "user" if event["isReplay"].as_bool() == Some(true) => replay(daemon, event), + "user" if !from_subagent => tool_results(daemon, bot, event), + "rate_limit_event" => rate_limit(daemon, bot, generation, event), + "result" => result(daemon, bot, generation, event), + _ => debug!(bot = %bot, kind, subtype, "stream event not used"), + } +} + +/// Every turn starts with the session id; the next start resumes it. +fn session(daemon: &Daemon, bot: &BotId, event: &Value) { + let Some(session) = event["session_id"].as_str() else { + return; + }; + let store = daemon.store(); + let known = store.session_id(bot).ok().flatten(); + if known.as_deref() != Some(session) + && let Err(err) = store.set_session_id(bot, Some(session)) + { + warn!(bot = %bot, "could not save the session id: {err}"); + } + drop(store); + daemon.supervisor.remember_session(bot, session); +} + +fn delta(daemon: &Daemon, bot: &BotId, event: &Value) { + let inner = &event["event"]; + if inner["type"] != "content_block_delta" || inner["delta"]["type"] != "text_delta" { + return; + } + if let Some(text) = inner["delta"]["text"].as_str() + && !text.is_empty() + { + daemon.emit(Event::ChatDelta(ChatDelta { + bot_id: bot.clone(), + text: text.to_owned(), + })); + } +} + +fn blocks(event: &Value) -> &[Value] { + event["message"]["content"] + .as_array() + .map_or(&[], Vec::as_slice) +} + +fn assistant(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { + if let Some(error) = event["error"].as_str() { + failed_turn(daemon, bot, generation, error, event); + return; + } + for block in blocks(event) { + match block["type"].as_str() { + Some("text") => { + let text = block["text"].as_str().unwrap_or_default().trim(); + if !text.is_empty() { + items::add( + daemon, + bot, + ChatBody::Reply(ReplyItem { + text: text.to_owned(), + }), + ); + } + } + Some("tool_use") => { + let name = block["name"].as_str().unwrap_or("tool"); + let input = &block["input"]; + items::add( + daemon, + bot, + ChatBody::Tool(ToolItem { + tool_use_id: block["id"].as_str().unwrap_or_default().to_owned(), + name: name.to_owned(), + summary: tool_summary(name, input), + input: clip(&input.to_string(), TOOL_INPUT_MAX), + status: ToolStatus::Running, + output: None, + }), + ); + } + _ => {} + } + } +} + +/// An API error ended the turn: say so, and stop or pause the bot when +/// only the owner or time can fix it (spec 7.2). +fn failed_turn(daemon: &Daemon, bot: &BotId, generation: u64, error: &str, event: &Value) { + let detail = blocks(event) + .iter() + .filter_map(|block| block["text"].as_str()) + .collect::>() + .join(" "); + let text = if SIGN_IN_ERRORS.contains(&error) { + "Claude Code is not signed in or the account cannot be used. Sign in with \ + `claude auth login` in a terminal, then restart the bot." + .to_owned() + } else if error == "rate_limit" { + "The account reached its usage limit. Messages wait until it resets.".to_owned() + } else if detail.is_empty() { + format!("The turn failed: {error}.") + } else { + detail + }; + items::add( + daemon, + bot, + ChatBody::Notice(NoticeItem { + level: NoticeLevel::Error, + text, + }), + ); + if SIGN_IN_ERRORS.contains(&error) { + daemon.supervisor.signed_out(bot, generation); + } else if error == "rate_limit" { + let until = daemon + .usage() + .and_then(|usage| usage.resets_at) + .unwrap_or_else(|| daemon.clock.now_ms() + DEFAULT_LIMIT_MS); + daemon.supervisor.rate_limited(bot, generation, until); + } +} + +/// The bot began the turn for a message the courier wrote (spec 9.1). +fn replay(daemon: &Daemon, event: &Value) { + let Some(uuid) = event["uuid"].as_str() else { + return; + }; + let now = daemon.clock.now_ms(); + match daemon.store().mark_read(uuid, now) { + Ok(Some(delivery)) => daemon.emit(Event::DeliveryChanged(delivery)), + Ok(None) => {} + Err(err) => warn!("could not mark a delivery read: {err}"), + } +} + +fn tool_results(daemon: &Daemon, bot: &BotId, event: &Value) { + for block in blocks(event) { + if block["type"] != "tool_result" { + continue; + } + let Some(id) = block["tool_use_id"].as_str() else { + continue; + }; + let found = daemon.store().tool_item(bot, id); + let Ok(Some(item)) = found else { + continue; + }; + let ChatBody::Tool(tool) = item.body else { + continue; + }; + let failed = block["is_error"].as_bool() == Some(true); + let output = result_text(&block["content"]); + let body = ChatBody::Tool(ToolItem { + status: if failed { + ToolStatus::Failed + } else { + ToolStatus::Done + }, + output: (!output.is_empty()).then(|| clip(&output, TOOL_OUTPUT_MAX)), + ..tool + }); + items::update(daemon, &item.id, body); + } +} + +/// The text of a tool result: a string, or text blocks among others. +fn result_text(content: &Value) -> String { + match content { + Value::String(text) => text.clone(), + Value::Array(parts) => parts + .iter() + .filter_map(|part| part["text"].as_str()) + .collect::>() + .join("\n"), + _ => String::new(), + } +} + +fn rate_limit(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { + let info = &event["rate_limit_info"]; + let seconds = |value: &Value| value.as_i64().map(|s| s * 1000); + let mut windows: Vec = info["unifiedWindows"] + .as_object() + .map(|windows| { + windows + .iter() + .map(|(name, window)| UsageWindow { + name: name.clone(), + utilization: window["utilization"].as_f64().unwrap_or_default(), + resets_at: seconds(&window["resetsAt"]), + }) + .collect() + }) + .unwrap_or_default(); + windows.sort_by(|a, b| a.name.cmp(&b.name)); + let status = info["status"].as_str().unwrap_or("unknown").to_owned(); + let resets_at = seconds(&info["resetsAt"]); + let limited = !status.starts_with("allowed"); + daemon.set_usage(AccountUsage { + status, + resets_at, + windows, + observed_at: daemon.clock.now_ms(), + }); + if limited { + let until = resets_at.unwrap_or_else(|| daemon.clock.now_ms() + DEFAULT_LIMIT_MS); + daemon.supervisor.rate_limited(bot, generation, until); + } +} + +fn result(daemon: &Daemon, bot: &BotId, generation: u64, event: &Value) { + let failed = event["is_error"].as_bool() == Some(true); + let error = failed.then(|| { + event["terminal_reason"] + .as_str() + .or_else(|| event["subtype"].as_str()) + .unwrap_or("error") + .to_owned() + }); + items::add( + daemon, + bot, + ChatBody::Turn(TurnItem { + duration_ms: event["duration_ms"].as_u64().unwrap_or_default(), + cost_usd: event["total_cost_usd"].as_f64(), + error, + }), + ); + daemon.supervisor.turn_ended(bot, generation); +} diff --git a/crates/botloftd/src/chat/items.rs b/crates/botloftd/src/chat/items.rs new file mode 100644 index 00000000..2834fea7 --- /dev/null +++ b/crates/botloftd/src/chat/items.rs @@ -0,0 +1,55 @@ +//! Saving chat items and telling the app (spec 8.3). + +use botloft_core::chat::activity_line; +use botloft_core::ids::{BotId, ChatItemId}; +use botloft_core::protocol::{Activity, ChatBody, ChatItem, ChatItemChanged}; +use tracing::warn; + +use crate::state::{Daemon, Event}; + +/// Saves a new item for `bot` and announces it with the bot's new +/// conversation-list line. +pub(crate) fn add(daemon: &Daemon, bot: &BotId, body: ChatBody) -> Option { + let now = daemon.clock.now_ms(); + let item = ChatItem { + id: ChatItemId::generate(), + bot_id: bot.clone(), + body, + created_at: now, + updated_at: now, + }; + if let Err(err) = daemon.store().insert_chat_item(&item) { + warn!(bot = %bot, "could not save a chat item: {err}"); + return None; + } + announce(daemon, item.clone()); + Some(item) +} + +/// Announces an item that is already saved, e.g. the one a message created. +pub(crate) fn announce(daemon: &Daemon, item: ChatItem) { + let activity = activity_line(&item.body).map(|text| Activity { + text, + at: item.updated_at, + }); + daemon.emit(Event::ChatItem(ChatItemChanged { item, activity })); +} + +/// Replaces an item's body, e.g. a tool that finished, and announces it. +pub(crate) fn update(daemon: &Daemon, id: &ChatItemId, body: ChatBody) -> Option { + let now = daemon.clock.now_ms(); + match daemon.store().update_chat_item(id, &body, now) { + Ok(Some(item)) => { + daemon.emit(Event::ChatItem(ChatItemChanged { + item: item.clone(), + activity: None, + })); + Some(item) + } + Ok(None) => None, + Err(err) => { + warn!(item = %id, "could not update a chat item: {err}"); + None + } + } +} diff --git a/crates/botloftd/src/chat/mod.rs b/crates/botloftd/src/chat/mod.rs new file mode 100644 index 00000000..bca27b17 --- /dev/null +++ b/crates/botloftd/src/chat/mod.rs @@ -0,0 +1,77 @@ +//! A bot's chat (spec 8): reads the stream-json its process prints, line by +//! line, and turns each event into chat items, live text, read receipts and +//! state changes. + +mod events; +pub(crate) mod items; + +use botloft_core::ids::BotId; +use tracing::debug; + +use crate::state::Daemon; + +/// Longest line kept; anything longer is dropped whole (spec 8.1). +const LINE_MAX: usize = 8 << 20; + +/// Splits one process's stdout into lines. Lines may arrive split across +/// chunks, and one chunk may hold several. +pub struct StreamReader { + bot: BotId, + generation: u64, + buffer: Vec, + /// Inside a line that grew past [`LINE_MAX`]; ends at its newline. + skipping: bool, +} + +impl StreamReader { + pub fn new(bot: BotId, generation: u64) -> Self { + Self { + bot, + generation, + buffer: Vec::new(), + skipping: false, + } + } + + pub fn bot(&self) -> &BotId { + &self.bot + } + + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn feed(&mut self, daemon: &Daemon, data: &[u8]) { + let mut rest = data; + while let Some(end) = rest.iter().position(|byte| *byte == b'\n') { + if self.skipping { + self.skipping = false; + } else { + self.buffer.extend_from_slice(&rest[..end]); + self.line(daemon); + } + self.buffer.clear(); + rest = &rest[end + 1..]; + } + if self.skipping { + return; + } + self.buffer.extend_from_slice(rest); + if self.buffer.len() > LINE_MAX { + debug!(bot = %self.bot, "dropped a stream line longer than 8 MiB"); + self.buffer.clear(); + self.skipping = true; + } + } + + fn line(&self, daemon: &Daemon) { + let line = self.buffer.strip_suffix(b"\r").unwrap_or(&self.buffer); + if line.iter().all(u8::is_ascii_whitespace) { + return; + } + match serde_json::from_slice(line) { + Ok(event) => events::apply(daemon, &self.bot, self.generation, &event), + Err(_) => debug!(bot = %self.bot, "stream line is not JSON"), + } + } +} diff --git a/crates/botloftd/src/config.rs b/crates/botloftd/src/config.rs index fbd1c5c4..692a602b 100644 --- a/crates/botloftd/src/config.rs +++ b/crates/botloftd/src/config.rs @@ -18,7 +18,7 @@ pub struct Config { pub log_level: String, pub supervisor: SupervisorConfig, pub courier: CourierConfig, - pub terminal: TerminalConfig, + pub bots: BotsConfig, pub tasks: TasksConfig, } @@ -32,7 +32,7 @@ impl Default for Config { log_level: "info".to_owned(), supervisor: SupervisorConfig::default(), courier: CourierConfig::default(), - terminal: TerminalConfig::default(), + bots: BotsConfig::default(), tasks: TasksConfig::default(), } } @@ -80,14 +80,18 @@ impl Default for CourierConfig { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] #[serde(default, deny_unknown_fields)] -pub struct TerminalConfig { - pub ring_buffer_bytes: usize, +pub struct BotsConfig { + /// How long a permission request waits for the owner (spec 10.1). + pub approval_timeout_minutes: u64, + /// Largest attachment, per file (spec 9.5). + pub attachment_max_mb: u64, } -impl Default for TerminalConfig { +impl Default for BotsConfig { fn default() -> Self { Self { - ring_buffer_bytes: 1 << 20, + approval_timeout_minutes: 60, + attachment_max_mb: 20, } } } diff --git a/crates/botloftd/src/courier/fake.rs b/crates/botloftd/src/courier/fake.rs deleted file mode 100644 index bfef2698..00000000 --- a/crates/botloftd/src/courier/fake.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! An inbox that records what the courier posts instead of writing to a -//! pipe, for tests. It can fail on demand or hold writes until released. - -use std::io; -use std::sync::{Arc, Mutex, MutexGuard}; -use std::time::Duration; - -use bytes::Bytes; -use futures_util::future::BoxFuture; -use serde_json::Value; -use tokio::sync::{Notify, watch}; - -use super::inbox::InboxWriter; - -/// One message that reached an inbox. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Post { - pub address: String, - /// Token from the auth line. - pub token: String, - /// The envelope, as the bot reads it. - pub text: String, -} - -#[derive(Clone)] -pub struct FakeInbox { - state: Arc>, - posted: Arc, - held: watch::Sender, -} - -#[derive(Default)] -struct State { - posts: Vec, - failures: usize, - attempts: usize, -} - -impl Default for FakeInbox { - fn default() -> Self { - Self { - state: Arc::default(), - posted: Arc::default(), - held: watch::Sender::new(false), - } - } -} - -impl FakeInbox { - pub fn new() -> Self { - Self::default() - } - - fn lock(&self) -> MutexGuard<'_, State> { - self.state - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } - - pub fn posts(&self) -> Vec { - self.lock().posts.clone() - } - - /// Writes started so far, failed ones included. - pub fn attempts(&self) -> usize { - self.lock().attempts - } - - /// The `n`-th post (1-based), waiting up to 5 s for it. - pub async fn post(&self, n: usize) -> Post { - let wait = async { - loop { - let notified = self.posted.notified(); - tokio::pin!(notified); - notified.as_mut().enable(); - if let Some(post) = self.lock().posts.get(n - 1) { - return post.clone(); - } - notified.await; - } - }; - tokio::time::timeout(Duration::from_secs(5), wait) - .await - .unwrap_or_else(|_| panic!("post {n} never arrived")) - } - - /// Makes the next `n` writes fail, as a closed pipe would. - pub fn fail_next(&self, n: usize) { - self.lock().failures += n; - } - - /// Writes wait until [`FakeInbox::release`]. - pub fn hold(&self) { - self.held.send_replace(true); - } - - pub fn release(&self) { - self.held.send_replace(false); - } -} - -impl InboxWriter for FakeInbox { - fn write<'a>(&'a self, address: &'a str, payload: Bytes) -> BoxFuture<'a, io::Result<()>> { - Box::pin(async move { - self.lock().attempts += 1; - let mut held = self.held.subscribe(); - let _ = held.wait_for(|held| !held).await; - { - let mut state = self.lock(); - if state.failures > 0 { - state.failures -= 1; - return Err(io::Error::new(io::ErrorKind::BrokenPipe, "fake failure")); - } - let post = parse(address, &payload)?; - state.posts.push(post); - } - self.posted.notify_waiters(); - Ok(()) - }) - } -} - -/// Reads the two lines back, checking their shape like Claude Code would. -fn parse(address: &str, payload: &[u8]) -> io::Result { - let invalid = |what: &str| io::Error::new(io::ErrorKind::InvalidData, what.to_owned()); - let text = std::str::from_utf8(payload).map_err(|_| invalid("not UTF-8"))?; - let mut lines = text.split_terminator('\n'); - let mut next = || -> io::Result { - let line = lines.next().ok_or_else(|| invalid("missing line"))?; - serde_json::from_str(line).map_err(|_| invalid("not a JSON line")) - }; - let auth = next()?; - let message = next()?; - let field = |value: &Value, name: &str| value[name].as_str().map(str::to_owned); - if field(&auth, "type").as_deref() != Some("auth") - || field(&message, "type").as_deref() != Some("user") - { - return Err(invalid("unexpected line types")); - } - Ok(Post { - address: address.to_owned(), - token: field(&auth, "token").ok_or_else(|| invalid("no token"))?, - text: field(&message["message"], "content").ok_or_else(|| invalid("no content"))?, - }) -} diff --git a/crates/botloftd/src/courier/inbox.rs b/crates/botloftd/src/courier/inbox.rs deleted file mode 100644 index f630d3ce..00000000 --- a/crates/botloftd/src/courier/inbox.rs +++ /dev/null @@ -1,66 +0,0 @@ -//! How a rendered envelope reaches a bot: the two lines Claude Code reads -//! from an inbox connection (spec 9.2), and the writer that sends them. - -use std::io; - -use bytes::Bytes; -use futures_util::future::BoxFuture; -use serde_json::json; - -use crate::platform; - -/// Writes one payload to an inbox and closes the connection. -pub trait InboxWriter: Send + Sync + 'static { - fn write<'a>(&'a self, address: &'a str, payload: Bytes) -> BoxFuture<'a, io::Result<()>>; -} - -/// The real inbox: a named pipe on Windows, a Unix socket elsewhere. -#[derive(Debug, Clone, Copy, Default)] -pub struct PipeInbox; - -impl InboxWriter for PipeInbox { - fn write<'a>(&'a self, address: &'a str, payload: Bytes) -> BoxFuture<'a, io::Result<()>> { - Box::pin(async move { platform::write_inbox(address, &payload).await }) - } -} - -/// The auth line, then the message line, each ending in `\n`. Holds the -/// messaging token and the body: never log it. -pub fn payload(token: &str, envelope: &str) -> Bytes { - let auth = json!({ "type": "auth", "token": token }); - let message = json!({ - "type": "user", - "message": { "role": "user", "content": envelope }, - }); - Bytes::from(format!("{auth}\n{message}\n")) -} - -#[cfg(test)] -mod tests { - use serde_json::Value; - - use super::*; - - #[test] - fn the_payload_is_two_json_lines_auth_first() { - let payload = payload("tok", "[botloft] from the owner\n\nOlá, \"bot\""); - let text = std::str::from_utf8(&payload).expect("utf8"); - assert!(text.ends_with('\n')); - let lines: Vec = text - .lines() - .map(|line| serde_json::from_str(line).expect("json line")) - .collect(); - assert_eq!( - lines.len(), - 2, - "one line each; newlines in the body are escaped" - ); - assert_eq!(lines[0], json!({ "type": "auth", "token": "tok" })); - assert_eq!(lines[1]["type"], "user"); - assert_eq!(lines[1]["message"]["role"], "user"); - assert_eq!( - lines[1]["message"]["content"], - "[botloft] from the owner\n\nOlá, \"bot\"" - ); - } -} diff --git a/crates/botloftd/src/courier/mod.rs b/crates/botloftd/src/courier/mod.rs index 3224ce45..cd65a90d 100644 --- a/crates/botloftd/src/courier/mod.rs +++ b/crates/botloftd/src/courier/mod.rs @@ -1,44 +1,39 @@ -//! The courier (spec 9.1): moves pending deliveries into the bots' inboxes, -//! in order and one at a time per bot, retrying with backoff until they are -//! sent or give up. Each cycle also expires overdue tasks (spec 9.4). +//! The courier (spec 9.1): writes pending deliveries into the bots' stdin, +//! in order and one at a time per bot, and puts back what a process that +//! ended never began. Each cycle also expires overdue tasks (spec 9.4). -pub mod fake; -pub mod inbox; +mod render; mod settings; use std::sync::Arc; use std::time::Duration; -use botloft_core::envelope::{Envelope, Sender}; +use botloft_core::ids::BotId; use botloft_core::protocol::{BotState, Delivery, SenderKind}; use botloft_store::{DeliveryOutcome, Store, StoreError}; -use bytes::Bytes; use tokio::sync::Notify; use tracing::{debug, warn}; -pub use self::inbox::{InboxWriter, PipeInbox}; +use self::render::{Context, Rendered, render}; pub use self::settings::CourierSettings; use crate::clock; use crate::service::tasks; use crate::state::{Daemon, Event}; -use crate::supervisor::Inbox; /// How long to wait for a bot that cannot take messages yet (spec 9.1). const WAIT_FOR_BOT: Duration = Duration::from_secs(5); -/// One attempt: opening the pipe, waiting while it is busy and writing. -const SEND_TIMEOUT: Duration = Duration::from_secs(10); +/// Why a delivery goes back in the queue after its process ended. +const PROCESS_ENDED: &str = "the bot's process ended before it began this message"; pub struct Courier { settings: CourierSettings, - writer: Arc, wake: Notify, } impl Courier { - pub fn new(settings: CourierSettings, writer: Arc) -> Self { + pub fn new(settings: CourierSettings) -> Self { Self { settings, - writer, wake: Notify::new(), } } @@ -63,19 +58,13 @@ pub async fn run(daemon: Arc) { /// What to do with a due delivery. enum Step { - Send(Job), + Send(Rendered), /// The bot cannot take messages yet. Wait, /// It can never be delivered. Drop(&'static str), } -struct Job { - delivery: Delivery, - inbox: Inbox, - payload: Bytes, -} - fn cycle(daemon: &Arc) { let now = daemon.clock.now_ms(); let store = daemon.store(); @@ -97,46 +86,79 @@ fn cycle(daemon: &Arc) { } }; for delivery in due { - let step = match prepare(daemon, &store, &delivery, now) { - Ok(step) => step, + let changed = match prepare(daemon, &store, &delivery, now) { + Ok(Step::Send(rendered)) => send(daemon, &store, &delivery, &rendered, now), + Ok(Step::Wait) => { + let until = clock::after(now, WAIT_FOR_BOT); + finish(&store, &delivery, DeliveryOutcome::Defer { until }, now) + } + Ok(Step::Drop(error)) => { + finish(&store, &delivery, DeliveryOutcome::Dead { error }, now) + } Err(err) => { warn!(delivery = %delivery.id, "courier could not prepare a delivery: {err}"); continue; } }; - let finished = match step { - Step::Send(job) => { - let lease_until = clock::after(now, daemon.courier.settings.lease); - match store.claim_delivery(&job.delivery.id, now, lease_until) { - Ok(Some(sending)) => { - daemon.emit(Event::DeliveryChanged(sending)); - tokio::spawn(send(Arc::clone(daemon), job)); - } - Ok(None) => {} - Err(err) => warn!(delivery = %delivery.id, "courier could not claim: {err}"), - } - continue; - } - Step::Wait => { - let until = clock::after(now, WAIT_FOR_BOT); - store.finish_delivery(&delivery.id, DeliveryOutcome::Defer { until }, now) - } - Step::Drop(error) => { - store.finish_delivery(&delivery.id, DeliveryOutcome::Dead { error }, now) - } - }; - match finished { - Ok(Some(changed)) if changed.state != delivery.state => { - debug!(delivery = %changed.id, "delivery dropped"); - daemon.emit(Event::DeliveryChanged(changed)); - } - Ok(_) => {} - Err(err) => warn!(delivery = %delivery.id, "courier could not update: {err}"), + if let Some(changed) = changed.filter(|changed| changed.state != delivery.state) { + daemon.emit(Event::DeliveryChanged(changed)); + } + } +} + +fn finish( + store: &Store, + delivery: &Delivery, + outcome: DeliveryOutcome<'_>, + now: i64, +) -> Option { + store + .finish_delivery(&delivery.id, outcome, now) + .unwrap_or_else(|err| { + warn!(delivery = %delivery.id, "courier could not update: {err}"); + None + }) +} + +/// Claims the delivery, writes it and records how that went. `None` when +/// someone else claimed it first. +fn send( + daemon: &Daemon, + store: &Store, + delivery: &Delivery, + rendered: &Rendered, + now: i64, +) -> Option { + let lease_until = clock::after(now, daemon.courier.settings.lease); + match store.claim_delivery(&delivery.id, now, lease_until) { + Ok(Some(_)) => {} + Ok(None) => return None, + Err(err) => { + warn!(delivery = %delivery.id, "courier could not claim: {err}"); + return None; } } + let outcome = match daemon + .supervisor + .write_message(&delivery.bot_id, rendered.line.clone()) + { + Ok(generation) => { + debug!(delivery = %delivery.id, bot = %delivery.bot_id, generation, "delivery written"); + DeliveryOutcome::Sent { + generation, + turn_uuid: &rendered.uuid, + } + } + // The process went away between the check and the write: not this + // delivery's fault. + Err(_) => DeliveryOutcome::Defer { + until: clock::after(now, WAIT_FOR_BOT), + }, + }; + finish(store, delivery, outcome, now) } -/// Decides whether the delivery can go now and renders its envelope. +/// Decides whether the delivery can go now and renders it. fn prepare( daemon: &Daemon, store: &Store, @@ -159,9 +181,9 @@ fn prepare( daemon.supervisor.status(&bot.id), Some((BotState::Idle | BotState::Busy | BotState::NeedsApproval, _)) ); - let Some(inbox) = daemon.supervisor.inbox(&bot.id).filter(|_| ready) else { + if !ready { return Ok(Step::Wait); - }; + } let task = match &message.task_id { Some(id) => store.task(id)?, None => None, @@ -170,70 +192,44 @@ fn prepare( (SenderKind::Bot, Some(id)) => store.bot(id)?.map(|sender| sender.handle), _ => None, }; - let from = match message.from_kind { - SenderKind::Owner => Sender::Owner, - SenderKind::Bot => Sender::Bot { - handle: sender.as_deref().unwrap_or("unknown"), - }, - SenderKind::System => Sender::Botloft, - }; - let text = Envelope { - from, - crew: &crew.name, - kind: message.kind, + let workspace = daemon.paths.bot_workspace(&crew.slug, &bot.slug); + let context = Context { + crew_name: &crew.name, + sender_handle: sender.as_deref(), task: task.as_ref(), - body: &message.body, - } - .render(now); - Ok(Step::Send(Job { - delivery: delivery.clone(), - payload: inbox::payload(&inbox.token, &text), - inbox, - })) + workspace: &workspace, + }; + Ok(Step::Send(render(&message, &context, now))) } -/// One attempt, then the outcome in the database. -async fn send(daemon: Arc, job: Job) { - let courier = &daemon.courier; - let written = tokio::time::timeout( - SEND_TIMEOUT, - courier.writer.write(&job.inbox.socket, job.payload), - ) - .await; - let error = match written { - Ok(Ok(())) => None, - Ok(Err(err)) => Some(err.to_string()), - Err(_) => Some("timed out writing to the inbox".to_owned()), - }; +/// The process of `generation` ended: what it never began goes back in +/// the queue, counting an attempt (spec 9.1). +pub fn requeue_unread(daemon: &Daemon, bot: &BotId, generation: u64) { let now = daemon.clock.now_ms(); - let delivery = &job.delivery; - let outcome = match &error { - None => DeliveryOutcome::Sent, - // The bot restarted meanwhile: its old inbox is gone, which says - // nothing about this delivery. Try the new one as soon as it is up. - Some(_) if daemon.supervisor.inbox(&delivery.bot_id).as_ref() != Some(&job.inbox) => { - DeliveryOutcome::Defer { until: now } - } - Some(error) => { - let attempts = delivery.attempts + 1; - let retry_at = (attempts < courier.settings.max_attempts) - .then(|| clock::after(now, courier.settings.retry_delay(attempts))); - DeliveryOutcome::Failed { error, retry_at } + let store = daemon.store(); + let unread = match store.unread_deliveries(bot, generation) { + Ok(unread) => unread, + Err(err) => { + warn!(bot = %bot, "could not read unread deliveries: {err}"); + return; } }; - if let Some(error) = &error { - debug!(delivery = %delivery.id, bot = %delivery.bot_id, "delivery attempt failed: {error}"); - } - let finished = daemon.store().finish_delivery(&delivery.id, outcome, now); - match finished { - Ok(Some(changed)) => { - if let DeliveryOutcome::Failed { retry_at: None, .. } = outcome { - warn!(delivery = %changed.id, bot = %changed.bot_id, "gave up on a delivery"); + let settings = &daemon.courier.settings; + for delivery in unread { + let attempts = delivery.attempts + 1; + let retry_at = (attempts < settings.max_attempts) + .then(|| clock::after(now, settings.retry_delay(attempts))); + match store.reopen_unread(&delivery.id, PROCESS_ENDED, retry_at, now) { + Ok(Some(changed)) => { + if retry_at.is_none() { + warn!(delivery = %changed.id, bot = %bot, "gave up on a delivery"); + } + daemon.emit(Event::DeliveryChanged(changed)); } - daemon.emit(Event::DeliveryChanged(changed)); + Ok(None) => {} + Err(err) => warn!(delivery = %delivery.id, "could not requeue: {err}"), } - Ok(None) => {} - Err(err) => warn!(delivery = %delivery.id, "courier could not record a send: {err}"), } - courier.wake(); + drop(store); + daemon.courier.wake(); } diff --git a/crates/botloftd/src/courier/render.rs b/crates/botloftd/src/courier/render.rs new file mode 100644 index 00000000..98dfd6a3 --- /dev/null +++ b/crates/botloftd/src/courier/render.rs @@ -0,0 +1,207 @@ +//! The line a message becomes on the bot's stdin (spec 9.2 and 9.3). + +use std::path::Path; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use botloft_core::envelope::{Envelope, Sender}; +use botloft_core::ids::random_uuid; +use botloft_core::protocol::{Attachment, Message, SenderKind, Task}; +use bytes::Bytes; +use serde_json::{Value, json}; +use tracing::debug; + +/// Images the API accepts inline (spec 9.5). +const INLINE_IMAGES: &[&str] = &["image/png", "image/jpeg", "image/gif", "image/webp"]; +const INLINE_IMAGE_MAX: u64 = 5 * 1024 * 1024; + +/// Who and where a message comes from, for the envelope. +pub(super) struct Context<'a> { + pub crew_name: &'a str, + /// Handle of the sending bot, for bot messages. + pub sender_handle: Option<&'a str>, + pub task: Option<&'a Task>, + /// The recipient's workspace, where attachments were saved. + pub workspace: &'a Path, +} + +pub(super) struct Rendered { + /// One stream-json line, with its newline. + pub line: Bytes, + /// Goes with the line; Claude Code gives it back when the turn begins. + pub uuid: String, +} + +pub(super) fn render(message: &Message, context: &Context<'_>, now: i64) -> Rendered { + let text = match message.from_kind { + // The owner is the session's user: their words go as written. + SenderKind::Owner => with_attachment_list(&message.body, &message.attachments), + SenderKind::Bot => envelope( + message, + context, + Sender::Bot { + handle: context.sender_handle.unwrap_or("unknown"), + }, + now, + ), + SenderKind::System => envelope(message, context, Sender::Botloft, now), + }; + let mut content = vec![json!({ "type": "text", "text": text })]; + content.extend( + message + .attachments + .iter() + .filter_map(|attachment| inline_image(attachment, context.workspace)), + ); + let uuid = random_uuid(); + let event = json!({ + "type": "user", + "uuid": uuid, + "message": { "role": "user", "content": content }, + }); + let mut line = event.to_string().into_bytes(); + line.push(b'\n'); + Rendered { + line: Bytes::from(line), + uuid, + } +} + +fn envelope(message: &Message, context: &Context<'_>, from: Sender<'_>, now: i64) -> String { + Envelope { + from, + crew: context.crew_name, + kind: message.kind, + task: context.task, + body: &message.body, + } + .render(now) +} + +/// The body, then where each attachment was saved (spec 9.5). +fn with_attachment_list(body: &str, attachments: &[Attachment]) -> String { + if attachments.is_empty() { + return body.to_owned(); + } + let paths: Vec<&str> = attachments.iter().map(|a| a.path.as_str()).collect(); + format!( + "{body}\n\nAttached files, saved in your folder: {}", + paths.join(", ") + ) +} + +/// An image block for a small enough image; `None` for anything else or +/// a file that cannot be read. +fn inline_image(attachment: &Attachment, workspace: &Path) -> Option { + if !INLINE_IMAGES.contains(&attachment.media_type.as_str()) + || attachment.size > INLINE_IMAGE_MAX + { + return None; + } + match std::fs::read(workspace.join(&attachment.path)) { + Ok(bytes) => Some(json!({ + "type": "image", + "source": { + "type": "base64", + "media_type": attachment.media_type, + "data": BASE64.encode(bytes), + }, + })), + Err(err) => { + debug!(attachment = %attachment.id, "attachment not inlined: {err}"); + None + } + } +} + +#[cfg(test)] +mod tests { + use botloft_core::ids::{AttachmentId, BotId, CrewId, MessageId}; + use botloft_core::protocol::MessageKind; + + use super::*; + + fn message(from_kind: SenderKind, body: &str) -> Message { + Message { + id: MessageId::generate(), + crew_id: CrewId::generate(), + from_kind, + from_bot_id: None, + to_bot_id: BotId::generate(), + kind: MessageKind::Note, + body: body.into(), + task_id: None, + attachments: Vec::new(), + created_at: 0, + } + } + + fn parse(rendered: &Rendered) -> Value { + assert!(rendered.line.ends_with(b"\n")); + serde_json::from_slice(&rendered.line).expect("json line") + } + + #[test] + fn the_owner_speaks_as_the_user_and_bots_come_in_an_envelope() { + let dir = tempfile::tempdir().expect("tempdir"); + let context = Context { + crew_name: "Ops", + sender_handle: Some("scout"), + task: None, + workspace: dir.path(), + }; + let owner = render(&message(SenderKind::Owner, "Ship it"), &context, 0); + let line = parse(&owner); + assert_eq!(line["type"], "user"); + assert_eq!(line["uuid"], owner.uuid); + assert_eq!(line["message"]["content"][0]["text"], "Ship it"); + + let bot = parse(&render(&message(SenderKind::Bot, "hi"), &context, 0)); + let text = bot["message"]["content"][0]["text"].as_str().expect("text"); + assert!( + text.starts_with("[botloft] from @scout · crew Ops"), + "{text}" + ); + assert!(text.ends_with("hi")); + } + + #[test] + fn attachments_are_listed_and_small_images_go_inline() { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::create_dir_all(dir.path().join("attachments")).expect("dir"); + std::fs::write(dir.path().join("attachments/shot.png"), b"png bytes").expect("png"); + let mut owner = message(SenderKind::Owner, "Look"); + owner.attachments = vec![ + Attachment { + id: AttachmentId::generate(), + name: "shot.png".into(), + media_type: "image/png".into(), + size: 9, + path: "attachments/shot.png".into(), + }, + Attachment { + id: AttachmentId::generate(), + name: "notes.pdf".into(), + media_type: "application/pdf".into(), + size: 100, + path: "attachments/notes.pdf".into(), + }, + ]; + let context = Context { + crew_name: "Ops", + sender_handle: None, + task: None, + workspace: dir.path(), + }; + let line = parse(&render(&owner, &context, 0)); + let content = line["message"]["content"].as_array().expect("blocks"); + assert_eq!(content.len(), 2, "text and the one image"); + assert_eq!( + content[0]["text"], + "Look\n\nAttached files, saved in your folder: attachments/shot.png, attachments/notes.pdf" + ); + assert_eq!(content[1]["type"], "image"); + assert_eq!(content[1]["source"]["media_type"], "image/png"); + assert_eq!(content[1]["source"]["data"], BASE64.encode(b"png bytes")); + } +} diff --git a/crates/botloftd/src/hooks/client.rs b/crates/botloftd/src/hooks/client.rs deleted file mode 100644 index ef98fcb9..00000000 --- a/crates/botloftd/src/hooks/client.rs +++ /dev/null @@ -1,164 +0,0 @@ -//! `botloftd hook `, which Claude Code runs for every hook (spec 7.6). -//! -//! It always exits 0 and never writes to stdout: a SessionStart hook's -//! stdout becomes part of Claude's context. Failures go to -//! `\logs\hook.log`, without the token. - -use std::io::{self, BufRead, BufReader, Read, Write}; -use std::net::{Ipv4Addr, SocketAddr, TcpStream}; -use std::path::PathBuf; -use std::time::Duration; - -use serde_json::Value; - -use super::{EVENTS, HookRequest}; - -const TIMEOUT: Duration = Duration::from_secs(3); -const MAX_STDIN: u64 = 1 << 20; - -/// Entry point of the subcommand. Never fails. -pub fn run(event: &str) { - if let Err(err) = forward(event, &mut io::stdin().take(MAX_STDIN)) { - log_failure(event, &err); - } -} - -fn forward(event: &str, stdin: &mut impl Read) -> Result<(), String> { - if !EVENTS.iter().any(|(_, arg)| *arg == event) { - return Err(format!("unknown hook event `{event}`")); - } - let token = var("BOTLOFT_BOT_TOKEN")?; - let port: u16 = var("BOTLOFT_PORT")? - .parse() - .map_err(|_| "BOTLOFT_PORT is not a port number".to_owned())?; - - let mut input = Vec::new(); - stdin - .read_to_end(&mut input) - .map_err(|err| format!("cannot read stdin: {err}"))?; - let mut request = HookRequest { - payload: serde_json::from_slice(&input).unwrap_or(Value::Null), - ..HookRequest::default() - }; - if event == "session-start" { - request.messaging_socket = std::env::var("CLAUDE_CODE_MESSAGING_SOCKET").ok(); - request.messaging_token = std::env::var("CLAUDE_CODE_MESSAGING_TOKEN").ok(); - } - let body = serde_json::to_vec(&request).map_err(|err| err.to_string())?; - post(port, &format!("/hooks/{event}"), &token, &body) -} - -fn var(name: &str) -> Result { - std::env::var(name) - .map_err(|_| format!("{name} is not set; is this running inside a Botloft bot?")) -} - -/// A minimal HTTP/1.1 POST to the daemon on 127.0.0.1. -pub fn post(port: u16, path: &str, token: &str, body: &[u8]) -> Result<(), String> { - let fail = |step: &str, err: io::Error| format!("{step} 127.0.0.1:{port} failed: {err}"); - let addr = SocketAddr::from((Ipv4Addr::LOCALHOST, port)); - let mut stream = - TcpStream::connect_timeout(&addr, TIMEOUT).map_err(|e| fail("connecting to", e))?; - stream - .set_read_timeout(Some(TIMEOUT)) - .map_err(|e| fail("configuring", e))?; - stream - .set_write_timeout(Some(TIMEOUT)) - .map_err(|e| fail("configuring", e))?; - let head = format!( - "POST {path} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {token}\r\n\ - Content-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - body.len() - ); - stream - .write_all(head.as_bytes()) - .and_then(|()| stream.write_all(body)) - .map_err(|e| fail("writing to", e))?; - let mut status = String::new(); - BufReader::new(stream) - .read_line(&mut status) - .map_err(|e| fail("reading from", e))?; - match status.split_whitespace().nth(1) { - Some(code) if code.starts_with('2') => Ok(()), - Some(code) => Err(format!("the daemon answered {code} to {path}")), - None => Err(format!("the daemon closed the connection on {path}")), - } -} - -fn log_failure(event: &str, err: &str) { - let Some(home) = std::env::var_os("BOTLOFT_HOME") else { - return; - }; - let path = PathBuf::from(home).join("logs").join("hook.log"); - let line = format!("{} hook {event}: {err}\n", botloft_core::now_ms()); - let _ = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(path) - .and_then(|mut file| file.write_all(line.as_bytes())); -} - -#[cfg(test)] -mod tests { - use std::net::TcpListener; - - use super::*; - - /// Serves one request, answers `status` and returns what it received. - fn one_request(status: &'static str) -> (u16, std::thread::JoinHandle) { - let listener = TcpListener::bind("127.0.0.1:0").expect("bind"); - let port = listener.local_addr().expect("addr").port(); - let server = std::thread::spawn(move || { - let (mut stream, _) = listener.accept().expect("accept"); - stream - .set_read_timeout(Some(Duration::from_millis(500))) - .expect("timeout"); - let mut received = Vec::new(); - let mut buf = [0u8; 4096]; - while let Ok(n) = stream.read(&mut buf) { - if n == 0 { - break; - } - received.extend_from_slice(&buf[..n]); - if received.windows(4).any(|w| w == b"\r\n\r\n") && received.ends_with(b"}") { - break; - } - } - stream - .write_all(format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\n\r\n").as_bytes()) - .expect("answer"); - String::from_utf8_lossy(&received).into_owned() - }); - (port, server) - } - - #[test] - fn posts_the_payload_with_the_bot_token() { - let (port, server) = one_request("204 No Content"); - post(port, "/hooks/stop", "tok", br#"{"payload":{}}"#).expect("post"); - let request = server.join().expect("server"); - assert!( - request.starts_with("POST /hooks/stop HTTP/1.1\r\n"), - "{request}" - ); - assert!(request.contains("Authorization: Bearer tok\r\n")); - assert!(request.ends_with(r#"{"payload":{}}"#)); - } - - #[test] - fn a_refusal_is_an_error_without_the_token() { - let (port, server) = one_request("401 Unauthorized"); - let err = post(port, "/hooks/stop", "secret-token", b"{}").expect_err("refused"); - server.join().expect("server"); - assert!( - err.contains("401") && !err.contains("secret-token"), - "{err}" - ); - } - - #[test] - fn unknown_events_are_rejected_before_any_network_call() { - let err = forward("explode", &mut io::empty()).expect_err("unknown"); - assert!(err.contains("explode")); - } -} diff --git a/crates/botloftd/src/hooks/mod.rs b/crates/botloftd/src/hooks/mod.rs deleted file mode 100644 index 11838f13..00000000 --- a/crates/botloftd/src/hooks/mod.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! Claude Code hooks (spec 7.5 and 7.6). Each bot runs `botloftd hook -//! ` (see [`client`]), which forwards the hook to `POST /hooks/` -//! with the bot's token; [`handle`] turns it into a supervisor [`Hook`]. - -pub mod client; - -use std::sync::Arc; - -use axum::body::Bytes; -use axum::extract::{Path, State}; -use axum::http::{HeaderMap, StatusCode, header}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use crate::state::Daemon; -use crate::supervisor::{Hook, Inbox}; - -/// Hook events registered in each bot's settings, with the argument -/// `botloftd hook` receives for each. -pub const EVENTS: &[(&str, &str)] = &[ - ("SessionStart", "session-start"), - ("UserPromptSubmit", "prompt-submit"), - ("Stop", "stop"), - ("StopFailure", "stop-failure"), - ("Notification", "notification"), - ("SessionEnd", "session-end"), -]; - -/// Body of `POST /hooks/`. -#[derive(Default, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HookRequest { - /// The JSON Claude Code wrote to the hook's stdin. - #[serde(default)] - pub payload: Value, - /// `CLAUDE_CODE_MESSAGING_SOCKET`, sent by `session-start` only. - #[serde(default)] - pub messaging_socket: Option, - /// `CLAUDE_CODE_MESSAGING_TOKEN`, sent by `session-start` only. - #[serde(default)] - pub messaging_token: Option, -} - -/// Reads the payload fields Claude Code documents for each event. -pub fn parse(event: &str, request: HookRequest) -> Option { - let field = |name: &str| { - request - .payload - .get(name) - .and_then(Value::as_str) - .unwrap_or_default() - .to_owned() - }; - Some(match event { - "session-start" => Hook::SessionStart { - inbox: match (request.messaging_socket, request.messaging_token) { - (Some(socket), Some(token)) if !socket.is_empty() && !token.is_empty() => { - Some(Inbox { socket, token }) - } - _ => None, - }, - }, - "prompt-submit" => Hook::PromptSubmit, - "stop" => Hook::Stop, - "stop-failure" => Hook::StopFailure { - error: field("error"), - }, - "notification" => Hook::Notification { - kind: field("notification_type"), - }, - "session-end" => Hook::SessionEnd, - _ => return None, - }) -} - -/// `POST /hooks/{event}`. Only the token of a running generation is valid. -pub async fn handle( - State(daemon): State>, - Path(event): Path, - headers: HeaderMap, - body: Bytes, -) -> StatusCode { - let token = headers - .get(header::AUTHORIZATION) - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")); - let Some((bot, generation)) = token.and_then(|token| daemon.supervisor.hook_owner(token)) - else { - return StatusCode::UNAUTHORIZED; - }; - let request = if body.is_empty() { - HookRequest::default() - } else { - match serde_json::from_slice(&body) { - Ok(request) => request, - Err(_) => return StatusCode::BAD_REQUEST, - } - }; - let Some(hook) = parse(&event, request) else { - return StatusCode::NOT_FOUND; - }; - daemon.supervisor.on_hook(&bot, generation, hook); - StatusCode::NO_CONTENT -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - fn request(payload: Value) -> HookRequest { - HookRequest { - payload, - ..HookRequest::default() - } - } - - #[test] - fn reads_the_documented_payload_fields() { - let failure = request(json!({ "hook_event_name": "StopFailure", "error": "rate_limit" })); - assert_eq!( - parse("stop-failure", failure), - Some(Hook::StopFailure { - error: "rate_limit".into() - }) - ); - let note = request(json!({ "notification_type": "permission_prompt", "message": "x" })); - assert_eq!( - parse("notification", note), - Some(Hook::Notification { - kind: "permission_prompt".into() - }) - ); - assert_eq!( - parse("prompt-submit", request(Value::Null)), - Some(Hook::PromptSubmit) - ); - assert_eq!(parse("bogus", request(Value::Null)), None); - } - - #[test] - fn session_start_carries_the_inbox_when_both_parts_are_there() { - let full = HookRequest { - payload: json!({ "source": "startup" }), - messaging_socket: Some(r"\\.\pipe\claude-1".into()), - messaging_token: Some("t".into()), - }; - let Some(Hook::SessionStart { inbox: Some(inbox) }) = parse("session-start", full) else { - panic!("expected an inbox"); - }; - assert_eq!(inbox.socket, r"\\.\pipe\claude-1"); - let partial = HookRequest { - messaging_socket: Some("s".into()), - ..HookRequest::default() - }; - assert_eq!( - parse("session-start", partial), - Some(Hook::SessionStart { inbox: None }) - ); - } - - #[test] - fn every_event_has_a_parser() { - for (_, arg) in EVENTS { - assert!(parse(arg, HookRequest::default()).is_some(), "{arg}"); - } - } -} diff --git a/crates/botloftd/src/lib.rs b/crates/botloftd/src/lib.rs index e6bee723..8ab4a722 100644 --- a/crates/botloftd/src/lib.rs +++ b/crates/botloftd/src/lib.rs @@ -2,10 +2,11 @@ //! serves the app over JSON-RPC on 127.0.0.1. The binary in `main.rs` only //! parses the command line and wires these modules together. +pub mod approvals; +pub mod chat; pub mod clock; pub mod config; pub mod courier; -pub mod hooks; pub mod logging; pub mod paths; pub mod platform; @@ -16,6 +17,5 @@ pub mod server; pub mod service; pub mod state; pub mod supervisor; -pub mod terminal; pub mod tools; pub mod workspace; diff --git a/crates/botloftd/src/main.rs b/crates/botloftd/src/main.rs index c07a9ba0..2b4aff96 100644 --- a/crates/botloftd/src/main.rs +++ b/crates/botloftd/src/main.rs @@ -8,14 +8,14 @@ use anyhow::Context; use botloft_store::Store; use botloftd::clock::SystemClock; use botloftd::config::Config; -use botloftd::courier::{self, CourierSettings, PipeInbox}; +use botloftd::courier::{self, CourierSettings}; use botloftd::paths::{self, Paths}; use botloftd::platform::{self, InstanceLock}; -use botloftd::runtime::PtyRuntime; +use botloftd::runtime::PipeRuntime; use botloftd::service::tasks::TaskSettings; -use botloftd::state::{Daemon, DaemonOptions}; +use botloftd::state::{BotSettings, Daemon, DaemonOptions}; use botloftd::supervisor::{self, SupervisorSettings}; -use botloftd::{hooks, logging, secrets, server}; +use botloftd::{approvals, logging, secrets, server}; use clap::{Parser, Subcommand}; use tokio::net::TcpListener; use tracing::info; @@ -35,23 +35,11 @@ enum Command { #[arg(long)] config: Option, }, - /// Forward a Claude Code hook to the daemon (run by the bots' settings). - #[command(hide = true)] - Hook { - /// session-start, prompt-submit, stop, stop-failure, notification or session-end. - event: String, - }, } fn main() -> anyhow::Result<()> { match Cli::parse().command { Command::Serve { config } => serve(config), - Command::Hook { event } => { - // Always exit 0, even on a panic: a failing hook must never get - // in the way of the bot (spec 7.6). - let _ = std::panic::catch_unwind(|| hooks::client::run(&event)); - std::process::exit(0); - } } } @@ -72,22 +60,22 @@ fn serve(config_path: Option) -> anyhow::Result<()> { let store = Store::open(&paths.db()).context("cannot open the database")?; std::fs::create_dir_all(&paths.workspaces_root) .with_context(|| format!("cannot create {}", paths.workspaces_root.display()))?; - let bin = std::env::current_exe().context("cannot find the botloftd executable")?; info!(home = %paths.home.display(), workspaces = %paths.workspaces_root.display(), "starting"); let daemon = Daemon::new(DaemonOptions { paths, port: config.port, - bin, store, owner_token, - runtime: Arc::new(PtyRuntime), + runtime: Arc::new(PipeRuntime), supervisor: SupervisorSettings::from_config(&config), clock: Arc::new(SystemClock), - inbox: Arc::new(PipeInbox), courier: CourierSettings::from_config(&config), tasks: TaskSettings::from_config(&config), + bots: BotSettings::from_config(&config), }); + // No bot process survived the last run, so nobody waits for these. + approvals::expire_all(&daemon); let runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() diff --git a/crates/botloftd/src/platform/mod.rs b/crates/botloftd/src/platform/mod.rs index 2e2fdb37..085e696e 100644 --- a/crates/botloftd/src/platform/mod.rs +++ b/crates/botloftd/src/platform/mod.rs @@ -11,9 +11,9 @@ use std::io; use std::path::Path; #[cfg(unix)] -pub use unix::{ProcessJob, restrict_to_current_user, user_environment, write_inbox}; +pub use unix::{ProcessJob, restrict_to_current_user, user_environment}; #[cfg(windows)] -pub use windows::{ProcessJob, restrict_to_current_user, user_environment, write_inbox}; +pub use windows::{ProcessJob, restrict_to_current_user, user_environment}; /// Exclusive lock on `\botloftd.lock`, held for the daemon's lifetime. /// The OS releases it when the process exits, even after a crash. diff --git a/crates/botloftd/src/platform/unix.rs b/crates/botloftd/src/platform/unix.rs index 048f1054..bf65cfca 100644 --- a/crates/botloftd/src/platform/unix.rs +++ b/crates/botloftd/src/platform/unix.rs @@ -42,15 +42,6 @@ impl ProcessJob { } } -/// Writes `payload` to the Unix socket Claude Code serves as the inbox. -pub async fn write_inbox(address: &str, payload: &[u8]) -> io::Result<()> { - use tokio::io::AsyncWriteExt; - - let mut stream = tokio::net::UnixStream::connect(address).await?; - stream.write_all(payload).await?; - stream.shutdown().await -} - /// The daemon's environment without Claude Code session variables. pub fn user_environment() -> io::Result> { Ok(std::env::vars_os() diff --git a/crates/botloftd/src/platform/windows.rs b/crates/botloftd/src/platform/windows.rs index 9de17d25..ade501df 100644 --- a/crates/botloftd/src/platform/windows.rs +++ b/crates/botloftd/src/platform/windows.rs @@ -1,13 +1,10 @@ -//! Windows ACLs, Job Objects, the user environment, inbox pipes and console -//! signals. +//! Windows ACLs, Job Objects, the user environment and console signals. mod env; mod job; -mod pipe; pub use env::user_environment; pub use job::ProcessJob; -pub use pipe::write_inbox; use std::io; use std::os::windows::ffi::OsStrExt; diff --git a/crates/botloftd/src/platform/windows/pipe.rs b/crates/botloftd/src/platform/windows/pipe.rs deleted file mode 100644 index a578e8f2..00000000 --- a/crates/botloftd/src/platform/windows/pipe.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! Writing to a Claude Code inbox, a named pipe the bot's own Claude Code -//! process serves (spec 9.2). The daemon is the client. - -use std::io; -use std::time::Duration; - -use tokio::io::AsyncWriteExt; -use tokio::net::windows::named_pipe::ClientOptions; -use tokio::time::Instant; -use windows::Win32::Foundation::ERROR_PIPE_BUSY; - -/// While every instance of the pipe is taken, try again this often... -const BUSY_RETRY: Duration = Duration::from_millis(50); -/// ...for this long, within one delivery attempt. -const BUSY_FOR: Duration = Duration::from_secs(2); - -/// Opens the pipe at `address`, writes `payload` and closes it. What was -/// written stays readable for Claude Code after the close. -pub async fn write_inbox(address: &str, payload: &[u8]) -> io::Result<()> { - if !is_pipe_name(address) { - // The address comes from a bot's hook. Anything but a plain pipe - // name could point `CreateFile` at a real file. - return Err(io::Error::new( - io::ErrorKind::InvalidInput, - "the inbox address is not a named pipe", - )); - } - let give_up = Instant::now() + BUSY_FOR; - let mut pipe = loop { - match ClientOptions::new().open(address) { - Ok(pipe) => break pipe, - Err(err) - if err.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) - && Instant::now() < give_up => - { - tokio::time::sleep(BUSY_RETRY).await; - } - Err(err) => return Err(err), - } - }; - pipe.write_all(payload).await?; - pipe.flush().await -} - -/// `\\.\pipe\` followed by plain name segments: no `.` or `..` segment, -/// which Windows would resolve to a path outside the pipe namespace. -fn is_pipe_name(address: &str) -> bool { - const PREFIX: &str = r"\\.\pipe\"; - let Some(name) = address - .get(..PREFIX.len()) - .filter(|prefix| prefix.eq_ignore_ascii_case(PREFIX)) - .and_then(|_| address.get(PREFIX.len()..)) - else { - return false; - }; - !name.is_empty() - && name.split('\\').all(|segment| { - !segment.is_empty() - && segment != "." - && segment != ".." - && segment - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')) - }) -} - -#[cfg(test)] -mod tests { - use tokio::io::AsyncReadExt; - use tokio::net::windows::named_pipe::ServerOptions; - - use super::*; - - fn unique_name() -> String { - format!( - r"\\.\pipe\LOCAL\botloft-test-{}", - botloft_core::ids::MessageId::generate().as_str() - ) - } - - #[test] - fn only_plain_pipe_names_are_accepted() { - assert!(is_pipe_name( - r"\\.\pipe\LOCAL\cc-msg-0123456789abcdef0123456789abcdef" - )); - assert!(is_pipe_name(r"\\.\PIPE\claude.1")); - for bad in [ - r"C:\Users\ana\notes.txt", - r"\\.\pipe\", - r"\\.\pipe\..\C:\Windows\win.ini", - r"\\.\pipe\LOCAL\..\x", - r"\\.\pipe\a/b", - r"\\.\pipe\a:b", - r"\\?\pipe\x", - ] { - assert!(!is_pipe_name(bad), "{bad}"); - } - } - - #[tokio::test] - async fn writes_everything_and_closes() { - let name = unique_name(); - let mut server = ServerOptions::new() - .first_pipe_instance(true) - .create(&name) - .expect("server"); - let reader = tokio::spawn(async move { - server.connect().await.expect("connect"); - let mut received = Vec::new(); - server.read_to_end(&mut received).await.expect("read"); - received - }); - write_inbox(&name, b"line 1\nline 2\n") - .await - .expect("write"); - assert_eq!(reader.await.expect("reader"), b"line 1\nline 2\n"); - } - - #[tokio::test] - async fn waits_while_the_pipe_is_busy() { - let name = unique_name(); - let first = ServerOptions::new() - .first_pipe_instance(true) - .create(&name) - .expect("server"); - // Someone else holds the only instance. - let _other = ClientOptions::new().open(&name).expect("other client"); - let name_for_server = name.clone(); - let late = tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(150)).await; - let mut second = ServerOptions::new() - .create(&name_for_server) - .expect("second instance"); - second.connect().await.expect("connect"); - let mut received = Vec::new(); - second.read_to_end(&mut received).await.expect("read"); - received - }); - write_inbox(&name, b"hello\n").await.expect("write"); - assert_eq!(late.await.expect("server"), b"hello\n"); - drop(first); - } - - #[tokio::test] - async fn a_missing_pipe_fails() { - assert!(write_inbox(&unique_name(), b"x\n").await.is_err()); - } -} diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 6e5d7b6a..79822210 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -6,7 +6,8 @@ use serde::de::DeserializeOwned; use serde_json::Value; use super::jsonrpc::{RpcError, empty_params}; -use crate::service::{self, ApiResult, bots, crews, deliveries, messages, tasks, terminal}; +use crate::approvals; +use crate::service::{self, ApiResult, bots, chat, crews, deliveries, messages, tasks}; use crate::state::Daemon; pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result { @@ -23,8 +24,8 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(bots::set_paused(daemon, parse(params)?)), method::BOTS_ARCHIVE => reply(bots::archive(daemon, parse(params)?)), method::BOTS_RESTART => reply(bots::restart(daemon, parse(params)?)), - method::TERMINAL_WRITE => reply(terminal::write(daemon, parse(params)?)), - method::TERMINAL_RESIZE => reply(terminal::resize(daemon, parse(params)?)), + method::CHAT_HISTORY => reply(chat::history(daemon, parse(params)?)), + method::APPROVALS_ANSWER => reply(approvals::answer(daemon, parse(params)?)), method::MESSAGES_SEND => reply(messages::send(daemon, parse(params)?)), method::MESSAGES_LIST => reply(messages::list(daemon, parse(params)?)), method::DELIVERIES_LIST => reply(deliveries::list(daemon, parse(params)?)), diff --git a/crates/botloftd/src/rpc/mod.rs b/crates/botloftd/src/rpc/mod.rs index e9202b6c..c453bdcf 100644 --- a/crates/botloftd/src/rpc/mod.rs +++ b/crates/botloftd/src/rpc/mod.rs @@ -3,7 +3,6 @@ mod dispatch; pub mod jsonrpc; -mod terminal; use std::sync::Arc; use std::time::Duration; @@ -41,9 +40,7 @@ pub async fn serve_connection(socket: WebSocket, daemon: Arc) { // Subscribe before answering hello so no change slips in between. let events = daemon.subscribe(); if authenticate(&mut stream, &outbox, &daemon).await { - let mut streams = terminal::Streams::default(); - run(stream, &outbox, events, &daemon, &mut streams).await; - streams.stop_all(); + run(stream, &outbox, events, &daemon).await; } drop(outbox); let _ = writer.await; @@ -108,12 +105,11 @@ async fn run( outbox: &mpsc::Sender, mut events: broadcast::Receiver, daemon: &Daemon, - streams: &mut terminal::Streams, ) { loop { let frame = tokio::select! { message = stream.next() => match message { - Some(Ok(Message::Text(text))) => handle(daemon, &text, outbox, streams).await, + Some(Ok(Message::Text(text))) => handle(daemon, &text), Some(Ok(Message::Close(_)) | Err(_)) | None => return, Some(Ok(_)) => None, }, @@ -135,31 +131,19 @@ async fn run( } } -async fn handle( - daemon: &Daemon, - text: &str, - outbox: &mpsc::Sender, - streams: &mut terminal::Streams, -) -> Option { +fn handle(daemon: &Daemon, text: &str) -> Option { let request = match jsonrpc::parse(text) { Ok(request) => request, Err((id, err)) => return Some(jsonrpc::failure(&id, &err)), }; debug!(method = %request.method, "rpc request"); - let id = request.id.clone(); - let result = match request.method.as_str() { - method::TERMINAL_ATTACH => { - // The response must go out before the first terminal.data. - return terminal::attach(daemon, request, outbox, streams).await; - } - method::TERMINAL_DETACH => terminal::detach(request.params, streams), - _ => dispatch::dispatch(daemon, &request.method, request.params), - }; - let id = id?; - Some(match result { - Ok(value) => jsonrpc::success(&id, value), - Err(err) => jsonrpc::failure(&id, &err), - }) + let id = request.id.clone()?; + Some( + match dispatch::dispatch(daemon, &request.method, request.params) { + Ok(value) => jsonrpc::success(&id, value), + Err(err) => jsonrpc::failure(&id, &err), + }, + ) } fn to_notification(event: &Event) -> String { @@ -167,6 +151,8 @@ fn to_notification(event: &Event) -> String { Event::CrewChanged(crew) => (notification::CREW_CHANGED, serde_json::to_value(crew)), Event::BotChanged(bot) => (notification::BOT_CHANGED, serde_json::to_value(bot)), Event::BotState(state) => (notification::BOT_STATE, serde_json::to_value(state)), + Event::ChatItem(item) => (notification::CHAT_ITEM, serde_json::to_value(item)), + Event::ChatDelta(delta) => (notification::CHAT_DELTA, serde_json::to_value(delta)), Event::MessageCreated(message) => { (notification::MESSAGE_CREATED, serde_json::to_value(message)) } diff --git a/crates/botloftd/src/rpc/terminal.rs b/crates/botloftd/src/rpc/terminal.rs deleted file mode 100644 index 309f0905..00000000 --- a/crates/botloftd/src/rpc/terminal.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Streaming terminal output to one connection (spec 8). - -use std::collections::HashMap; - -use botloft_core::ids::BotId; -use botloft_core::protocol::{ - BotIdParams, TerminalAttachParams, TerminalAttachResult, TerminalData, notification, -}; -use serde_json::Value; -use tokio::sync::{broadcast, mpsc}; -use tokio::task::JoinHandle; - -use super::dispatch::parse; -use super::jsonrpc::{self, Request, RpcError}; -use crate::service; -use crate::state::Daemon; -use crate::terminal::Attachment; - -/// The terminals this connection watches, one forwarding task each. -#[derive(Default)] -pub struct Streams { - tasks: HashMap>, -} - -impl Streams { - fn replace(&mut self, bot: BotId, task: JoinHandle<()>) { - if let Some(old) = self.tasks.insert(bot, task) { - old.abort(); - } - } - - pub fn stop_all(&mut self) { - for (_, task) in self.tasks.drain() { - task.abort(); - } - } -} - -/// Answers `terminal.attach` and then streams the replay and live output. -/// Returns a frame only when there is no stream to start. -pub async fn attach( - daemon: &Daemon, - request: Request, - outbox: &mpsc::Sender, - streams: &mut Streams, -) -> Option { - let id = request.id.unwrap_or(Value::Null); - let attached = parse::(request.params).and_then(|params| { - let bot = params.bot_id.clone(); - service::terminal::attach(daemon, params) - .map(|attachment| (bot, attachment)) - .map_err(RpcError::from) - }); - let (bot, attachment) = match attached { - Ok(attached) => attached, - Err(err) => return Some(jsonrpc::failure(&id, &err)), - }; - let point = attachment.point; - let result = TerminalAttachResult { - generation: point.generation, - offset: point.offset, - reset: point.reset, - live_offset: point.live_offset, - }; - let frame = jsonrpc::success(&id, serde_json::to_value(result).unwrap_or(Value::Null)); - if outbox.send(frame).await.is_err() { - return None; - } - let task = tokio::spawn(forward(bot.clone(), attachment, outbox.clone())); - streams.replace(bot, task); - None -} - -pub fn detach(params: Option, streams: &mut Streams) -> Result { - let params: BotIdParams = parse(params)?; - if let Some(task) = streams.tasks.remove(¶ms.bot_id) { - task.abort(); - } - Ok(Value::Null) -} - -/// Sends the replay, then every new chunk. A client that falls behind sees -/// the offset jump and attaches again from what it has. -async fn forward(bot: BotId, attachment: Attachment, outbox: mpsc::Sender) { - let Attachment { - point, - replay, - mut live, - } = attachment; - if !replay.is_empty() - && outbox - .send(data_frame(&bot, point.generation, point.offset, &replay)) - .await - .is_err() - { - return; - } - loop { - let chunk = match live.recv().await { - Ok(chunk) => chunk, - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => return, - }; - let frame = data_frame(&bot, chunk.generation, chunk.offset, &chunk.data); - if outbox.send(frame).await.is_err() { - return; - } - } -} - -fn data_frame(bot: &BotId, generation: u64, offset: u64, data: &[u8]) -> String { - let params = TerminalData { - bot_id: bot.clone(), - generation, - offset, - data: service::terminal::encode(data), - }; - jsonrpc::notification( - notification::TERMINAL_DATA, - serde_json::to_value(params).unwrap_or(Value::Null), - ) -} diff --git a/crates/botloftd/src/runtime/fake.rs b/crates/botloftd/src/runtime/fake.rs index 47633b4e..23b3e891 100644 --- a/crates/botloftd/src/runtime/fake.rs +++ b/crates/botloftd/src/runtime/fake.rs @@ -1,6 +1,6 @@ //! A runtime that starts nothing (spec 1.6). Each spawn returns a -//! [`FakeProcess`] the test drives by hand: it emits output, exits and -//! records what the daemon wrote to it. +//! [`FakeProcess`] the test drives by hand: it emits stream-json events, +//! exits and records the lines the daemon wrote to its stdin. use std::io; use std::sync::atomic::{AtomicBool, Ordering}; @@ -10,7 +10,9 @@ use std::time::Duration; use bytes::Bytes; use tokio::sync::{Notify, mpsc}; -use super::{EVENT_BUFFER, Process, ProcessControl, ProcessEvent, Runtime, SpawnSpec, TermSize}; +use serde_json::Value; + +use super::{EVENT_BUFFER, Process, ProcessControl, ProcessEvent, Runtime, SpawnSpec}; #[derive(Clone, Default)] pub struct FakeRuntime { @@ -29,7 +31,7 @@ pub struct FakeProcess { pub spec: SpawnSpec, events: mpsc::Sender, input: Arc>>, - sizes: Arc>>, + wrote: Arc, killed: Arc, } @@ -83,13 +85,13 @@ impl Runtime for FakeRuntime { spec, events: events.clone(), input: Arc::default(), - sizes: Arc::default(), + wrote: Arc::default(), killed: Arc::default(), }; let control = FakeControl { events, input: Arc::clone(&process.input), - sizes: Arc::clone(&process.sizes), + wrote: Arc::clone(&process.wrote), killed: Arc::clone(&process.killed), }; state.processes.push(process); @@ -111,6 +113,13 @@ impl FakeProcess { .await; } + /// Writes one stream-json event to stdout, as Claude Code would. + pub async fn emit(&self, event: Value) { + let mut line = event.to_string().into_bytes(); + line.push(b'\n'); + self.output(&line).await; + } + pub async fn exit(&self, code: u32) { let _ = self.events.send(ProcessEvent::Exited(Some(code))).await; } @@ -119,8 +128,32 @@ impl FakeProcess { lock(&self.input).clone() } - pub fn sizes(&self) -> Vec { - lock(&self.sizes).clone() + /// Every complete line written to stdin, parsed as JSON. + pub fn input_lines(&self) -> Vec { + self.input() + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .map(|line| serde_json::from_slice(line).expect("stdin line is JSON")) + .collect() + } + + /// Waits up to 5 s until at least `n` lines were written to stdin. + pub async fn wait_lines(&self, n: usize) -> Vec { + let wait = async { + loop { + let notified = self.wrote.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + let lines = self.input_lines(); + if lines.len() >= n { + return lines; + } + notified.await; + } + }; + tokio::time::timeout(Duration::from_secs(5), wait) + .await + .unwrap_or_else(|_| panic!("expected {n} lines on stdin, got {:?}", self.input_lines())) } pub fn killed(&self) -> bool { @@ -147,18 +180,17 @@ impl FakeProcess { struct FakeControl { events: mpsc::Sender, input: Arc>>, - sizes: Arc>>, + wrote: Arc, killed: Arc, } impl ProcessControl for FakeControl { fn write(&self, data: Bytes) -> io::Result<()> { + if self.killed.load(Ordering::SeqCst) { + return Err(io::Error::new(io::ErrorKind::BrokenPipe, "killed")); + } lock(&self.input).extend_from_slice(&data); - Ok(()) - } - - fn resize(&self, size: TermSize) -> io::Result<()> { - lock(&self.sizes).push(size); + self.wrote.notify_waiters(); Ok(()) } diff --git a/crates/botloftd/src/runtime/mod.rs b/crates/botloftd/src/runtime/mod.rs index 249e0c12..50c231e7 100644 --- a/crates/botloftd/src/runtime/mod.rs +++ b/crates/botloftd/src/runtime/mod.rs @@ -1,10 +1,10 @@ -//! How bot processes run. [`PtyRuntime`] starts them in a pseudo terminal -//! (ConPTY on Windows); [`fake::FakeRuntime`] implements the same contract -//! for tests, so the supervisor and terminal are tested without Claude. +//! How bot processes run. [`PipeRuntime`] starts them with stdin and stdout +//! in pipes (spec 7.4); [`fake::FakeRuntime`] implements the same contract +//! for tests, so the supervisor, chat and courier are tested without Claude. pub mod claude; pub mod fake; -mod pty; +mod pipe; use std::ffi::OsString; use std::io; @@ -13,24 +13,7 @@ use std::path::PathBuf; use bytes::Bytes; use tokio::sync::mpsc; -pub use pty::PtyRuntime; - -/// Terminal size in character cells. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct TermSize { - pub cols: u16, - pub rows: u16, -} - -impl Default for TermSize { - /// Used until the app sends `terminal.resize` (spec 7.4). - fn default() -> Self { - Self { - cols: 120, - rows: 32, - } - } -} +pub use pipe::PipeRuntime; #[derive(Debug, Clone, PartialEq, Eq)] pub struct SpawnSpec { @@ -39,11 +22,11 @@ pub struct SpawnSpec { pub cwd: PathBuf, /// The complete environment. Nothing is inherited from the daemon. pub env: Vec<(OsString, OsString)>, - pub size: TermSize, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ProcessEvent { + /// Bytes from stdout, in order; lines may be split across chunks. Output(Bytes), /// Last event. The exit code, when the OS reported one. Exited(Option), @@ -51,9 +34,8 @@ pub enum ProcessEvent { /// Handle to a running process. Dropping it kills the process tree. pub trait ProcessControl: Send + Sync { - /// Queues bytes for the process's input without blocking. + /// Queues bytes for the process's stdin without blocking. fn write(&self, data: Bytes) -> io::Result<()>; - fn resize(&self, size: TermSize) -> io::Result<()>; /// Kills the process and everything it started. fn kill(&self) -> io::Result<()>; } diff --git a/crates/botloftd/src/runtime/pipe.rs b/crates/botloftd/src/runtime/pipe.rs new file mode 100644 index 00000000..2ad2c5cd --- /dev/null +++ b/crates/botloftd/src/runtime/pipe.rs @@ -0,0 +1,190 @@ +//! Processes with stdin, stdout and stderr in pipes (spec 7.4). Each one +//! gets a thread to read stdout, one to write stdin (writing never blocks +//! the caller), one to drain stderr and one to wait for the exit. On +//! Windows the process joins its own Job Object and has no console. + +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::{Arc, Mutex, mpsc as std_mpsc}; +use std::thread; + +use bytes::Bytes; +use tokio::sync::mpsc; +use tracing::debug; + +use super::{EVENT_BUFFER, Process, ProcessControl, ProcessEvent, Runtime, SpawnSpec}; +use crate::platform::ProcessJob; + +const READ_CHUNK: usize = 64 * 1024; +/// Longest stderr line kept for the debug log. +const STDERR_LINE_MAX: usize = 300; + +pub struct PipeRuntime; + +impl Runtime for PipeRuntime { + fn spawn(&self, spec: SpawnSpec) -> io::Result { + let mut command = Command::new(&spec.program); + command + .args(&spec.args) + .current_dir(&spec.cwd) + .env_clear() + .envs(spec.env.iter().map(|(name, value)| (name, value))) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + #[cfg(windows)] + { + use std::os::windows::process::CommandExt as _; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + command.creation_flags(CREATE_NO_WINDOW); + } + + let job = Arc::new(ProcessJob::new()?); + let mut child = command.spawn()?; + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle as _; + if let Err(err) = job.assign(child.as_raw_handle()) { + let _ = child.kill(); + return Err(err); + } + } + let pid = Some(child.id()); + let stdout = child + .stdout + .take() + .ok_or_else(|| io::Error::other("no stdout"))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| io::Error::other("no stderr"))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| io::Error::other("no stdin"))?; + let child = Arc::new(Mutex::new(child)); + + let (events, events_rx) = mpsc::channel(EVENT_BUFFER); + let (exit_tx, exit_rx) = std_mpsc::channel(); + let waiter_child = Arc::clone(&child); + let waiter_job = Arc::clone(&job); + thread::Builder::new() + .name("bot-wait".into()) + .spawn(move || { + let code = wait(&waiter_child); + let _ = exit_tx.send(code); + // Something the bot started may still hold stdout open; + // ending the job lets the reader see EOF. + let _ = waiter_job.terminate(); + })?; + thread::Builder::new() + .name("bot-read".into()) + .spawn(move || read_output(stdout, &events, &exit_rx))?; + thread::Builder::new() + .name("bot-stderr".into()) + .spawn(move || drain_stderr(stderr))?; + let (input, input_rx) = std_mpsc::channel::(); + thread::Builder::new() + .name("bot-write".into()) + .spawn(move || write_input(stdin, &input_rx))?; + + Ok(Process { + pid, + events: events_rx, + control: Box::new(PipeControl { + input: Mutex::new(Some(input)), + child, + job, + }), + }) + } +} + +/// Polls instead of blocking in `wait`, so `kill` can take the lock. +fn wait(child: &Mutex) -> Option { + loop { + match lock(child).try_wait() { + Ok(Some(status)) => return status.code().and_then(|code| u32::try_from(code).ok()), + Ok(None) => {} + Err(_) => return None, + } + thread::sleep(std::time::Duration::from_millis(50)); + } +} + +fn read_output( + mut stdout: impl Read, + events: &mpsc::Sender, + exit: &std_mpsc::Receiver>, +) { + let mut buf = vec![0u8; READ_CHUNK]; + loop { + match stdout.read(&mut buf) { + Ok(0) | Err(_) => break, + Ok(n) => { + let chunk = Bytes::copy_from_slice(&buf[..n]); + if events.blocking_send(ProcessEvent::Output(chunk)).is_err() { + break; + } + } + } + } + let code = exit.recv().ok().flatten(); + let _ = events.blocking_send(ProcessEvent::Exited(code)); +} + +/// Claude Code writes warnings here. Only their start goes to the debug +/// log: a line could quote the conversation. +fn drain_stderr(stderr: impl Read) { + for line in BufReader::new(stderr).lines() { + let Ok(line) = line else { break }; + let short: String = line.chars().take(STDERR_LINE_MAX).collect(); + debug!(target: "botloftd::bot_stderr", "{short}"); + } +} + +fn write_input(mut stdin: ChildStdin, input: &std_mpsc::Receiver) { + while let Ok(data) = input.recv() { + if stdin.write_all(&data).and_then(|()| stdin.flush()).is_err() { + return; + } + } + // The sender was dropped: closing stdin ends the Claude Code session. +} + +struct PipeControl { + /// `None` after the process was killed. + input: Mutex>>, + child: Arc>, + job: Arc, +} + +impl ProcessControl for PipeControl { + fn write(&self, data: Bytes) -> io::Result<()> { + let closed = || io::Error::new(io::ErrorKind::BrokenPipe, "the process has exited"); + lock(&self.input) + .as_ref() + .ok_or_else(closed)? + .send(data) + .map_err(|_| closed()) + } + + fn kill(&self) -> io::Result<()> { + lock(&self.input).take(); + let job = self.job.terminate(); + let child = lock(&self.child).kill(); + job.or(child) + } +} + +impl Drop for PipeControl { + fn drop(&mut self) { + let _ = self.kill(); + } +} + +fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} diff --git a/crates/botloftd/src/runtime/pty.rs b/crates/botloftd/src/runtime/pty.rs deleted file mode 100644 index 84345aba..00000000 --- a/crates/botloftd/src/runtime/pty.rs +++ /dev/null @@ -1,161 +0,0 @@ -//! Pseudo terminal runtime. ConPTY has no handle to poll, so each process -//! gets three threads (spec 8): one reads output, one writes input and one -//! waits for the exit. On Windows the process joins its own Job Object. - -use std::io::{self, Read, Write}; -use std::sync::{Arc, Mutex, mpsc as std_mpsc}; -use std::thread; - -use bytes::Bytes; -use portable_pty::{ChildKiller, CommandBuilder, MasterPty, PtySize, native_pty_system}; -use tokio::sync::mpsc; - -use super::{EVENT_BUFFER, Process, ProcessControl, ProcessEvent, Runtime, SpawnSpec, TermSize}; -use crate::platform::ProcessJob; - -const READ_CHUNK: usize = 32 * 1024; - -pub struct PtyRuntime; - -impl Runtime for PtyRuntime { - fn spawn(&self, spec: SpawnSpec) -> io::Result { - let pair = native_pty_system() - .openpty(pty_size(spec.size)) - .map_err(io::Error::other)?; - let mut command = CommandBuilder::new(&spec.program); - command.args(&spec.args); - command.cwd(&spec.cwd); - command.env_clear(); - for (name, value) in &spec.env { - command.env(name, value); - } - - let job = ProcessJob::new()?; - let mut child = pair - .slave - .spawn_command(command) - .map_err(io::Error::other)?; - // The child holds its own end; keeping ours would stop EOF from arriving. - drop(pair.slave); - let mut killer = child.clone_killer(); - #[cfg(windows)] - if let Some(handle) = child.as_raw_handle() - && let Err(err) = job.assign(handle) - { - let _ = killer.kill(); - return Err(err); - } - - let pid = child.process_id(); - let reader = pair.master.try_clone_reader().map_err(io::Error::other)?; - let writer = pair.master.take_writer().map_err(io::Error::other)?; - let master = Arc::new(Mutex::new(Some(pair.master))); - let (events, events_rx) = mpsc::channel(EVENT_BUFFER); - let (exit_tx, exit_rx) = std_mpsc::channel(); - - let waiter_master = Arc::clone(&master); - thread::Builder::new() - .name("bot-wait".into()) - .spawn(move || { - let code = child.wait().ok().map(|status| status.exit_code()); - let _ = exit_tx.send(code); - // ConPTY keeps the output pipe open until the pseudo console - // closes; dropping the master lets the reader see EOF. - lock(&waiter_master).take(); - })?; - thread::Builder::new() - .name("bot-read".into()) - .spawn(move || read_output(reader, &events, &exit_rx))?; - let (input, input_rx) = std_mpsc::channel::(); - thread::Builder::new() - .name("bot-write".into()) - .spawn(move || write_input(writer, &input_rx))?; - - Ok(Process { - pid, - events: events_rx, - control: Box::new(PtyControl { - input: Mutex::new(input), - master, - killer: Mutex::new(killer), - job, - }), - }) - } -} - -fn read_output( - mut reader: Box, - events: &mpsc::Sender, - exit: &std_mpsc::Receiver>, -) { - let mut buf = vec![0u8; READ_CHUNK]; - loop { - match reader.read(&mut buf) { - Ok(0) | Err(_) => break, - Ok(n) => { - let chunk = Bytes::copy_from_slice(&buf[..n]); - if events.blocking_send(ProcessEvent::Output(chunk)).is_err() { - break; - } - } - } - } - let code = exit.recv().ok().flatten(); - let _ = events.blocking_send(ProcessEvent::Exited(code)); -} - -fn write_input(mut writer: Box, input: &std_mpsc::Receiver) { - while let Ok(data) = input.recv() { - if writer - .write_all(&data) - .and_then(|()| writer.flush()) - .is_err() - { - return; - } - } -} - -struct PtyControl { - input: Mutex>, - master: Arc>>>, - killer: Mutex>, - job: ProcessJob, -} - -impl ProcessControl for PtyControl { - fn write(&self, data: Bytes) -> io::Result<()> { - lock(&self.input) - .send(data) - .map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "the process has exited")) - } - - fn resize(&self, size: TermSize) -> io::Result<()> { - match lock(&self.master).as_ref() { - Some(master) => master.resize(pty_size(size)).map_err(io::Error::other), - None => Ok(()), - } - } - - fn kill(&self) -> io::Result<()> { - let job = self.job.terminate(); - let child = lock(&self.killer).kill(); - job.or(child) - } -} - -fn pty_size(size: TermSize) -> PtySize { - PtySize { - rows: size.rows.max(1), - cols: size.cols.max(1), - pixel_width: 0, - pixel_height: 0, - } -} - -fn lock(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { - mutex - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) -} diff --git a/crates/botloftd/src/server.rs b/crates/botloftd/src/server.rs index d97e091b..b4ffc105 100644 --- a/crates/botloftd/src/server.rs +++ b/crates/botloftd/src/server.rs @@ -1,5 +1,5 @@ //! HTTP server on 127.0.0.1: `GET /health`, the `/rpc` WebSocket for the -//! app, and `POST /hooks/{event}` and `POST /mcp` for the bots. +//! app, and `POST /mcp` for the bots. use std::future::Future; use std::sync::Arc; @@ -14,7 +14,7 @@ use serde_json::json; use tokio::net::TcpListener; use crate::state::Daemon; -use crate::{hooks, rpc, tools}; +use crate::{rpc, tools}; /// Browser origins allowed to open `/rpc` (spec 11.1): the Tauri app in /// production and the Vite dev server. Clients that send no `Origin`, such @@ -25,10 +25,9 @@ pub const ALLOWED_ORIGINS: &[&str] = &[ "http://localhost:1420", ]; -/// Largest WebSocket message accepted from the app. -const MAX_MESSAGE_BYTES: usize = 4 << 20; -/// Largest hook body; payloads are small JSON objects. -const MAX_HOOK_BYTES: usize = 1 << 20; +/// Largest WebSocket message accepted from the app; attachments travel in +/// `messages.send` (spec 11). +const MAX_MESSAGE_BYTES: usize = 32 << 20; /// Largest MCP request: a message of 100 000 characters fits with room. const MAX_MCP_BYTES: usize = 2 << 20; @@ -36,10 +35,6 @@ pub fn router(daemon: Arc) -> Router { Router::new() .route("/health", get(health)) .route("/rpc", get(rpc_upgrade)) - .route( - "/hooks/{event}", - post(hooks::handle).layer(DefaultBodyLimit::max(MAX_HOOK_BYTES)), - ) .route( "/mcp", post(tools::handle) diff --git a/crates/botloftd/src/service/attachments.rs b/crates/botloftd/src/service/attachments.rs new file mode 100644 index 00000000..cbdf03e5 --- /dev/null +++ b/crates/botloftd/src/service/attachments.rs @@ -0,0 +1,182 @@ +//! Files the owner sends with a message (spec 9.5): saved in the bot's +//! `attachments\\` folder before the message is stored. + +use std::path::Path; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use botloft_core::ids::AttachmentId; +use botloft_core::protocol::{Attachment, AttachmentUpload}; +use botloft_core::{slug, validate}; + +use super::{ApiError, ApiResult}; + +const ATTACHMENTS_DIR: &str = "attachments"; +const DEFAULT_MEDIA_TYPE: &str = "application/octet-stream"; + +/// Checks and saves every upload. Returns what the message records. +pub(crate) fn save( + workspace: &Path, + uploads: Vec, + max_bytes: u64, + now_ms: i64, +) -> ApiResult> { + if uploads.len() > validate::ATTACHMENTS_MAX { + return Err(ApiError::validation(format!( + "a message can carry at most {} files", + validate::ATTACHMENTS_MAX + ))); + } + // Decode and check everything before writing anything. + let mut files = Vec::with_capacity(uploads.len()); + for upload in uploads { + let name = slug::file_name(&upload.name); + let bytes = BASE64 + .decode(upload.data.as_bytes()) + .map_err(|_| ApiError::validation(format!("{name} is not valid base64")))?; + let size = bytes.len() as u64; + if size > max_bytes { + return Err(ApiError::validation(format!( + "{name} is larger than {} MB", + max_bytes / (1024 * 1024) + ))); + } + files.push((name, media_type(&upload.media_type), bytes)); + } + if files.is_empty() { + return Ok(Vec::new()); + } + let folder = format!("{ATTACHMENTS_DIR}/{}", date(now_ms)); + let dir = workspace.join(&folder); + std::fs::create_dir_all(&dir).map_err(ApiError::Workspace)?; + let mut saved = Vec::with_capacity(files.len()); + for (name, media_type, bytes) in files { + let name = free_name(&dir, &name); + std::fs::write(dir.join(&name), &bytes).map_err(ApiError::Workspace)?; + saved.push(Attachment { + id: AttachmentId::generate(), + path: format!("{folder}/{name}"), + name, + media_type, + size: bytes.len() as u64, + }); + } + Ok(saved) +} + +/// A MIME type as given, or the generic one when it looks wrong. +fn media_type(given: &str) -> String { + let given = given.trim().to_ascii_lowercase(); + let valid = given.len() <= 100 + && given.split_once('/').is_some_and(|(kind, sub)| { + let token = |s: &str| { + !s.is_empty() + && s.chars() + .all(|ch| ch.is_ascii_alphanumeric() || "+-.".contains(ch)) + }; + token(kind) && token(sub) + }); + if valid { + given + } else { + DEFAULT_MEDIA_TYPE.to_owned() + } +} + +/// `name`, or `name-2.ext`, `name-3.ext`... when the folder has it already. +fn free_name(dir: &Path, name: &str) -> String { + if !dir.join(name).exists() { + return name.to_owned(); + } + let (stem, extension) = match name.rfind('.') { + Some(dot) if dot > 0 => (&name[..dot], &name[dot..]), + _ => (name, ""), + }; + (2u32..) + .map(|n| format!("{stem}-{n}{extension}")) + .find(|candidate| !dir.join(candidate).exists()) + .unwrap_or_else(|| name.to_owned()) +} + +/// `yyyy-mm-dd` of a Unix time in milliseconds, in UTC. +fn date(ms: i64) -> String { + let days = ms.div_euclid(86_400_000); + // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z.rem_euclid(146_097); + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + format!("{year:04}-{month:02}-{day:02}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn upload(name: &str, media_type: &str, data: &[u8]) -> AttachmentUpload { + AttachmentUpload { + name: name.into(), + media_type: media_type.into(), + data: BASE64.encode(data), + } + } + + #[test] + fn dates_are_utc_calendar_days() { + assert_eq!(date(0), "1970-01-01"); + assert_eq!(date(1_790_000_000_000), "2026-09-21"); + assert_eq!(date(951_782_400_000), "2000-02-29"); + } + + #[test] + fn uploads_are_saved_under_the_day_with_unique_names() { + let dir = tempfile::tempdir().expect("tempdir"); + let now = 1_790_000_000_000; + let first = save( + dir.path(), + vec![upload("plan.pdf", "application/pdf", b"one")], + 1024, + now, + ) + .expect("save"); + let second = save( + dir.path(), + vec![upload("../plan.pdf", "weird type", b"two")], + 1024, + now, + ) + .expect("save again"); + assert_eq!(first[0].path, "attachments/2026-09-21/plan.pdf"); + assert_eq!(second[0].path, "attachments/2026-09-21/plan-2.pdf"); + assert_eq!(second[0].media_type, DEFAULT_MEDIA_TYPE); + assert_eq!(second[0].size, 3); + let written = std::fs::read(dir.path().join(&second[0].path)).expect("read"); + assert_eq!(written, b"two"); + } + + #[test] + fn too_many_too_big_or_broken_uploads_are_refused_whole() { + let dir = tempfile::tempdir().expect("tempdir"); + let many = (0..=validate::ATTACHMENTS_MAX) + .map(|n| upload(&format!("{n}.txt"), "text/plain", b"x")) + .collect(); + assert!(save(dir.path(), many, 1024, 0).is_err()); + let big = vec![ + upload("ok.txt", "text/plain", b"x"), + upload("big.bin", "", &[0; 11]), + ]; + assert!(save(dir.path(), big, 10, 0).is_err()); + assert!(!dir.path().join("attachments").exists(), "nothing written"); + let broken = vec![AttachmentUpload { + name: "a.png".into(), + media_type: "image/png".into(), + data: "not base64!".into(), + }]; + assert!(save(dir.path(), broken, 1024, 0).is_err()); + } +} diff --git a/crates/botloftd/src/service/bots.rs b/crates/botloftd/src/service/bots.rs index 337fa8bc..c69262b5 100644 --- a/crates/botloftd/src/service/bots.rs +++ b/crates/botloftd/src/service/bots.rs @@ -26,7 +26,7 @@ pub fn list(daemon: &Daemon, params: BotsListParams) -> ApiResult> { .into_iter() .map(|record| { let crew = crews::find(&store, &record.crew_id)?; - Ok(to_protocol(daemon, &crew, record)) + Ok(to_protocol(daemon, &store, &crew, record)) }) .collect() } @@ -65,7 +65,7 @@ pub fn create(daemon: &Daemon, params: BotsCreateParams) -> ApiResult { }; workspace::prepare_bot(daemon.workspace_env(), &crew, &record).map_err(ApiError::Workspace)?; store.insert_bot(&record)?; - let bot = changed(daemon, &crew, record); + let bot = changed(daemon, &store, &crew, record); daemon.supervisor.wake(); Ok(bot) } @@ -91,18 +91,18 @@ pub fn update(daemon: &Daemon, params: BotsUpdateParams) -> ApiResult { // reads them at its next start. workspace::write_rules(&daemon.paths, &crew, &record).map_err(ApiError::Workspace)?; store.update_bot(&record)?; - Ok(changed(daemon, &crew, record)) + Ok(changed(daemon, &store, &crew, record)) } pub fn set_paused(daemon: &Daemon, params: BotsSetPausedParams) -> ApiResult { let store = daemon.store(); let (crew, mut record) = active(&store, ¶ms.bot_id)?; if record.paused == params.paused { - return Ok(to_protocol(daemon, &crew, record)); + return Ok(to_protocol(daemon, &store, &crew, record)); } record.paused = params.paused; store.update_bot(&record)?; - let bot = changed(daemon, &crew, record); + let bot = changed(daemon, &store, &crew, record); daemon.supervisor.wake(); Ok(bot) } @@ -121,7 +121,7 @@ pub fn restart(daemon: &Daemon, params: BotsRestartParams) -> ApiResult { daemon .supervisor .restart(&record.id, params.fresh.unwrap_or(false)); - Ok(to_protocol(daemon, &crew, record)) + Ok(to_protocol(daemon, &store, &crew, record)) } /// Archives the bot and stops its process. Archiving twice is not an error. @@ -131,17 +131,17 @@ pub fn archive(daemon: &Daemon, params: BotIdParams) -> ApiResult { let mut record = find(&store, ¶ms.bot_id)?; let crew = crews::find(&store, &record.crew_id)?; if record.archived_at.is_some() { - return Ok(to_protocol(daemon, &crew, record)); + return Ok(to_protocol(daemon, &store, &crew, record)); } record.archived_at = Some(now_ms()); store.update_bot(&record)?; - let bot = changed(daemon, &crew, record); + let bot = changed(daemon, &store, &crew, record); daemon.supervisor.wake(); Ok(bot) } /// The protocol view of a stored bot, with the supervisor's live state. -pub(crate) fn to_protocol(daemon: &Daemon, crew: &Crew, record: BotRecord) -> Bot { +pub(crate) fn to_protocol(daemon: &Daemon, store: &Store, crew: &Crew, record: BotRecord) -> Bot { let (live, generation) = daemon .supervisor .status(&record.id) @@ -152,6 +152,10 @@ pub(crate) fn to_protocol(daemon: &Daemon, crew: &Crew, record: BotRecord) -> Bo live }; let workspace = daemon.paths.bot_workspace(&crew.slug, &record.slug); + let last_activity = store.last_activity(&record.id).unwrap_or_else(|err| { + tracing::warn!(bot = %record.id, "could not read the last activity: {err}"); + None + }); Bot { id: record.id, crew_id: record.crew_id, @@ -165,13 +169,14 @@ pub(crate) fn to_protocol(daemon: &Daemon, crew: &Crew, record: BotRecord) -> Bo state, generation, workspace: workspace.to_string_lossy().into_owned(), + last_activity, created_at: record.created_at, archived_at: record.archived_at, } } -fn changed(daemon: &Daemon, crew: &Crew, record: BotRecord) -> Bot { - let bot = to_protocol(daemon, crew, record); +fn changed(daemon: &Daemon, store: &Store, crew: &Crew, record: BotRecord) -> Bot { + let bot = to_protocol(daemon, store, crew, record); daemon.emit(Event::BotChanged(bot.clone())); bot } diff --git a/crates/botloftd/src/service/chat.rs b/crates/botloftd/src/service/chat.rs new file mode 100644 index 00000000..df3abc95 --- /dev/null +++ b/crates/botloftd/src/service/chat.rs @@ -0,0 +1,22 @@ +//! `chat.history` (spec 8, 11.2). + +use botloft_core::protocol::{ChatHistoryParams, ChatItem}; + +use super::{ApiError, ApiResult, bots}; +use crate::state::Daemon; + +const DEFAULT_LIMIT: u32 = 50; +const MAX_LIMIT: u32 = 200; + +/// A page of a bot's chat, newest first. Archived bots keep theirs. +pub fn history(daemon: &Daemon, params: ChatHistoryParams) -> ApiResult> { + let limit = params.limit.unwrap_or(DEFAULT_LIMIT); + if !(1..=MAX_LIMIT).contains(&limit) { + return Err(ApiError::validation(format!( + "limit must be between 1 and {MAX_LIMIT}" + ))); + } + let store = daemon.store(); + bots::find(&store, ¶ms.bot_id)?; + Ok(store.chat_history(¶ms.bot_id, params.before.as_ref(), limit)?) +} diff --git a/crates/botloftd/src/service/crews.rs b/crates/botloftd/src/service/crews.rs index 2bd6c2b3..b9bc65e7 100644 --- a/crates/botloftd/src/service/crews.rs +++ b/crates/botloftd/src/service/crews.rs @@ -80,7 +80,9 @@ pub fn archive(daemon: &Daemon, params: CrewIdParams) -> ApiResult { let crew = find(&store, &crew.id)?; for bot in bots { if let Some(bot) = store.bot(&bot.id)? { - daemon.emit(Event::BotChanged(bots::to_protocol(daemon, &crew, bot))); + daemon.emit(Event::BotChanged(bots::to_protocol( + daemon, &store, &crew, bot, + ))); } } daemon.emit(Event::CrewChanged(crew.clone())); diff --git a/crates/botloftd/src/service/messages.rs b/crates/botloftd/src/service/messages.rs index 4ebd2820..69a8c17b 100644 --- a/crates/botloftd/src/service/messages.rs +++ b/crates/botloftd/src/service/messages.rs @@ -3,23 +3,35 @@ use botloft_core::ids::{DeliveryId, MessageId}; use botloft_core::protocol::{ - Delivery, DeliveryState, Message, MessageKind, MessagesListParams, MessagesSendParams, - SenderKind, Task, + ChatItem, Delivery, DeliveryState, Message, MessageKind, MessagesListParams, + MessagesSendParams, SenderKind, Task, }; use botloft_core::validate; use botloft_store::{MessageFilter, Store}; -use super::{ApiError, ApiResult, bots, crews}; +use super::{ApiError, ApiResult, attachments, bots, crews}; +use crate::chat::items; use crate::state::{Daemon, Event}; const DEFAULT_LIMIT: u32 = 50; const MAX_LIMIT: u32 = 200; -/// The owner writes to a bot. A paused bot gets it once it runs again. +/// The owner writes to a bot, with files if any (spec 9.5). A paused bot +/// gets it once it runs again. pub fn send(daemon: &Daemon, params: MessagesSendParams) -> ApiResult { - let body = validate::message("body", ¶ms.body)?; + let uploads = params.attachments.unwrap_or_default(); + let body = if uploads.is_empty() || !params.body.trim().is_empty() { + validate::message("body", ¶ms.body)? + } else { + // Files alone are a message too. + String::new() + }; let store = daemon.store(); let (crew, bot) = bots::active(&store, ¶ms.bot_id)?; + let now = daemon.clock.now_ms(); + let workspace = daemon.paths.bot_workspace(&crew.slug, &bot.slug); + let attachments = + attachments::save(&workspace, uploads, daemon.bots.attachment_max_bytes, now)?; let message = Message { id: MessageId::generate(), crew_id: crew.id, @@ -29,7 +41,8 @@ pub fn send(daemon: &Daemon, params: MessagesSendParams) -> ApiResult { kind: MessageKind::Note, body, task_id: None, - created_at: daemon.clock.now_ms(), + attachments, + created_at: now, }; post(daemon, &store, message, None) } @@ -43,8 +56,8 @@ pub(crate) fn post( task: Option, ) -> ApiResult { let delivery = pending_delivery(&message); - store.insert_message(&message, &delivery, task.as_ref())?; - announce(daemon, task, &message, delivery); + let item = store.insert_message(&message, &delivery, task.as_ref())?; + announce(daemon, task, &message, delivery, item); Ok(message) } @@ -58,18 +71,26 @@ pub(crate) fn pending_delivery(message: &Message) -> Delivery { attempts: 0, next_attempt_at: message.created_at, last_error: None, + read_at: None, updated_at: message.created_at, } } /// Tells the app about a stored message (and the task it changed) and wakes /// the courier for it. -pub(crate) fn announce(daemon: &Daemon, task: Option, message: &Message, delivery: Delivery) { +pub(crate) fn announce( + daemon: &Daemon, + task: Option, + message: &Message, + delivery: Delivery, + item: ChatItem, +) { if let Some(task) = task { daemon.emit(Event::TaskChanged(task)); } daemon.emit(Event::MessageCreated(message.clone())); daemon.emit(Event::DeliveryChanged(delivery)); + items::announce(daemon, item); daemon.courier.wake(); } diff --git a/crates/botloftd/src/service/mod.rs b/crates/botloftd/src/service/mod.rs index c87aacee..8318a352 100644 --- a/crates/botloftd/src/service/mod.rs +++ b/crates/botloftd/src/service/mod.rs @@ -1,12 +1,13 @@ //! What the RPC methods do, independent of JSON-RPC. Each operation runs //! under the store lock, so checks and writes cannot interleave. +pub mod attachments; pub mod bots; +pub mod chat; pub mod crews; pub mod deliveries; pub mod messages; pub mod tasks; -pub mod terminal; use botloft_core::protocol::{PROTOCOL_VERSION, SystemStatus, error_code}; use botloft_core::slug; @@ -66,6 +67,7 @@ pub fn status(daemon: &Daemon) -> ApiResult { claude_version: daemon.supervisor.claude_version(), runtime_error: daemon.supervisor.runtime_error(), deliveries, + usage: daemon.usage(), }) } diff --git a/crates/botloftd/src/service/tasks.rs b/crates/botloftd/src/service/tasks.rs index 3f9c79cd..fb1b647b 100644 --- a/crates/botloftd/src/service/tasks.rs +++ b/crates/botloftd/src/service/tasks.rs @@ -103,6 +103,7 @@ pub fn send( }, body, task_id: task.as_ref().map(|task| task.id.clone()), + attachments: Vec::new(), created_at: now, }; let message = post(daemon, &store, message, task.clone())?; @@ -225,10 +226,11 @@ pub fn complete( kind: MessageKind::Result, body: result.clone(), task_id: Some(task.id.clone()), + attachments: Vec::new(), created_at: now, }; let delivery = pending_delivery(&report); - let settled = store + let (settled, item) = store .settle_task( &task.id, &UNFINISHED, @@ -240,7 +242,7 @@ pub fn complete( .ok_or_else(|| { ApiError::Conflict(format!("task {task_id} changed meanwhile; check my_tasks")) })?; - announce(daemon, Some(settled.clone()), &report, delivery); + announce(daemon, Some(settled.clone()), &report, delivery, item); Ok(settled) } diff --git a/crates/botloftd/src/service/tasks/expire.rs b/crates/botloftd/src/service/tasks/expire.rs index 7e0e5ff1..98eafa16 100644 --- a/crates/botloftd/src/service/tasks/expire.rs +++ b/crates/botloftd/src/service/tasks/expire.rs @@ -42,6 +42,7 @@ fn expire(daemon: &Daemon, store: &Store, task: &Task, now: i64) -> ApiResult<() task.id ), task_id: Some(task.id.clone()), + attachments: Vec::new(), created_at: now, }; let delivery = pending_delivery(¬ice); @@ -54,8 +55,8 @@ fn expire(daemon: &Daemon, store: &Store, task: &Task, now: i64) -> ApiResult<() now, (¬ice, &delivery), )?; - if let Some(expired) = expired { - announce(daemon, Some(expired), ¬ice, delivery); + if let Some((expired, item)) = expired { + announce(daemon, Some(expired), ¬ice, delivery, item); } Ok(()) } diff --git a/crates/botloftd/src/service/terminal.rs b/crates/botloftd/src/service/terminal.rs deleted file mode 100644 index 4b764646..00000000 --- a/crates/botloftd/src/service/terminal.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! `terminal.*` operations (spec 8). Attaching needs the connection, so the -//! RPC layer streams the output; this module validates and prepares it. - -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; -use botloft_core::ids::BotId; -use botloft_core::protocol::{TerminalAttachParams, TerminalResizeParams, TerminalWriteParams}; -use bytes::Bytes; - -use super::{ApiError, ApiResult, bots}; -use crate::runtime::TermSize; -use crate::state::Daemon; -use crate::terminal::Attachment; - -/// Largest terminal the daemon accepts. -const MAX_CELLS: u16 = 1000; - -pub fn attach(daemon: &Daemon, params: TerminalAttachParams) -> ApiResult { - ensure_exists(daemon, ¶ms.bot_id)?; - let terminal = daemon.supervisor.terminal(¶ms.bot_id); - Ok(terminal.attach(params.generation, params.offset)) -} - -pub fn write(daemon: &Daemon, params: TerminalWriteParams) -> ApiResult<()> { - ensure_exists(daemon, ¶ms.bot_id)?; - let data = BASE64 - .decode(params.data.as_bytes()) - .map_err(|_| ApiError::validation("data must be base64"))?; - daemon - .supervisor - .write(¶ms.bot_id, Bytes::from(data)) - .map_err(|err| ApiError::Conflict(err.to_string())) -} - -pub fn resize(daemon: &Daemon, params: TerminalResizeParams) -> ApiResult<()> { - ensure_exists(daemon, ¶ms.bot_id)?; - let valid = |n: u16| (1..=MAX_CELLS).contains(&n); - if !valid(params.cols) || !valid(params.rows) { - return Err(ApiError::validation(format!( - "cols and rows must be between 1 and {MAX_CELLS}" - ))); - } - let size = TermSize { - cols: params.cols, - rows: params.rows, - }; - daemon.supervisor.resize(¶ms.bot_id, size); - Ok(()) -} - -/// Base64 for `terminal.data`. -pub fn encode(data: &[u8]) -> String { - BASE64.encode(data) -} - -fn ensure_exists(daemon: &Daemon, bot: &BotId) -> ApiResult<()> { - bots::find(&daemon.store(), bot).map(|_| ()) -} diff --git a/crates/botloftd/src/state.rs b/crates/botloftd/src/state.rs index b6468aa9..bbba196a 100644 --- a/crates/botloftd/src/state.rs +++ b/crates/botloftd/src/state.rs @@ -1,16 +1,19 @@ //! State shared by every connection: the store, the supervisor, the //! courier, the event bus and what the daemon knows about itself. -use std::path::PathBuf; use std::sync::{Arc, Mutex, MutexGuard}; -use std::time::Instant; +use std::time::{Duration, Instant}; -use botloft_core::protocol::{Bot, BotStateChanged, Crew, Delivery, Message, Task}; +use botloft_core::protocol::{ + AccountUsage, Bot, BotStateChanged, ChatDelta, ChatItemChanged, Crew, Delivery, Message, Task, +}; use botloft_store::Store; use tokio::sync::broadcast; +use crate::approvals::Approvals; use crate::clock::Clock; -use crate::courier::{Courier, CourierSettings, InboxWriter}; +use crate::config::Config; +use crate::courier::{Courier, CourierSettings}; use crate::paths::Paths; use crate::runtime::Runtime; use crate::secrets::TokenHash; @@ -19,11 +22,13 @@ use crate::supervisor::{Supervisor, SupervisorSettings}; use crate::workspace::WorkspaceEnv; /// Something that changed and every connected app should hear about. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq)] pub enum Event { CrewChanged(Crew), BotChanged(Bot), BotState(BotStateChanged), + ChatItem(ChatItemChanged), + ChatDelta(ChatDelta), MessageCreated(Message), DeliveryChanged(Delivery), TaskChanged(Task), @@ -32,34 +37,52 @@ pub enum Event { /// Events buffered per connection before a slow client is dropped. const EVENT_BUFFER: usize = 1024; +/// Settings for what bots may ask of the owner (spec 6, `[bots]`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BotSettings { + /// How long a permission request waits for the owner (spec 10.1). + pub approval_timeout: Duration, + /// Largest attachment, per file (spec 9.5). + pub attachment_max_bytes: u64, +} + +impl BotSettings { + pub fn from_config(config: &Config) -> Self { + Self { + approval_timeout: Duration::from_secs(config.bots.approval_timeout_minutes * 60), + attachment_max_bytes: config.bots.attachment_max_mb * 1024 * 1024, + } + } +} + pub struct DaemonOptions { pub paths: Paths, /// Port the daemon listens on, written into each bot's `mcp.json`. pub port: u16, - /// Absolute path of `botloftd.exe`, run by the bots' hooks. - pub bin: PathBuf, pub store: Store, pub owner_token: TokenHash, pub runtime: Arc, pub supervisor: SupervisorSettings, pub clock: Arc, - pub inbox: Arc, pub courier: CourierSettings, pub tasks: TaskSettings, + pub bots: BotSettings, } pub struct Daemon { pub paths: Paths, pub port: u16, - pub bin: PathBuf, pub supervisor: Supervisor, pub courier: Courier, pub tasks: TaskSettings, + pub bots: BotSettings, + pub approvals: Approvals, /// Time for everything stored or compared with stored times. pub clock: Arc, store: Mutex, owner_token: TokenHash, events: broadcast::Sender, + usage: Mutex>, started: Instant, } @@ -76,15 +99,17 @@ impl Daemon { options.supervisor, events.clone(), ), - courier: Courier::new(options.courier, options.inbox), + courier: Courier::new(options.courier), tasks: options.tasks, + bots: options.bots, + approvals: Approvals::default(), clock: options.clock, paths: options.paths, port: options.port, - bin: options.bin, store: Mutex::new(options.store), owner_token: options.owner_token, events, + usage: Mutex::new(None), started: Instant::now(), }) } @@ -102,8 +127,8 @@ impl Daemon { pub fn workspace_env(&self) -> WorkspaceEnv<'_> { WorkspaceEnv { paths: &self.paths, - bin: &self.bin, port: self.port, + approval_timeout: self.bots.approval_timeout, } } @@ -120,6 +145,21 @@ impl Daemon { let _ = self.events.send(event); } + /// The account usage Claude Code last reported (spec 8.1). + pub fn usage(&self) -> Option { + self.usage + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + pub(crate) fn set_usage(&self, usage: AccountUsage) { + *self + .usage + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(usage); + } + pub fn uptime_ms(&self) -> i64 { i64::try_from(self.started.elapsed().as_millis()).unwrap_or(i64::MAX) } diff --git a/crates/botloftd/src/supervisor/mod.rs b/crates/botloftd/src/supervisor/mod.rs index 4cbc2873..cc3358de 100644 --- a/crates/botloftd/src/supervisor/mod.rs +++ b/crates/botloftd/src/supervisor/mod.rs @@ -1,6 +1,6 @@ //! Keeps every active bot running (spec 7): starts processes, follows their -//! state through hooks, restarts them with backoff and stops them when they -//! are paused or archived. +//! state through the events they print, restarts them with backoff and +//! stops them when they are paused or archived. //! //! Lock order: the store lock may be held while taking the supervisor lock //! (service calls read states), never the other way around. @@ -9,6 +9,7 @@ mod reconcile; mod settings; mod slot; mod spawn; +mod turns; use std::collections::HashMap; use std::sync::atomic::AtomicU64; @@ -21,17 +22,15 @@ use botloft_core::protocol::{BotState, BotStateChanged}; use bytes::Bytes; use tokio::sync::{Notify, broadcast}; use tokio::time::Instant; -use tracing::{debug, warn}; +use tracing::warn; use self::reconcile::slot_entry; pub use self::settings::{ClaudeSource, SupervisorSettings}; -pub use self::slot::{Hook, Inbox}; -use self::slot::{Slot, StopIntent, state_after_hook}; +use self::slot::{Slot, StopIntent}; +use crate::runtime::Runtime; use crate::runtime::claude::Claude; -use crate::runtime::{Runtime, TermSize}; use crate::secrets::TokenHash; use crate::state::{Daemon, Event}; -use crate::terminal::Terminal; /// How often the supervisor compares running processes with the database. const RECONCILE_EVERY: Duration = Duration::from_secs(5); @@ -58,6 +57,9 @@ struct Inner { slots: HashMap, /// Token hash of each running generation -> its bot. tokens: HashMap, + /// The conversation each bot resumes, as far as this run knows; the + /// database has it too (spec 7.3). + sessions: HashMap, claude: ClaudeStatus, } @@ -126,51 +128,35 @@ impl Supervisor { } } - pub fn terminal(&self, bot: &BotId) -> Arc { - let mut inner = self.lock(); - Arc::clone(&self.slot(&mut inner, bot).terminal) - } - fn slot<'a>(&self, inner: &'a mut Inner, bot: &BotId) -> &'a mut Slot { slot_entry(&mut inner.slots, bot, &self.settings) } - /// Sends keystrokes to the bot. Answering a permission prompt resumes - /// the turn (spec 7.2). - pub fn write(&self, bot: &BotId, data: Bytes) -> Result<(), NotRunning> { + /// Writes one stream-json line to the bot's stdin (spec 9.2). A message + /// is one turn more for the bot. Returns the generation it went to. + pub fn write_message(&self, bot: &BotId, line: Bytes) -> Result { let mut inner = self.lock(); let slot = self.slot(&mut inner, bot); - let running = slot.running.as_ref().ok_or(NotRunning)?; - running.control.write(data).map_err(|_| NotRunning)?; - if slot.state == BotState::NeedsApproval { - self.set_state(bot, slot, BotState::Busy); + let (Some(running), Some(generation)) = (&slot.running, slot.generation) else { + return Err(NotRunning); + }; + if slot.stop.is_some() { + return Err(NotRunning); + } + running.control.write(line).map_err(|_| NotRunning)?; + slot.turns += 1; + if slot.is_working() { + self.set_state(bot, slot, slot.working_state()); } - Ok(()) + Ok(generation) } - /// Answers the process of `generation` on behalf of the terminal. - pub(crate) fn reply(&self, bot: &BotId, generation: u64, data: &'static [u8]) { - let inner = self.lock(); - let running = inner + /// Whether `generation` is the bot's running process. + pub fn is_current(&self, bot: &BotId, generation: u64) -> bool { + self.lock() .slots .get(bot) - .filter(|slot| slot.generation == Some(generation)) - .and_then(|slot| slot.running.as_ref()); - if let Some(running) = running { - let _ = running.control.write(Bytes::from_static(data)); - } - } - - /// Remembers the size for the next start and applies it now if running. - pub fn resize(&self, bot: &BotId, size: TermSize) { - let mut inner = self.lock(); - let slot = self.slot(&mut inner, bot); - slot.size = size; - if let Some(running) = &slot.running - && let Err(err) = running.control.resize(size) - { - debug!(bot = %bot, "resize failed: {err}"); - } + .is_some_and(|slot| slot.generation == Some(generation) && slot.running.is_some()) } /// Restarts the bot, also out of `auth_error`. The caller checked that @@ -198,40 +184,14 @@ impl Supervisor { self.wake(); } - /// The bot and generation a hook token belongs to. - pub fn hook_owner(&self, token: &str) -> Option<(BotId, u64)> { + /// The bot and generation an MCP token belongs to. + pub fn token_owner(&self, token: &str) -> Option<(BotId, u64)> { self.lock() .tokens .get(&TokenHash::of(token).to_hex()) .cloned() } - pub fn on_hook(&self, bot: &BotId, generation: u64, hook: Hook) { - let mut inner = self.lock(); - let slot = self.slot(&mut inner, bot); - if slot.generation != Some(generation) || slot.running.is_none() { - return; - } - debug!(bot = %bot, generation, ?hook, "hook"); - if let Hook::SessionStart { inbox } = &hook { - slot.inbox.clone_from(inbox); - if let Some(running) = &slot.running { - spawn::mark_started(&running.workspace); - } - } - if let Some(next) = state_after_hook(slot.state, &hook) { - self.set_state(bot, slot, next); - } - } - - /// Where to deliver messages to the bot's current generation. - pub fn inbox(&self, bot: &BotId) -> Option { - self.lock() - .slots - .get(bot) - .and_then(|slot| slot.inbox.clone()) - } - fn set_state(&self, bot: &BotId, slot: &mut Slot, state: BotState) { if slot.state != state { slot.state = state; diff --git a/crates/botloftd/src/supervisor/reconcile.rs b/crates/botloftd/src/supervisor/reconcile.rs index 73afb059..9f448015 100644 --- a/crates/botloftd/src/supervisor/reconcile.rs +++ b/crates/botloftd/src/supervisor/reconcile.rs @@ -23,13 +23,21 @@ impl Supervisor { return; }; self.ensure_claude().await; + // Read before taking the supervisor lock (lock order, see mod.rs). let read = { let store = daemon.store(); - store - .bots(None, true) - .and_then(|bots| Ok((bots, store.crews(true)?))) + store.bots(None, true).and_then(|bots| { + let crews = store.crews(true)?; + let mut sessions = HashMap::new(); + for bot in &bots { + if let Some(session) = store.session_id(&bot.id)? { + sessions.insert(bot.id.clone(), session); + } + } + Ok((bots, crews, sessions)) + }) }; - let (bots, crews) = match read { + let (bots, crews, sessions) = match read { Ok(read) => read, Err(err) => { warn!("reconcile could not read the database: {err}"); @@ -40,8 +48,12 @@ impl Supervisor { .into_iter() .map(|crew| (crew.id.clone(), crew)) .collect(); + self.release_limits(daemon.clock.now_ms()); let now = Instant::now(); let mut inner = self.lock(); + for (bot, session) in sessions { + inner.sessions.entry(bot).or_insert(session); + } for bot in &bots { if let Some(crew) = crews.get(&bot.crew_id) { self.reconcile_bot(&mut inner, &daemon, crew, bot, now); @@ -164,6 +176,6 @@ pub(super) fn slot_entry<'a>( ) -> &'a mut Slot { slots.entry(bot.clone()).or_insert_with(|| { let backoff = Backoff::new(settings.backoff_initial, settings.backoff_max); - Slot::new(settings.ring_buffer_bytes, backoff) + Slot::new(backoff) }) } diff --git a/crates/botloftd/src/supervisor/settings.rs b/crates/botloftd/src/supervisor/settings.rs index 3a0e3328..4422e38c 100644 --- a/crates/botloftd/src/supervisor/settings.rs +++ b/crates/botloftd/src/supervisor/settings.rs @@ -19,7 +19,9 @@ pub struct SupervisorSettings { pub backoff_initial: Duration, pub backoff_max: Duration, pub fresh_start_if_dies_within: Duration, - pub ring_buffer_bytes: usize, + /// A new process is taken as ready after living this long; Claude Code + /// says nothing until the first message (spec 7.2). + pub ready_after: Duration, } impl SupervisorSettings { @@ -33,7 +35,7 @@ impl SupervisorSettings { fresh_start_if_dies_within: Duration::from_secs( config.supervisor.fresh_start_if_dies_within_s, ), - ring_buffer_bytes: config.terminal.ring_buffer_bytes, + ready_after: Duration::from_millis(1500), } } } diff --git a/crates/botloftd/src/supervisor/slot.rs b/crates/botloftd/src/supervisor/slot.rs index bda6ef09..2f911930 100644 --- a/crates/botloftd/src/supervisor/slot.rs +++ b/crates/botloftd/src/supervisor/slot.rs @@ -1,15 +1,11 @@ -//! Per-bot supervision state and the transition rules of spec 7.2. +//! Per-bot supervision state and restart backoff (spec 7). -use std::fmt; -use std::path::PathBuf; -use std::sync::Arc; use std::time::Duration; use botloft_core::protocol::BotState; use tokio::time::Instant; -use crate::runtime::{ProcessControl, TermSize}; -use crate::terminal::Terminal; +use crate::runtime::ProcessControl; pub(super) struct Slot { pub state: BotState, @@ -18,18 +14,20 @@ pub(super) struct Slot { pub running: Option, pub backoff: Backoff, pub restart_at: Option, - /// Start the next process without `--continue`. + /// Start the next process with a new conversation. pub fresh_next: bool, /// Why the running process is being killed, if the daemon asked. pub stop: Option, - pub size: TermSize, - pub terminal: Arc, - /// Where to deliver messages to this generation (spec 9.2); memory only. - pub inbox: Option, + /// Messages written to the process whose turn has not ended yet. + pub turns: u32, + /// Permission requests waiting for the owner. + pub approvals: u32, + /// While `rate_limited`: Unix ms when the limit resets. + pub limited_until: Option, } impl Slot { - pub fn new(ring_buffer_bytes: usize, backoff: Backoff) -> Self { + pub fn new(backoff: Backoff) -> Self { Self { state: BotState::Offline, generation: None, @@ -38,20 +36,39 @@ impl Slot { restart_at: None, fresh_next: false, stop: None, - size: TermSize::default(), - terminal: Arc::new(Terminal::new(ring_buffer_bytes)), - inbox: None, + turns: 0, + approvals: 0, + limited_until: None, } } + + /// The state that follows from the counters once nothing blocks the bot. + pub fn working_state(&self) -> BotState { + if self.approvals > 0 { + BotState::NeedsApproval + } else if self.turns > 0 { + BotState::Busy + } else { + BotState::Idle + } + } + + /// Whether the counters decide the state, rather than a launch, a + /// limit, a sign-in problem or a stop. + pub fn is_working(&self) -> bool { + matches!( + self.state, + BotState::Idle | BotState::Busy | BotState::NeedsApproval + ) + } } pub(super) struct Running { pub control: Box, pub started: Instant, - /// Started with `--continue`. + /// Started with `--resume`. pub resumed: bool, pub token_hash: String, - pub workspace: PathBuf, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -59,62 +76,10 @@ pub(super) enum StopIntent { Restart { fresh: bool, }, - /// Paused or archived: settle in this state. + /// Paused, archived or signed out: settle in this state. Halt(BotState), } -/// The bot's inbox, reported by the `SessionStart` hook. -#[derive(Clone, PartialEq, Eq)] -pub struct Inbox { - pub socket: String, - pub token: String, -} - -impl fmt::Debug for Inbox { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Inbox") - .field("socket", &self.socket) - .field("token", &"") - .finish() - } -} - -/// What a hook reported. Payload fields as documented by Claude Code. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Hook { - SessionStart { - inbox: Option, - }, - PromptSubmit, - Stop, - /// `error`: `rate_limit`, `authentication_failed`, ... - StopFailure { - error: String, - }, - /// `notification_type`: `permission_prompt`, `idle_prompt`, ... - Notification { - kind: String, - }, - SessionEnd, -} - -/// The state a hook moves the bot to, if it changes it (spec 7.2). -pub(super) fn state_after_hook(current: BotState, hook: &Hook) -> Option { - let next = match hook { - Hook::SessionStart { .. } | Hook::Stop => BotState::Idle, - Hook::PromptSubmit => BotState::Busy, - Hook::StopFailure { error } => match error.as_str() { - "rate_limit" => BotState::RateLimited, - "authentication_failed" | "oauth_org_not_allowed" => BotState::AuthError, - // The turn ended; the session is still usable. - _ => BotState::Idle, - }, - Hook::Notification { kind } if kind == "permission_prompt" => BotState::NeedsApproval, - Hook::Notification { .. } | Hook::SessionEnd => return None, - }; - (next != current).then_some(next) -} - /// Exponential restart delay with jitter (spec 7.3). #[derive(Debug, Clone)] pub(super) struct Backoff { @@ -159,43 +124,14 @@ fn random_below(bound: u64) -> u64 { mod tests { use super::*; - fn after(current: BotState, hook: Hook) -> Option { - state_after_hook(current, &hook) - } - #[test] - fn hooks_move_the_bot_through_its_states() { - use BotState::*; - assert_eq!( - after(Launching, Hook::SessionStart { inbox: None }), - Some(Idle) - ); - assert_eq!(after(Idle, Hook::PromptSubmit), Some(Busy)); - assert_eq!(after(Busy, Hook::Stop), Some(Idle)); - let permission = Hook::Notification { - kind: "permission_prompt".into(), - }; - assert_eq!(after(Busy, permission), Some(NeedsApproval)); - let idle_prompt = Hook::Notification { - kind: "idle_prompt".into(), - }; - assert_eq!(after(Idle, idle_prompt), None); - assert_eq!(after(Idle, Hook::SessionEnd), None); - assert_eq!(after(Idle, Hook::Stop), None, "no change, no event"); - } - - #[test] - fn stop_failures_map_to_their_states() { - use BotState::*; - let failure = |error: &str| Hook::StopFailure { - error: error.into(), - }; - assert_eq!(after(Busy, failure("rate_limit")), Some(RateLimited)); - assert_eq!( - after(Busy, failure("authentication_failed")), - Some(AuthError) - ); - assert_eq!(after(Busy, failure("server_error")), Some(Idle)); + fn counters_decide_the_working_state() { + let mut slot = Slot::new(Backoff::new(Duration::ZERO, Duration::ZERO)); + assert_eq!(slot.working_state(), BotState::Idle); + slot.turns = 2; + assert_eq!(slot.working_state(), BotState::Busy); + slot.approvals = 1; + assert_eq!(slot.working_state(), BotState::NeedsApproval); } #[test] @@ -219,13 +155,4 @@ mod tests { backoff.reset(); assert!(backoff.next_delay() <= Duration::from_secs(1)); } - - #[test] - fn inbox_debug_hides_the_token() { - let inbox = Inbox { - socket: r"\\.\pipe\x".into(), - token: "secret".into(), - }; - assert!(!format!("{inbox:?}").contains("secret")); - } } diff --git a/crates/botloftd/src/supervisor/spawn.rs b/crates/botloftd/src/supervisor/spawn.rs index dcd8f296..9b2ff1bd 100644 --- a/crates/botloftd/src/supervisor/spawn.rs +++ b/crates/botloftd/src/supervisor/spawn.rs @@ -2,34 +2,29 @@ use std::ffi::OsString; use std::io; -use std::path::{Path, PathBuf}; use std::sync::{Arc, Weak}; -use std::time::Duration; -use botloft_core::ids::BotId; +use botloft_core::ids::{BotId, random_uuid}; use botloft_core::protocol::{BotState, Crew}; use botloft_store::BotRecord; -use bytes::BytesMut; use tokio::sync::mpsc; use tokio::time::Instant; use tracing::{debug, warn}; use super::slot::{Running, StopIntent}; use super::{ClaudeStatus, Inner, STABLE_AFTER, Supervisor}; +use crate::chat::StreamReader; use crate::platform; use crate::runtime::claude::Claude; use crate::runtime::{ProcessEvent, SpawnSpec}; use crate::secrets::{self, TokenHash}; use crate::state::Daemon; -use crate::terminal::{CursorQueryWatch, Terminal}; -use crate::workspace; +use crate::{approvals, courier, workspace}; -/// Output read within this window goes out as one chunk (spec 8). -const COALESCE_WINDOW: Duration = Duration::from_millis(8); -const COALESCE_MAX: usize = 32 * 1024; -/// Written after the first `SessionStart`; without it there is no -/// conversation to `--continue`. -const STARTED_MARKER: &str = ".botloft/started"; +/// Tools the bot uses without asking: its crew tools (spec 7.4). +const ALLOWED_TOOLS: &str = "mcp__botloft"; +/// Where permission requests go (spec 10.1). +const PERMISSION_TOOL: &str = "mcp__botloft__permission_prompt"; impl Supervisor { pub(super) fn start( @@ -42,6 +37,7 @@ impl Supervisor { let Inner { slots, tokens, + sessions, claude, } = inner; let Some(slot) = slots.get_mut(&bot.id) else { @@ -54,32 +50,39 @@ impl Supervisor { let generation = self .next_generation .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let launched = launch_spec(daemon, crew, bot, claude, slot.fresh_next, slot.size) + let session = if slot.fresh_next { + None + } else { + sessions.get(&bot.id).cloned() + }; + let launched = launch_spec(daemon, crew, bot, claude, session.as_deref()) .and_then(|(spec, launch)| Ok((self.runtime.spawn(spec)?, launch))); match launched { Ok((process, launch)) => { + sessions.insert(bot.id.clone(), launch.session.clone()); slot.generation = Some(generation); - slot.terminal.reset(generation); slot.restart_at = None; slot.fresh_next = false; - slot.inbox = None; + slot.turns = 0; + slot.approvals = 0; + slot.limited_until = None; tokens.insert(launch.token_hash.clone(), (bot.id.clone(), generation)); slot.running = Some(Running { control: process.control, started: Instant::now(), resumed: launch.resumed, token_hash: launch.token_hash, - workspace: launch.workspace, }); slot.state = BotState::Launching; self.announce(&bot.id, slot); debug!(bot = %bot.id, generation, pid = ?process.pid, resumed = launch.resumed, "bot started"); - tokio::spawn(pump( + let reader = StreamReader::new(bot.id.clone(), generation); + tokio::spawn(pump(self.daemon.clone(), reader, process.events)); + tokio::spawn(settle( self.daemon.clone(), bot.id.clone(), generation, - Arc::clone(&slot.terminal), - process.events, + self.settings.ready_after, )); } Err(err) => { @@ -91,8 +94,15 @@ impl Supervisor { } /// The process of `generation` ended: restart it, or settle if the - /// daemon stopped it on purpose (spec 7.3). - pub(super) fn on_exit(&self, bot: &BotId, generation: u64, code: Option) { + /// daemon stopped it on purpose (spec 7.3). What it had not begun goes + /// back in the queue, and open approvals expire. + pub(super) fn on_exit( + &self, + daemon: &Arc, + bot: &BotId, + generation: u64, + code: Option, + ) { let mut inner = self.lock(); let Inner { slots, tokens, .. } = &mut *inner; let Some(slot) = slots.get_mut(bot) else { @@ -105,7 +115,9 @@ impl Supervisor { return; }; tokens.remove(&running.token_hash); - slot.inbox = None; + slot.turns = 0; + slot.approvals = 0; + slot.limited_until = None; let ran = running.started.elapsed(); let resumed = running.resumed; // Closing the job kills anything the bot left behind. @@ -121,8 +133,6 @@ impl Supervisor { slot.restart_at = None; self.set_state(bot, slot, state); } - // Restarting cannot fix credentials; the owner restarts it. - None if slot.state == BotState::AuthError => slot.restart_at = None, None => { if resumed && ran < self.settings.fresh_start_if_dies_within { slot.fresh_next = true; @@ -135,6 +145,8 @@ impl Supervisor { } } drop(inner); + approvals::expire_for_bot(daemon, bot); + courier::requeue_unread(daemon, bot, generation); self.wake(); } } @@ -142,31 +154,57 @@ impl Supervisor { struct Launch { token_hash: String, resumed: bool, - workspace: PathBuf, + session: String, } /// Command line and environment of spec 7.4, with a fresh bot token. +/// `session` is the conversation to resume; `None` starts a new one. fn launch_spec( daemon: &Daemon, crew: &Crew, bot: &BotRecord, claude: &Claude, - fresh: bool, - size: crate::runtime::TermSize, + session: Option<&str>, ) -> io::Result<(SpawnSpec, Launch)> { // settings.json and the rules are rewritten on every start. let workspace = workspace::prepare_bot(daemon.workspace_env(), crew, bot)?; - let resumed = !fresh && workspace.join(STARTED_MARKER).exists(); let token = secrets::random_token()?; + let (session, resumed) = match session { + Some(session) => (session.to_owned(), true), + None => (random_uuid(), false), + }; - let mut args: Vec = Vec::new(); - if resumed { - args.push("--continue".into()); + let mcp = workspace.join(".botloft").join("mcp.json"); + let mut args: Vec = [ + "-p", + "--input-format", + "stream-json", + "--output-format", + "stream-json", + "--verbose", + "--include-partial-messages", + "--replay-user-messages", + ] + .into_iter() + .map(OsString::from) + .collect(); + args.push(if resumed { "--resume" } else { "--session-id" }.into()); + args.push(session.clone().into()); + for arg in [ + "--setting-sources", + "project,local", + "--strict-mcp-config", + "--permission-mode", + "default", + "--permission-prompt-tool", + PERMISSION_TOOL, + "--allowedTools", + ALLOWED_TOOLS, + "--mcp-config", + ] { + args.push(arg.into()); } - args.push("--settings".into()); - args.push(workspace.join(".claude").join("settings.json").into()); - args.push("--mcp-config".into()); - args.push(workspace.join(".botloft").join("mcp.json").into()); + args.push(mcp.into_os_string()); let mut env = platform::user_environment()?; env.retain(|(name, _)| { @@ -179,8 +217,6 @@ fn launch_spec( ("BOTLOFT_BOT_ID", OsString::from(bot.id.as_str())), ("BOTLOFT_BOT_TOKEN", OsString::from(&token)), ("BOTLOFT_PORT", OsString::from(daemon.port.to_string())), - ("BOTLOFT_BIN", daemon.bin.clone().into_os_string()), - ("BOTLOFT_HOME", daemon.paths.home.clone().into_os_string()), ] { env.push((OsString::from(name), value)); } @@ -190,113 +226,47 @@ fn launch_spec( args, cwd: workspace.clone(), env, - size, }; Ok(( spec, Launch { token_hash: TokenHash::of(&token).to_hex(), resumed, - workspace, + session, }, )) } -pub(super) fn mark_started(workspace: &Path) { - let marker = workspace.join(STARTED_MARKER); - if !marker.exists() - && let Err(err) = std::fs::write(&marker, b"") - { - warn!("could not write {}: {err}", marker.display()); +/// Claude Code says nothing until the first message, so a process that +/// is still alive after a moment counts as ready (spec 7.2). +async fn settle(daemon: Weak, bot: BotId, generation: u64, after: std::time::Duration) { + tokio::time::sleep(after).await; + if let Some(daemon) = daemon.upgrade() { + daemon.supervisor.ready(&bot, generation); } } -/// Moves output into the terminal until the process exits, then reports it. +/// Feeds stdout to the chat until the process exits, then reports it. async fn pump( daemon: Weak, - bot: BotId, - generation: u64, - terminal: Arc, + mut reader: StreamReader, mut events: mpsc::Receiver, ) { - let mut cursor_query = CursorQueryWatch::default(); - let code = coalesce(&mut events, |data| { - terminal.push(generation, data); - if cursor_query.feed(data) - && let Some(daemon) = daemon.upgrade() - { - daemon - .supervisor - .reply(&bot, generation, CursorQueryWatch::ANSWER); - } - }) - .await; - if let Some(daemon) = daemon.upgrade() { - daemon.supervisor.on_exit(&bot, generation, code); - } -} - -/// Joins output arriving within [`COALESCE_WINDOW`] (up to -/// [`COALESCE_MAX`]) and hands each batch to `sink`. Returns the exit code. -pub(crate) async fn coalesce( - events: &mut mpsc::Receiver, - mut sink: impl FnMut(&[u8]), -) -> Option { - loop { - let mut batch = match events.recv().await { - Some(ProcessEvent::Output(data)) => BytesMut::from(&data[..]), - Some(ProcessEvent::Exited(code)) => return code, - None => return None, - }; - let deadline = Instant::now() + COALESCE_WINDOW; - let mut end = None; - while batch.len() < COALESCE_MAX { - match tokio::time::timeout_at(deadline, events.recv()).await { - Ok(Some(ProcessEvent::Output(data))) => batch.extend_from_slice(&data), - Ok(Some(ProcessEvent::Exited(code))) => { - end = Some(code); - break; - } - Ok(None) => { - end = Some(None); - break; - } - Err(_) => break, + let code = loop { + match events.recv().await { + Some(ProcessEvent::Output(data)) => { + let Some(daemon) = daemon.upgrade() else { + return; + }; + reader.feed(&daemon, &data); } + Some(ProcessEvent::Exited(code)) => break code, + None => break None, } - sink(&batch); - if let Some(code) = end { - return code; - } - } -} - -#[cfg(test)] -mod tests { - use bytes::Bytes; - - use super::*; - - #[tokio::test(start_paused = true)] - async fn output_within_the_window_is_one_chunk() { - let (tx, mut rx) = mpsc::channel(8); - tx.send(ProcessEvent::Output(Bytes::from_static(b"ab"))) - .await - .expect("send"); - tx.send(ProcessEvent::Output(Bytes::from_static(b"cd"))) - .await - .expect("send"); - let late = tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(50)).await; - tx.send(ProcessEvent::Output(Bytes::from_static(b"ef"))) - .await - .expect("send"); - tx.send(ProcessEvent::Exited(Some(3))).await.expect("send"); - }); - let mut chunks = Vec::new(); - let code = coalesce(&mut rx, |data| chunks.push(data.to_vec())).await; - late.await.expect("sender"); - assert_eq!(code, Some(3)); - assert_eq!(chunks, vec![b"abcd".to_vec(), b"ef".to_vec()]); + }; + if let Some(daemon) = daemon.upgrade() { + daemon + .supervisor + .on_exit(&daemon, reader.bot(), reader.generation(), code); } } diff --git a/crates/botloftd/src/supervisor/turns.rs b/crates/botloftd/src/supervisor/turns.rs new file mode 100644 index 00000000..81721eb9 --- /dev/null +++ b/crates/botloftd/src/supervisor/turns.rs @@ -0,0 +1,110 @@ +//! State changes driven by what the bot's process prints and by approvals +//! (spec 7.2). Each call names the generation it is about; news from a +//! process that is gone is ignored. + +use botloft_core::ids::BotId; +use botloft_core::protocol::BotState; +use tracing::{debug, warn}; + +use super::slot::{Slot, StopIntent}; +use super::{Inner, Supervisor}; + +impl Supervisor { + /// Runs `change` on the slot of `bot` if `generation` is still running. + fn with_current(&self, bot: &BotId, generation: u64, change: impl FnOnce(&Self, &mut Slot)) { + let mut inner = self.lock(); + let Inner { slots, .. } = &mut *inner; + if let Some(slot) = slots.get_mut(bot) + && slot.generation == Some(generation) + && slot.running.is_some() + { + change(self, slot); + } + } + + /// The conversation the bot is in, from `system/init`; the next start + /// resumes it (spec 7.3). + pub(crate) fn remember_session(&self, bot: &BotId, session: &str) { + self.lock().sessions.insert(bot.clone(), session.to_owned()); + } + + /// The process lived through its first moments: it is ready. + pub(crate) fn ready(&self, bot: &BotId, generation: u64) { + self.with_current(bot, generation, |supervisor, slot| { + if slot.state == BotState::Launching { + supervisor.set_state(bot, slot, slot.working_state()); + } + }); + } + + /// A `result` closed one turn. + pub(crate) fn turn_ended(&self, bot: &BotId, generation: u64) { + self.with_current(bot, generation, |supervisor, slot| { + slot.turns = slot.turns.saturating_sub(1); + if slot.is_working() { + supervisor.set_state(bot, slot, slot.working_state()); + } + }); + } + + /// A tool waits for the owner (spec 10.1). + pub fn approval_opened(&self, bot: &BotId, generation: u64) { + self.with_current(bot, generation, |supervisor, slot| { + slot.approvals += 1; + if slot.is_working() { + supervisor.set_state(bot, slot, slot.working_state()); + } + }); + } + + pub fn approval_closed(&self, bot: &BotId, generation: u64) { + self.with_current(bot, generation, |supervisor, slot| { + slot.approvals = slot.approvals.saturating_sub(1); + if slot.is_working() { + supervisor.set_state(bot, slot, slot.working_state()); + } + }); + } + + /// The account hit its usage limit until `until` (Unix ms). Messages + /// wait; the bot goes back to work once the limit resets. + pub(crate) fn rate_limited(&self, bot: &BotId, generation: u64, until: i64) { + self.with_current(bot, generation, |supervisor, slot| { + slot.limited_until = Some(until); + if slot.is_working() || slot.state == BotState::RateLimited { + debug!(bot = %bot, until, "rate limited"); + supervisor.set_state(bot, slot, BotState::RateLimited); + } + }); + } + + /// Claude Code is not signed in: restarting cannot fix that, so the + /// process stops until the owner restarts the bot (spec 7.3). + pub(crate) fn signed_out(&self, bot: &BotId, generation: u64) { + self.with_current(bot, generation, |supervisor, slot| { + supervisor.set_state(bot, slot, BotState::AuthError); + if slot.stop.is_none() { + slot.stop = Some(StopIntent::Halt(BotState::AuthError)); + if let Some(running) = &slot.running + && let Err(err) = running.control.kill() + { + warn!(bot = %bot, "could not stop a signed-out bot: {err}"); + } + } + }); + } + + /// Ends every rate limit whose time has come (checked while reconciling). + pub(super) fn release_limits(&self, now_ms: i64) { + let mut inner = self.lock(); + let Inner { slots, .. } = &mut *inner; + for (bot, slot) in slots.iter_mut() { + let over = slot.limited_until.is_some_and(|until| until <= now_ms); + if slot.state == BotState::RateLimited && over && slot.running.is_some() { + slot.limited_until = None; + let next = slot.working_state(); + self.set_state(bot, slot, next); + } + } + } +} diff --git a/crates/botloftd/src/terminal.rs b/crates/botloftd/src/terminal.rs deleted file mode 100644 index 074bf886..00000000 --- a/crates/botloftd/src/terminal.rs +++ /dev/null @@ -1,296 +0,0 @@ -//! A bot's terminal output (spec 8): a ring buffer addressed by byte offset -//! within a process generation, and a broadcast of new output to every -//! attached client. - -use std::collections::VecDeque; -use std::sync::Mutex; - -use bytes::Bytes; -use tokio::sync::broadcast; - -/// Output chunks a slow client may lag behind before it has to re-attach. -const LIVE_BUFFER: usize = 256; - -/// Output of one generation, starting at `offset`. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Chunk { - pub generation: u64, - pub offset: u64, - pub data: Bytes, -} - -/// Where a client picks up after `attach`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct AttachPoint { - pub generation: u64, - /// Offset of the first byte of the replay. - pub offset: u64, - /// The client must clear its screen: it saw another generation, or the - /// bytes after its offset are no longer buffered. - pub reset: bool, - /// End of the replay, where live output starts. - pub live_offset: u64, -} - -pub struct Attachment { - pub point: AttachPoint, - /// Buffered bytes from `point.offset`, possibly empty. - pub replay: Bytes, - /// Everything pushed after the replay, with no gap and no overlap. - pub live: broadcast::Receiver, -} - -pub struct Terminal { - inner: Mutex, - live: broadcast::Sender, -} - -struct Inner { - generation: u64, - capacity: usize, - /// Offset of `buf[0]` within the generation. - start: u64, - buf: VecDeque, -} - -impl Inner { - fn end(&self) -> u64 { - self.start + self.buf.len() as u64 - } -} - -impl Terminal { - pub fn new(capacity: usize) -> Self { - let (live, _) = broadcast::channel(LIVE_BUFFER); - Self { - inner: Mutex::new(Inner { - generation: 0, - capacity: capacity.max(1), - start: 0, - buf: VecDeque::new(), - }), - live, - } - } - - fn lock(&self) -> std::sync::MutexGuard<'_, Inner> { - self.inner - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } - - pub fn generation(&self) -> u64 { - self.lock().generation - } - - /// Starts a new generation with an empty buffer. - pub fn reset(&self, generation: u64) { - let mut inner = self.lock(); - inner.generation = generation; - inner.start = 0; - inner.buf.clear(); - } - - /// Appends output of `generation`; output of an older one is dropped. - pub fn push(&self, generation: u64, data: &[u8]) { - if data.is_empty() { - return; - } - let mut inner = self.lock(); - if inner.generation != generation { - return; - } - let offset = inner.end(); - inner.buf.extend(data); - let excess = inner.buf.len().saturating_sub(inner.capacity); - if excess > 0 { - inner.buf.drain(..excess); - inner.start += excess as u64; - } - // Sent under the lock so attach() never sees a chunk twice or misses one. - let _ = self.live.send(Chunk { - generation, - offset, - data: Bytes::copy_from_slice(data), - }); - } - - /// Replays from what the client last saw when possible (spec 8). - pub fn attach(&self, generation: Option, offset: Option) -> Attachment { - let inner = self.lock(); - let live = self.live.subscribe(); - let resumable = match (generation, offset) { - (Some(g), Some(o)) => g == inner.generation && (inner.start..=inner.end()).contains(&o), - _ => false, - }; - let from = if resumable { - offset.unwrap_or(inner.start) - } else { - reset_point(&inner) - }; - let skip = usize::try_from(from - inner.start).unwrap_or(usize::MAX); - let replay: Vec = inner.buf.iter().skip(skip).copied().collect(); - Attachment { - point: AttachPoint { - generation: inner.generation, - offset: from, - reset: !resumable, - live_offset: inner.end(), - }, - replay: Bytes::from(replay), - live, - } - } -} - -/// Finds the first cursor position query (`ESC [ 6 n`) in a byte stream, -/// even when it is split across chunks. -/// -/// ConPTY, as `portable-pty` creates it (`PSEUDOCONSOLE_INHERIT_CURSOR`), -/// asks the host terminal where the cursor is when it starts and holds the -/// child until it gets an answer. The daemon has no screen, so it answers -/// that first query itself; later queries are left to attached terminals. -#[derive(Debug, Default)] -pub struct CursorQueryWatch { - matched: usize, - seen: bool, -} - -impl CursorQueryWatch { - const QUERY: &'static [u8] = b"\x1b[6n"; - /// Row 1, column 1: a fresh screen. - pub const ANSWER: &'static [u8] = b"\x1b[1;1R"; - - /// Feeds output; true exactly once, when the first query completes. - pub fn feed(&mut self, data: &[u8]) -> bool { - if self.seen { - return false; - } - for &byte in data { - self.matched = if byte == Self::QUERY[self.matched] { - self.matched + 1 - } else if byte == Self::QUERY[0] { - 1 - } else { - 0 - }; - if self.matched == Self::QUERY.len() { - self.seen = true; - return true; - } - } - false - } -} - -/// Where a full replay starts: the whole generation when nothing was dropped, -/// otherwise after the first newline so it never opens mid escape sequence. -fn reset_point(inner: &Inner) -> u64 { - if inner.start == 0 { - return 0; - } - match inner.buf.iter().position(|&b| b == b'\n') { - Some(newline) => inner.start + newline as u64 + 1, - None => inner.start, - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn text(bytes: &Bytes) -> &str { - std::str::from_utf8(bytes).expect("utf8") - } - - #[test] - fn replays_only_what_the_client_missed() { - let term = Terminal::new(1024); - term.reset(7); - term.push(7, b"hello "); - term.push(7, b"world"); - - let fresh = term.attach(None, None); - assert_eq!( - fresh.point, - AttachPoint { - generation: 7, - offset: 0, - reset: true, - live_offset: 11 - } - ); - assert_eq!(text(&fresh.replay), "hello world"); - - let resumed = term.attach(Some(7), Some(6)); - assert_eq!( - resumed.point, - AttachPoint { - generation: 7, - offset: 6, - reset: false, - live_offset: 11 - } - ); - assert_eq!(text(&resumed.replay), "world"); - - let up_to_date = term.attach(Some(7), Some(11)); - assert!(!up_to_date.point.reset); - assert!(up_to_date.replay.is_empty()); - } - - #[test] - fn another_generation_or_a_dropped_offset_resets() { - let term = Terminal::new(8); - term.reset(1); - term.push(1, b"ab\ncdefgh\nij"); - // Only the last 8 bytes stay: "defgh\nij". - let old = term.attach(Some(1), Some(0)); - assert!(old.point.reset); - assert_eq!(text(&old.replay), "ij", "cut after the first newline"); - - term.reset(2); - term.push(2, b"new"); - let stale = term.attach(Some(1), Some(3)); - assert_eq!( - stale.point, - AttachPoint { - generation: 2, - offset: 0, - reset: true, - live_offset: 3 - } - ); - assert_eq!(text(&stale.replay), "new"); - } - - #[test] - fn the_first_cursor_query_is_found_once_even_when_split() { - let mut watch = CursorQueryWatch::default(); - assert!(!watch.feed(b"banner \x1b[")); - assert!(watch.feed(b"6n more")); - assert!(!watch.feed(b"\x1b[6n"), "only the first one"); - - let mut near_miss = CursorQueryWatch::default(); - assert!(!near_miss.feed(b"\x1b[5n\x1b[?25l")); - assert!(near_miss.feed(b"\x1b\x1b[6n")); - } - - #[test] - fn live_output_follows_the_replay_without_gaps() { - let term = Terminal::new(64); - term.reset(3); - term.push(3, b"one"); - let mut attached = term.attach(None, None); - term.push(3, b"two"); - term.push(2, b"stale generation is dropped"); - - let chunk = attached.live.try_recv().expect("live chunk"); - assert_eq!( - chunk.offset, - attached.point.offset + attached.replay.len() as u64 - ); - assert_eq!(&chunk.data[..], b"two"); - assert!(attached.live.try_recv().is_err()); - } -} diff --git a/crates/botloftd/src/tools/catalog.rs b/crates/botloftd/src/tools/catalog.rs index c35dbea2..600073bb 100644 --- a/crates/botloftd/src/tools/catalog.rs +++ b/crates/botloftd/src/tools/catalog.rs @@ -9,6 +9,8 @@ pub const CREW_ROSTER: &str = "crew_roster"; pub const SEND_MESSAGE: &str = "send_message"; pub const COMPLETE_TASK: &str = "complete_task"; pub const MY_TASKS: &str = "my_tasks"; +/// Claude Code's `--permission-prompt-tool` (spec 10.1). +pub const PERMISSION_PROMPT: &str = "permission_prompt"; pub fn tools() -> Value { json!([ @@ -99,5 +101,20 @@ pub fn tools() -> Value { }, "annotations": { "readOnlyHint": true }, }, + { + "name": PERMISSION_PROMPT, + "title": "Permission prompt", + "description": "Used by Claude Code to ask the owner before a tool runs. Never call \ + it yourself: it decides nothing about what you do.", + "inputSchema": { + "type": "object", + "properties": { + "tool_name": { "type": "string" }, + "input": { "type": "object" }, + "tool_use_id": { "type": "string" }, + }, + "required": ["tool_name", "input"], + }, + }, ]) } diff --git a/crates/botloftd/src/tools/era.rs b/crates/botloftd/src/tools/era.rs new file mode 100644 index 00000000..6284a43d --- /dev/null +++ b/crates/botloftd/src/tools/era.rs @@ -0,0 +1,110 @@ +//! Which MCP revision a request follows, and the header checks 2026-07-28 +//! requires. + +use axum::http::{HeaderMap, StatusCode}; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use botloft_core::protocol::error_code; +use serde_json::{Value, json}; + +use super::Failure; +use crate::rpc::jsonrpc::Request; + +/// The revision without `initialize`. +pub(super) const MODERN: &str = "2026-07-28"; +/// Revisions that open with `initialize`, newest first. +pub(super) const LEGACY: &[&str] = &["2025-11-25", "2025-06-18", "2025-03-26"]; +/// MCP error codes (2026-07-28). +const HEADER_MISMATCH: i32 = -32020; +const UNSUPPORTED_VERSION: i32 = -32022; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Era { + Modern, + Legacy, +} + +fn mismatch(message: &str) -> Failure { + Failure::new(StatusCode::BAD_REQUEST, HEADER_MISMATCH, message) +} + +fn header_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name).and_then(|value| value.to_str().ok()) +} + +/// Which revision the request follows, checking what 2026-07-28 requires. +pub(super) fn era(headers: &HeaderMap, request: &Request) -> Result { + match header_value(headers, "mcp-protocol-version") { + // `initialize`, and clients from before the header existed. + None => Ok(Era::Legacy), + Some(MODERN) => check_modern(headers, request).map(|()| Era::Modern), + Some(version) if LEGACY.contains(&version) => Ok(Era::Legacy), + Some(version) => { + let mut failure = Failure::new( + StatusCode::BAD_REQUEST, + UNSUPPORTED_VERSION, + "Unsupported protocol version", + ); + failure.data = Some(json!({ "supported": supported(), "requested": version })); + Err(failure) + } + } +} + +pub(super) fn supported() -> Vec<&'static str> { + std::iter::once(MODERN) + .chain(LEGACY.iter().copied()) + .collect() +} + +/// The headers must match the body, and the body must carry its metadata. +fn check_modern(headers: &HeaderMap, request: &Request) -> Result<(), Failure> { + let params = request.params.as_ref(); + let meta = params.and_then(|params| params.get("_meta")); + let field = + |name: &str| meta.and_then(|meta| meta.get(format!("io.modelcontextprotocol/{name}"))); + let (Some(version), Some(_)) = (field("protocolVersion"), field("clientCapabilities")) else { + return Err(Failure::new( + StatusCode::BAD_REQUEST, + error_code::INVALID_PARAMS, + "_meta needs io.modelcontextprotocol/protocolVersion and clientCapabilities", + )); + }; + if version.as_str() != Some(MODERN) { + return Err(mismatch( + "MCP-Protocol-Version does not match the protocol version in _meta", + )); + } + if header_value(headers, "mcp-method") != Some(request.method.as_str()) { + return Err(mismatch( + "Mcp-Method is missing or does not match the method", + )); + } + if request.method == "tools/call" { + let name = params + .and_then(|params| params.get("name")) + .and_then(Value::as_str); + let header = header_value(headers, "mcp-name").and_then(decode_header); + if name.is_none() || header.as_deref() != name { + return Err(mismatch( + "Mcp-Name is missing or does not match the tool name", + )); + } + } + Ok(()) +} + +/// Undoes the `=?base64?...?=` form clients use for values that are not +/// plain ASCII. `None` when that form does not decode. +fn decode_header(value: &str) -> Option { + match value + .strip_prefix("=?base64?") + .and_then(|rest| rest.strip_suffix("?=")) + { + Some(encoded) => BASE64 + .decode(encoded) + .ok() + .and_then(|bytes| String::from_utf8(bytes).ok()), + None => Some(value.to_owned()), + } +} diff --git a/crates/botloftd/src/tools/mod.rs b/crates/botloftd/src/tools/mod.rs index a0122eeb..9757df87 100644 --- a/crates/botloftd/src/tools/mod.rs +++ b/crates/botloftd/src/tools/mod.rs @@ -8,6 +8,7 @@ mod calls; mod catalog; +mod era; use std::sync::Arc; @@ -15,35 +16,22 @@ use axum::body::Bytes; use axum::extract::State; use axum::http::{HeaderMap, HeaderValue, StatusCode, header}; use axum::response::{IntoResponse, Response}; -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; use botloft_core::ids::BotId; use botloft_core::protocol::error_code; use serde_json::{Value, json}; use tracing::debug; +use self::era::{Era, LEGACY, era, supported}; +use crate::approvals; use crate::rpc::jsonrpc::{self, Request}; use crate::state::Daemon; -/// The revision without `initialize`. -const MODERN: &str = "2026-07-28"; -/// Revisions that open with `initialize`, newest first. -const LEGACY: &[&str] = &["2025-11-25", "2025-06-18", "2025-03-26"]; -/// MCP error codes (2026-07-28). -const HEADER_MISMATCH: i32 = -32020; -const UNSUPPORTED_VERSION: i32 = -32022; /// How long clients may reuse `server/discover` and `tools/list`. const CACHE_TTL_MS: u64 = 60 * 60 * 1000; const INSTRUCTIONS: &str = "Tools to work with your Botloft crew: see who is in it, send notes \ - or tasks to other bots, and report the result of tasks assigned to you. Messages from the \ - owner and from other bots arrive as prompts that start with [botloft]."; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Era { - Modern, - Legacy, -} + or tasks to other bots, and report the result of tasks assigned to you. The owner writes to \ + you directly; messages from other bots and from Botloft start with [botloft]."; /// A JSON-RPC error with the HTTP status it goes out with. struct Failure { @@ -63,10 +51,6 @@ impl Failure { } } - fn mismatch(message: impl Into) -> Self { - Self::new(StatusCode::BAD_REQUEST, HEADER_MISMATCH, message) - } - fn into_response(self, id: &Value) -> Response { let mut error = json!({ "code": self.code, "message": self.message }); if let Some(data) = self.data { @@ -91,7 +75,7 @@ pub async fn handle( .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) .and_then(|value| value.strip_prefix("Bearer ")); - let Some((bot, _generation)) = token.and_then(|token| daemon.supervisor.hook_owner(token)) + let Some((bot, generation)) = token.and_then(|token| daemon.supervisor.token_owner(token)) else { debug!( has_token = token.is_some(), @@ -122,7 +106,16 @@ pub async fn handle( debug!(bot = %bot, method = %request.method, "mcp notification"); return StatusCode::ACCEPTED.into_response(); }; - let answered = era(&headers, &request).and_then(|era| answer(&daemon, &bot, era, &request)); + let answered = match era(&headers, &request) { + // Holds the request until the owner answers (spec 10.1). + Ok(era) if is_permission_prompt(&request) => { + permission(&daemon, &bot, generation, &request) + .await + .map(|result| decorate(era, &request, result)) + } + Ok(era) => answer(&daemon, &bot, era, &request), + Err(failure) => Err(failure), + }; // Method names only: arguments carry message bodies. match answered { Ok(result) => { @@ -146,91 +139,46 @@ fn json_response(status: StatusCode, body: String) -> Response { (status, [(header::CONTENT_TYPE, "application/json")], body).into_response() } -fn header_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { - headers.get(name).and_then(|value| value.to_str().ok()) -} - -/// Which revision the request follows, checking what 2026-07-28 requires. -fn era(headers: &HeaderMap, request: &Request) -> Result { - match header_value(headers, "mcp-protocol-version") { - // `initialize`, and clients from before the header existed. - None => Ok(Era::Legacy), - Some(MODERN) => check_modern(headers, request).map(|()| Era::Modern), - Some(version) if LEGACY.contains(&version) => Ok(Era::Legacy), - Some(version) => { - let mut failure = Failure::new( - StatusCode::BAD_REQUEST, - UNSUPPORTED_VERSION, - "Unsupported protocol version", - ); - failure.data = Some(json!({ "supported": supported(), "requested": version })); - Err(failure) - } - } -} - -fn supported() -> Vec<&'static str> { - std::iter::once(MODERN) - .chain(LEGACY.iter().copied()) - .collect() +fn is_permission_prompt(request: &Request) -> bool { + request.method == "tools/call" + && request + .params + .as_ref() + .and_then(|params| params.get("name")) + .and_then(Value::as_str) + == Some(catalog::PERMISSION_PROMPT) } -/// The headers must match the body, and the body must carry its metadata. -fn check_modern(headers: &HeaderMap, request: &Request) -> Result<(), Failure> { - let params = request.params.as_ref(); - let meta = params.and_then(|params| params.get("_meta")); - let field = - |name: &str| meta.and_then(|meta| meta.get(format!("io.modelcontextprotocol/{name}"))); - let (Some(version), Some(_)) = (field("protocolVersion"), field("clientCapabilities")) else { - return Err(Failure::new( - StatusCode::BAD_REQUEST, +async fn permission( + daemon: &Daemon, + bot: &BotId, + generation: u64, + request: &Request, +) -> Result { + let arguments = request + .params + .as_ref() + .and_then(|params| params.get("arguments")) + .cloned() + .unwrap_or_else(|| json!({})); + let args: approvals::PromptArgs = serde_json::from_value(arguments).map_err(|err| { + Failure::new( + StatusCode::OK, error_code::INVALID_PARAMS, - "_meta needs io.modelcontextprotocol/protocolVersion and clientCapabilities", - )); - }; - if version.as_str() != Some(MODERN) { - return Err(Failure::mismatch( - "MCP-Protocol-Version does not match the protocol version in _meta", - )); - } - if header_value(headers, "mcp-method") != Some(request.method.as_str()) { - return Err(Failure::mismatch( - "Mcp-Method is missing or does not match the method", - )); - } - if request.method == "tools/call" { - let name = params - .and_then(|params| params.get("name")) - .and_then(Value::as_str); - let header = header_value(headers, "mcp-name").and_then(decode_header); - if name.is_none() || header.as_deref() != name { - return Err(Failure::mismatch( - "Mcp-Name is missing or does not match the tool name", - )); - } - } - Ok(()) + format!("invalid arguments: {err}"), + ) + })?; + let decision = approvals::prompt(daemon, bot, generation, args).await; + Ok(json!({ "content": [{ "type": "text", "text": decision }], "isError": false })) } -/// Undoes the `=?base64?...?=` form clients use for values that are not -/// plain ASCII. `None` when that form does not decode. -fn decode_header(value: &str) -> Option { - match value - .strip_prefix("=?base64?") - .and_then(|rest| rest.strip_suffix("?=")) - { - Some(encoded) => BASE64 - .decode(encoded) - .ok() - .and_then(|bytes| String::from_utf8(bytes).ok()), - None => Some(value.to_owned()), - } +fn server_info() -> Value { + json!({ "name": "botloft", "version": env!("CARGO_PKG_VERSION") }) } fn answer(daemon: &Daemon, bot: &BotId, era: Era, request: &Request) -> Result { let params = request.params.clone().unwrap_or_else(|| json!({})); - let server_info = json!({ "name": "botloft", "version": env!("CARGO_PKG_VERSION") }); - let mut result = match (era, request.method.as_str()) { + let result = match (era, request.method.as_str()) { (_, "ping") => json!({}), (Era::Modern, "server/discover") => json!({ "supportedVersions": supported(), @@ -245,7 +193,7 @@ fn answer(daemon: &Daemon, bot: &BotId, era: Era, request: &Request) -> Result Result Value { if era == Era::Modern { result["resultType"] = json!("complete"); - result["_meta"] = json!({ "io.modelcontextprotocol/serverInfo": server_info }); + result["_meta"] = json!({ "io.modelcontextprotocol/serverInfo": server_info() }); // 2026-07-28 requires caching hints on these; clients reject the // result without them. The tools only change with the daemon. if matches!(request.method.as_str(), "server/discover" | "tools/list") { @@ -275,5 +228,5 @@ fn answer(daemon: &Daemon, bot: &BotId, era: Era, request: &Request) -> Result Value { - let command = botloft_bin.to_string_lossy(); - let hooks: serde_json::Map = HOOKS - .iter() - .map(|(event, arg)| { - let hook = json!({ "type": "command", "command": command, "args": ["hook", arg] }); - ((*event).to_owned(), json!([{ "hooks": [hook] }])) - }) - .collect(); +/// Longer than the approval timeout, so Claude Code never gives up on a +/// permission request before the daemon answers it (spec 10). +const MCP_TIMEOUT_MARGIN: Duration = Duration::from_secs(120); + +/// `.claude/settings.json` (spec 7.5): keeps the bot out of the daemon's +/// secrets. Deny rules apply even in a folder nobody trusted. +pub fn settings_json(botloft_home: &Path) -> Value { let secrets = permission_rule_path(&botloft_home.join("secrets")); json!({ - "crossSessionInbound": "accept", - "hooks": hooks, "permissions": { - // The crew tools run without a prompt, so bots work unattended. - "allow": [BOTLOFT_TOOLS], "deny": [format!("Read({secrets}/**)")], }, }) } /// `.botloft/mcp.json` (spec 10). The token is expanded by Claude Code from -/// the bot's environment, so it is never written to disk. -pub fn mcp_json(port: u16) -> Value { +/// the bot's environment, so it is never written to disk. `timeout` lets a +/// permission request wait for the owner. +pub fn mcp_json(port: u16, approval_timeout: Duration) -> Value { + let timeout = approval_timeout + MCP_TIMEOUT_MARGIN; json!({ "mcpServers": { "botloft": { "type": "http", "url": format!("http://127.0.0.1:{port}/mcp"), "headers": { "Authorization": "Bearer ${BOTLOFT_BOT_TOKEN}" }, + "timeout": u64::try_from(timeout.as_millis()).unwrap_or(u64::MAX), } } }) @@ -77,8 +69,8 @@ pub fn rules_md(crew: &Crew, bot: &BotRecord, shared_dir: &Path) -> String { # Botloft You are **{name}** (`@{handle}`), a bot in the crew **{crew}**. Botloft keeps -you running all the time and delivers messages from the owner and from the -other bots of your crew. +you running all the time. The owner talks to you in a chat in the Botloft +app; the other bots of your crew send you messages too. ## Role @@ -88,18 +80,28 @@ other bots of your crew. {instructions} +## Talking with the owner + +- The owner's messages reach you as they wrote them. Your replies show up in + their chat as markdown: keep them short and clear, and say what you did. +- Files the owner sends are saved in `attachments/` in this folder; the + 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. + ## Working with your crew - This folder is your workspace. `CLAUDE.md` here is your memory across restarts. - Files for the whole crew go in the shared folder: `{shared}`. -- Messages from the owner and from other bots arrive as prompts that start - with `[botloft]`. The first line says who sent it; for a task it also has - the task id and the deadline. +- Messages from other bots and notices from Botloft start with `[botloft]`. + The first line says who sent it; for a task it also has the task id and + the deadline. - Use the `botloft` MCP tools to work with the crew: - `crew_roster`: bots of your crew with handle, role and state. - `send_message`: send a note, or a task with `kind: \"task\"`, to another bot by handle. - `complete_task`: report the result of a task assigned to you; the requester is told. - `my_tasks`: open tasks assigned to you or requested by you. +- Never call `permission_prompt` yourself; Claude Code uses it for approvals. - You can only reach bots of your own crew. - A task you receive ends with `complete_task`, also when you could not do it (`status: \"failed\"` and why): the bot that asked is waiting for it. @@ -120,36 +122,25 @@ mod tests { use super::*; #[test] - fn settings_use_exec_form_hooks_and_deny_the_secrets_folder() { - let bin = Path::new(r"C:\Users\ana\AppData\Local\Botloft\bin\botloftd.exe"); + fn settings_deny_the_secrets_folder_and_nothing_else() { let home = Path::new(r"C:\Users\ana\AppData\Local\Botloft"); - let settings = settings_json(bin, home); - - assert_eq!(settings["crossSessionInbound"], "accept"); - let stop = &settings["hooks"]["StopFailure"][0]["hooks"][0]; - assert_eq!(stop["type"], "command"); - assert_eq!(stop["command"], bin.to_string_lossy().as_ref()); - assert_eq!(stop["args"], json!(["hook", "stop-failure"])); - assert_eq!( - settings["hooks"].as_object().map(|h| h.len()), - Some(HOOKS.len()) - ); + let settings = settings_json(home); assert_eq!( - settings["permissions"]["deny"][0], - "Read(//c/Users/ana/AppData/Local/Botloft/secrets/**)" + settings, + json!({ "permissions": { "deny": ["Read(//c/Users/ana/AppData/Local/Botloft/secrets/**)"] } }) ); - assert_eq!(settings["permissions"]["allow"], json!(["mcp__botloft"])); } #[test] - fn mcp_config_points_at_the_daemon_and_expands_the_token() { - let mcp = mcp_json(45710); + fn mcp_config_points_at_the_daemon_and_outlasts_an_approval() { + let mcp = mcp_json(45710, Duration::from_secs(3600)); let server = &mcp["mcpServers"]["botloft"]; assert_eq!(server["url"], "http://127.0.0.1:45710/mcp"); assert_eq!( server["headers"]["Authorization"], "Bearer ${BOTLOFT_BOT_TOKEN}" ); + assert_eq!(server["timeout"], 3_720_000); } #[test] @@ -181,5 +172,6 @@ mod tests { assert!(rules.contains("Review every PR.")); assert!(rules.contains("Ask the owner")); assert!(rules.contains("/ws/site/shared")); + assert!(rules.contains("attachments/")); } } diff --git a/crates/botloftd/src/workspace/mod.rs b/crates/botloftd/src/workspace/mod.rs index 479bd3cb..25c4779e 100644 --- a/crates/botloftd/src/workspace/mod.rs +++ b/crates/botloftd/src/workspace/mod.rs @@ -6,6 +6,7 @@ mod files; use std::io; use std::path::{Path, PathBuf}; +use std::time::Duration; use botloft_core::protocol::Crew; use botloft_store::BotRecord; @@ -16,9 +17,9 @@ use crate::paths::Paths; #[derive(Debug, Clone, Copy)] pub struct WorkspaceEnv<'a> { pub paths: &'a Paths, - /// Absolute path of `botloftd.exe`, run by the hooks. - pub bin: &'a Path, pub port: u16, + /// How long the MCP server may hold a permission request (spec 10). + pub approval_timeout: Duration, } /// Creates the crew folder and its `shared` folder. @@ -39,11 +40,11 @@ pub fn prepare_bot(env: WorkspaceEnv<'_>, crew: &Crew, bot: &BotRecord) -> io::R } write_json( &dir.join(".claude").join("settings.json"), - &files::settings_json(env.bin, &env.paths.home), + &files::settings_json(&env.paths.home), )?; write_json( &dir.join(".botloft").join("mcp.json"), - &files::mcp_json(env.port), + &files::mcp_json(env.port, env.approval_timeout), )?; write_rules(env.paths, crew, bot)?; Ok(dir) @@ -112,8 +113,8 @@ mod tests { let (_dir, paths, crew, bot) = fixture(); let env = WorkspaceEnv { paths: &paths, - bin: Path::new("botloftd.exe"), port: 45710, + approval_timeout: Duration::from_secs(3600), }; let ws = prepare_bot(env, &crew, &bot).expect("prepare"); @@ -130,7 +131,7 @@ mod tests { let settings: serde_json::Value = serde_json::from_slice(&std::fs::read(ws.join(".claude/settings.json")).expect("read")) .expect("valid json"); - assert_eq!(settings["crossSessionInbound"], "accept"); + assert!(settings["permissions"]["deny"].is_array()); } #[test] @@ -138,8 +139,8 @@ mod tests { let (_dir, paths, crew, mut bot) = fixture(); let env = WorkspaceEnv { paths: &paths, - bin: Path::new("botloftd.exe"), port: 45710, + approval_timeout: Duration::from_secs(3600), }; let ws = prepare_bot(env, &crew, &bot).expect("prepare"); std::fs::write(ws.join("CLAUDE.md"), "my notes").expect("edit memory"); diff --git a/crates/botloftd/tests/chat.rs b/crates/botloftd/tests/chat.rs new file mode 100644 index 00000000..efe9c689 --- /dev/null +++ b/crates/botloftd/tests/chat.rs @@ -0,0 +1,280 @@ +//! The chat (spec 8) and approvals (spec 10.1), end to end: a fake bot +//! prints stream-json, the app sees items and live text, and permission +//! requests wait for the owner's answer. + +mod common; + +use std::time::Duration; + +use botloft_core::protocol::BotState; +use common::bots::{Crew, two_bots}; +use common::mcp::Mcp; +use common::stream; +use serde_json::{Value, json}; + +const CONFLICT: i64 = -32003; + +/// The next `chat.item` whose item matches, skipping others. +async fn item_where(app: &mut common::Client, wanted: impl Fn(&Value) -> bool) -> Value { + loop { + let changed = app.notification("chat.item").await; + if wanted(&changed["item"]) { + return changed; + } + } +} + +async fn kind(app: &mut common::Client, kind: &str) -> Value { + item_where(app, |item| item["body"]["kind"] == kind).await +} + +/// Sends the owner's message to @lead and waits until its process has it. +async fn talk_to_lead(c: &mut Crew, body: &str) -> Value { + let lead = c.app.call("bots.list", json!({})).await.expect("bots")[0].clone(); + c.app + .call( + "messages.send", + json!({ "botId": lead["id"], "body": body }), + ) + .await + .expect("send"); + let lines = c.lead_process.wait_lines(1).await; + lines.last().cloned().expect("line") +} + +async fn lead(c: &mut Crew) -> Value { + c.app.call("bots.list", json!({})).await.expect("bots")[0].clone() +} + +#[tokio::test] +async fn what_the_bot_prints_becomes_the_chat() { + let mut c = two_bots().await; + let line = talk_to_lead(&mut c, "Run the tests").await; + let lead = lead(&mut c).await; + c.t.until_state(&lead, BotState::Busy).await; + let p = &c.lead_process; + p.emit(stream::init("s-1")).await; + p.emit(stream::replay(&line)).await; + p.emit(stream::delta("Run")).await; + p.emit(stream::delta("ning.")).await; + p.emit(stream::text("Running.")).await; + p.emit(stream::tool_use( + "toolu_1", + "Bash", + json!({ "command": "npm test", "description": "Run the tests" }), + )) + .await; + // A subagent's traffic stays out of the chat. + let mut sub = stream::text("inner thoughts"); + sub["parent_tool_use_id"] = json!("toolu_1"); + p.emit(sub).await; + p.emit(stream::tool_result("toolu_1", "12 passed", false)) + .await; + p.emit(stream::text("All 12 tests pass.")).await; + p.emit(stream::result(false)).await; + + assert_eq!(c.app.notification("chat.delta").await["text"], "Run"); + assert_eq!(c.app.notification("chat.delta").await["text"], "ning."); + let reply = kind(&mut c.app, "reply").await; + assert_eq!(reply["item"]["body"]["text"], "Running."); + 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["activity"]["text"], "Bash · Run the tests"); + let done = kind(&mut c.app, "tool").await; + assert_eq!(done["item"]["id"], running["item"]["id"]); + assert_eq!(done["item"]["body"]["status"], "done"); + assert_eq!(done["item"]["body"]["output"], "12 passed"); + assert_eq!( + done["activity"], + Value::Null, + "an update keeps the list line" + ); + kind(&mut c.app, "reply").await; + let turn = kind(&mut c.app, "turn").await; + assert_eq!(turn["item"]["body"]["durationMs"], 1234); + c.t.until_state(&lead, BotState::Idle).await; + + let history = c + .app + .call("chat.history", json!({ "botId": lead["id"] })) + .await + .expect("history"); + let kinds: Vec<_> = history + .as_array() + .expect("items") + .iter() + .map(|item| item["body"]["kind"].as_str().expect("kind").to_owned()) + .collect(); + assert_eq!(kinds, ["turn", "reply", "tool", "reply", "inbound"]); + assert!(!history.to_string().contains("inner thoughts")); + let older = c + .app + .call( + "chat.history", + json!({ "botId": lead["id"], "before": history[1]["id"], "limit": 2 }), + ) + .await + .expect("older"); + assert_eq!(older.as_array().map(Vec::len), Some(2)); + let listed = c.app.call("bots.list", json!({})).await.expect("bots"); + assert_eq!(listed[0]["lastActivity"]["text"], "All 12 tests pass."); +} + +/// Starts a permission request for `tool_use_id` the way Claude Code does, +/// in the background; the answer comes out of the handle. +fn ask(mcp: &Mcp, tool_use_id: &str) -> tokio::task::JoinHandle { + let mut mcp = Mcp::new(mcp.addr, mcp.token.clone()); + let args = json!({ "tool_name": "Bash", "input": { "command": "rm -rf build" }, "tool_use_id": tool_use_id }); + tokio::spawn(async move { mcp.tool("permission_prompt", args).await.expect("decision") }) +} + +#[tokio::test] +async fn approvals_wait_for_the_owner() { + let mut c = two_bots().await; + let line = talk_to_lead(&mut c, "Clean the build").await; + let lead = lead(&mut c).await; + c.lead_process.emit(stream::replay(&line)).await; + c.lead_process + .emit(stream::tool_use( + "toolu_9", + "Bash", + json!({ "command": "rm -rf build" }), + )) + .await; + + let decision = ask(&c.lead, "toolu_9"); + let pending = kind(&mut c.app, "approval").await; + let body = &pending["item"]["body"]; + assert_eq!( + (&body["status"], &body["toolName"]), + (&json!("pending"), &json!("Bash")) + ); + assert_eq!(body["summary"], "rm -rf build"); + assert_eq!(pending["activity"]["text"], "Waiting for approval: Bash"); + c.t.until_state(&lead, BotState::NeedsApproval).await; + + let answered = c + .app + .call( + "approvals.answer", + json!({ "approvalId": body["approvalId"], "allow": true }), + ) + .await + .expect("answer"); + assert_eq!(answered["status"], "allowed"); + let outcome = decision.await.expect("task"); + assert_eq!( + outcome, + json!({ "behavior": "allow", "updatedInput": { "command": "rm -rf build" } }) + ); + let shown = kind(&mut c.app, "approval").await; + assert_eq!(shown["item"]["id"], pending["item"]["id"]); + assert_eq!(shown["item"]["body"]["status"], "allowed"); + c.t.until_state(&lead, BotState::Busy).await; + let twice = c + .app + .call( + "approvals.answer", + json!({ "approvalId": body["approvalId"], "allow": false }), + ) + .await + .expect_err("already answered"); + assert_eq!(twice.code, CONFLICT); + + // A denial carries the owner's note. + c.lead_process + .emit(stream::tool_use( + "toolu_10", + "Bash", + json!({ "command": "rm -rf build" }), + )) + .await; + let decision = ask(&c.lead, "toolu_10"); + let pending = kind(&mut c.app, "approval").await; + c.app + .call( + "approvals.answer", + json!({ "approvalId": pending["item"]["body"]["approvalId"], "allow": false, "note": "keep the cache" }), + ) + .await + .expect("deny"); + let outcome = decision.await.expect("task"); + assert_eq!(outcome["behavior"], "deny"); + assert_eq!(outcome["message"], "The owner denied this: keep the cache"); +} + +#[tokio::test] +async fn requests_that_match_no_tool_or_nobody_answers_are_denied() { + let mut c = two_bots().await; + let line = talk_to_lead(&mut c, "Go").await; + c.lead_process.emit(stream::replay(&line)).await; + + // No tool call with that id: denied without bothering the owner. + let invented = ask(&c.lead, "toolu_made_up").await.expect("task"); + assert_eq!(invented["behavior"], "deny"); + + c.lead_process + .emit(stream::tool_use( + "toolu_2", + "Bash", + json!({ "command": "make" }), + )) + .await; + let started = std::time::Instant::now(); + let outcome = ask(&c.lead, "toolu_2").await.expect("task"); + assert_eq!(outcome["behavior"], "deny"); + assert_eq!(outcome["message"], "The owner did not answer in time."); + assert!( + started.elapsed() >= Duration::from_secs(3), + "the test timeout is 3 s" + ); + let expired = item_where(&mut c.app, |item| item["body"]["status"] == "expired").await; + assert_eq!(expired["item"]["body"]["kind"], "approval"); +} + +#[tokio::test] +async fn a_process_that_ends_expires_its_open_requests() { + let mut c = two_bots().await; + let line = talk_to_lead(&mut c, "Go").await; + c.lead_process.emit(stream::replay(&line)).await; + c.lead_process + .emit(stream::tool_use( + "toolu_3", + "Bash", + json!({ "command": "make" }), + )) + .await; + let decision = ask(&c.lead, "toolu_3"); + kind(&mut c.app, "approval").await; + c.lead_process.exit(1).await; + let expired = item_where(&mut c.app, |item| item["body"]["status"] == "expired").await; + assert_eq!(expired["item"]["body"]["kind"], "approval"); + let outcome = decision.await.expect("task"); + assert_eq!(outcome["behavior"], "deny"); +} + +#[tokio::test] +async fn api_errors_are_told_in_the_chat() { + let mut c = two_bots().await; + let line = talk_to_lead(&mut c, "Go").await; + let lead = lead(&mut c).await; + c.lead_process.emit(stream::replay(&line)).await; + c.lead_process + .emit(stream::api_error( + "server_error", + "API Error: 500 Internal Server Error", + )) + .await; + c.lead_process.emit(stream::result(true)).await; + let notice = kind(&mut c.app, "notice").await; + assert_eq!(notice["item"]["body"]["level"], "error"); + assert_eq!( + notice["item"]["body"]["text"], + "API Error: 500 Internal Server Error" + ); + let turn = kind(&mut c.app, "turn").await; + assert_eq!(turn["item"]["body"]["error"], "api_error"); + c.t.until_state(&lead, BotState::Idle).await; +} diff --git a/crates/botloftd/tests/common/bots.rs b/crates/botloftd/tests/common/bots.rs index 196df53f..2b9b4d4a 100644 --- a/crates/botloftd/tests/common/bots.rs +++ b/crates/botloftd/tests/common/bots.rs @@ -1,9 +1,10 @@ -//! Running fake bots: finding their process, reporting their inbox through -//! `/hooks` and a ready-made crew of two. +//! Running fake bots: finding their process, waiting for their state and a +//! ready-made crew of two. use std::time::Duration; -use botloftd::hooks::client::post; +use botloft_core::ids::BotId; +use botloft_core::protocol::BotState; use botloftd::runtime::fake::FakeProcess; use serde_json::{Value, json}; @@ -11,7 +12,7 @@ use super::mcp::Mcp; use super::{Client, TestDaemon}; impl TestDaemon { - /// The fake process of `bot`, waiting up to 5 s for it to start. + /// The newest fake process of `bot`, waiting up to 5 s for it to start. pub async fn process_of(&self, bot: &Value) -> FakeProcess { let id = bot["id"].as_str().expect("bot id"); for _ in 0..500 { @@ -28,48 +29,48 @@ impl TestDaemon { } panic!("bot {id} never started"); } -} - -/// Inbox address fake bot `n` reports. -pub fn pipe(n: u32) -> String { - format!(r"\\.\pipe\LOCAL\cc-msg-test-{n}") -} -/// Sends `SessionStart` through `/hooks`, as the bot's hook would, with the -/// inbox `pipe(n)` and messaging token `tok-n`. -pub async fn session_start(t: &TestDaemon, process: &FakeProcess, n: u32) { - let token = process.env("BOTLOFT_BOT_TOKEN").expect("bot token"); - let body = json!({ - "payload": { "hook_event_name": "SessionStart" }, - "messagingSocket": pipe(n), - "messagingToken": format!("tok-{n}"), - }); - let body = serde_json::to_vec(&body).expect("json"); - let port = t.addr.port(); - tokio::task::spawn_blocking(move || post(port, "/hooks/session-start", &token, &body)) - .await - .expect("join") - .expect("hook accepted"); + /// Waits up to 5 s for the bot to reach `state`. + pub async fn until_state(&self, bot: &Value, state: BotState) { + let id: BotId = bot["id"].as_str().expect("bot id").parse().expect("bot id"); + let mut last = None; + for _ in 0..500 { + last = self.daemon.supervisor.status(&id).map(|(s, _)| s); + if last == Some(state) { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("bot never reached {state:?}; it is {last:?}"); + } } -/// Creates a bot, waits for its process and reports inbox `n`. Returns the -/// bot's MCP client. -pub async fn ready_bot(t: &TestDaemon, app: &mut Client, crew: &Value, name: &str, n: u32) -> Mcp { +/// Creates a bot and waits until its process is ready. Returns the bot, its +/// process and its MCP client. +pub async fn ready_bot( + t: &TestDaemon, + app: &mut Client, + crew: &Value, + name: &str, +) -> (Value, FakeProcess, Mcp) { let role = format!("{name} role"); let bot = json!({ "crewId": crew["id"], "name": name, "role": role, "instructions": "" }); let bot = app.call("bots.create", bot).await.expect("bot"); let process = t.process_of(&bot).await; - session_start(t, &process, n).await; - Mcp::new(t.addr, process.env("BOTLOFT_BOT_TOKEN").expect("token")) + t.until_state(&bot, BotState::Idle).await; + let mcp = Mcp::new(t.addr, process.env("BOTLOFT_BOT_TOKEN").expect("token")); + (bot, process, mcp) } -/// A running crew "Ops" with @lead (inbox 1) and @writer (inbox 2). +/// A running crew "Ops" with @lead and @writer. pub struct Crew { pub t: TestDaemon, pub app: Client, pub crew: Value, pub lead: Mcp, + pub lead_process: FakeProcess, pub writer: Mcp, + pub writer_process: FakeProcess, } pub async fn two_bots() -> Crew { @@ -79,13 +80,23 @@ pub async fn two_bots() -> Crew { .call("crews.create", json!({ "name": "Ops" })) .await .expect("crew"); - let lead = ready_bot(&t, &mut app, &crew, "Lead", 1).await; - let writer = ready_bot(&t, &mut app, &crew, "Writer", 2).await; + let (_, lead_process, lead) = ready_bot(&t, &mut app, &crew, "Lead").await; + let (_, writer_process, writer) = ready_bot(&t, &mut app, &crew, "Writer").await; Crew { t, app, crew, lead, + lead_process, writer, + writer_process, } } + +/// The text a bot got in the message written as stdin line `line`. +pub fn text_of(line: &Value) -> String { + line["message"]["content"][0]["text"] + .as_str() + .expect("text block") + .to_owned() +} diff --git a/crates/botloftd/tests/common/mod.rs b/crates/botloftd/tests/common/mod.rs index a2362285..0fd8ff64 100644 --- a/crates/botloftd/tests/common/mod.rs +++ b/crates/botloftd/tests/common/mod.rs @@ -5,6 +5,8 @@ pub mod bots; pub mod mcp; +pub mod stream; +pub mod supervised; use std::collections::VecDeque; use std::net::SocketAddr; @@ -14,7 +16,6 @@ use std::time::Duration; use botloft_store::Store; use botloftd::clock::ManualClock; -use botloftd::courier::fake::FakeInbox; use botloftd::courier::{self, CourierSettings}; use botloftd::paths::Paths; use botloftd::runtime::claude::Claude; @@ -22,7 +23,7 @@ use botloftd::runtime::fake::FakeRuntime; use botloftd::secrets::TokenHash; use botloftd::server; use botloftd::service::tasks::TaskSettings; -use botloftd::state::{Daemon, DaemonOptions}; +use botloftd::state::{BotSettings, Daemon, DaemonOptions}; use botloftd::supervisor::{self, ClaudeSource, SupervisorSettings}; use futures_util::{SinkExt, StreamExt}; use serde_json::{Value, json}; @@ -40,7 +41,6 @@ pub struct TestDaemon { pub paths: Paths, pub daemon: Arc, pub runtime: FakeRuntime, - pub inbox: FakeInbox, pub clock: Arc, _dir: TempDir, } @@ -50,12 +50,20 @@ pub fn test_settings() -> SupervisorSettings { SupervisorSettings { claude: ClaudeSource::Fixed(Claude { path: PathBuf::from(r"C:\Claude\claude.exe"), - version: "2.1.283".to_owned(), + version: "2.1.284".to_owned(), }), backoff_initial: Duration::from_millis(40), backoff_max: Duration::from_millis(200), fresh_start_if_dies_within: Duration::from_secs(15), - ring_buffer_bytes: 64 * 1024, + ready_after: Duration::from_millis(20), + } +} + +/// Approvals time out fast enough for a test to wait for it. +pub fn bot_settings() -> BotSettings { + BotSettings { + approval_timeout: Duration::from_secs(3), + attachment_max_bytes: 1024 * 1024, } } @@ -74,7 +82,6 @@ pub fn courier_settings() -> CourierSettings { pub struct Parts { pub daemon: Arc, pub runtime: FakeRuntime, - pub inbox: FakeInbox, pub clock: Arc, pub paths: Paths, pub dir: TempDir, @@ -84,28 +91,25 @@ pub fn new_daemon(settings: SupervisorSettings) -> Parts { let dir = tempfile::tempdir().expect("tempdir"); let paths = Paths::new(dir.path().join("home"), dir.path().join("workspaces")); let runtime = FakeRuntime::new(); - let inbox = FakeInbox::new(); let clock = Arc::new(ManualClock::new()); let daemon = Daemon::new(DaemonOptions { paths: paths.clone(), port: 45710, - bin: PathBuf::from(r"C:\Botloft\bin\botloftd.exe"), store: Store::open_in_memory().expect("store"), owner_token: TokenHash::of(TOKEN), runtime: Arc::new(runtime.clone()), supervisor: settings, clock: Arc::clone(&clock) as _, - inbox: Arc::new(inbox.clone()), courier: courier_settings(), tasks: TaskSettings { max_hops: 3, default_deadline: Duration::from_secs(120 * 60), }, + bots: bot_settings(), }); Parts { daemon, runtime, - inbox, clock, paths, dir, @@ -119,7 +123,7 @@ impl TestDaemon { } /// Server, supervisor and courier, with bots running on a - /// [`FakeRuntime`] and inboxes that are a [`FakeInbox`]. + /// [`FakeRuntime`]. pub async fn start_supervised() -> Self { Self::launch(true).await } @@ -142,7 +146,6 @@ impl TestDaemon { paths: parts.paths, daemon: parts.daemon, runtime: parts.runtime, - inbox: parts.inbox, clock: parts.clock, _dir: parts.dir, } @@ -243,7 +246,7 @@ impl Client { pub async fn hello(&mut self, token: &str) -> Result { self.call( "session.hello", - json!({ "token": token, "client": { "name": "tests", "version": "0" }, "protocol": 1 }), + json!({ "token": token, "client": { "name": "tests", "version": "0" }, "protocol": 2 }), ) .await } diff --git a/crates/botloftd/tests/common/stream.rs b/crates/botloftd/tests/common/stream.rs new file mode 100644 index 00000000..4d8bdb45 --- /dev/null +++ b/crates/botloftd/tests/common/stream.rs @@ -0,0 +1,84 @@ +//! Stream-json events as Claude Code prints them (spec 8.1), for fake bots. + +use botloftd::runtime::fake::FakeProcess; +use serde_json::{Value, json}; + +pub fn init(session: &str) -> Value { + json!({ "type": "system", "subtype": "init", "session_id": session, "tools": [] }) +} + +/// Claude Code giving back a message it began, with the daemon's uuid. +pub fn replay(line: &Value) -> Value { + json!({ + "type": "user", + "isReplay": true, + "uuid": line["uuid"], + "message": line["message"], + "parent_tool_use_id": null, + }) +} + +pub fn text(text: &str) -> Value { + json!({ + "type": "assistant", + "message": { "role": "assistant", "content": [{ "type": "text", "text": text }] }, + "parent_tool_use_id": null, + }) +} + +pub fn tool_use(id: &str, name: &str, input: Value) -> Value { + json!({ + "type": "assistant", + "message": { "role": "assistant", "content": [ + { "type": "tool_use", "id": id, "name": name, "input": input } + ] }, + "parent_tool_use_id": null, + }) +} + +pub fn tool_result(id: &str, output: &str, is_error: bool) -> Value { + json!({ + "type": "user", + "message": { "role": "user", "content": [ + { "type": "tool_result", "tool_use_id": id, "content": output, "is_error": is_error } + ] }, + "parent_tool_use_id": null, + }) +} + +pub fn delta(text: &str) -> Value { + json!({ + "type": "stream_event", + "event": { "type": "content_block_delta", "index": 0, + "delta": { "type": "text_delta", "text": text } }, + "parent_tool_use_id": null, + }) +} + +pub fn api_error(error: &str, text: &str) -> Value { + json!({ + "type": "assistant", + "error": error, + "message": { "role": "assistant", "content": [{ "type": "text", "text": text }] }, + "parent_tool_use_id": null, + }) +} + +pub fn result(is_error: bool) -> Value { + json!({ + "type": "result", + "subtype": "success", + "is_error": is_error, + "duration_ms": 1234, + "total_cost_usd": 0.0125, + "terminal_reason": if is_error { "api_error" } else { "completed" }, + }) +} + +/// The bot begins the message `line`, answers `reply` and ends the turn. +pub async fn answer(process: &FakeProcess, line: &Value, reply: &str) { + process.emit(init("session-1")).await; + process.emit(replay(line)).await; + process.emit(text(reply)).await; + process.emit(result(false)).await; +} diff --git a/crates/botloftd/tests/common/supervised.rs b/crates/botloftd/tests/common/supervised.rs new file mode 100644 index 00000000..749c6d7c --- /dev/null +++ b/crates/botloftd/tests/common/supervised.rs @@ -0,0 +1,87 @@ +//! One bot under a supervisor on a FakeRuntime, driven directly through the +//! daemon (no WebSocket), for the supervisor tests. + +use std::sync::Arc; +use std::time::Duration; + +use botloft_core::ids::BotId; +use botloft_core::protocol::{BotState, BotsCreateParams, CrewsCreateParams}; +use botloftd::clock::ManualClock; +use botloftd::runtime::fake::{FakeProcess, FakeRuntime}; +use botloftd::service::{bots, crews}; +use botloftd::state::Daemon; +use botloftd::supervisor; +use serde_json::json; + +use super::{new_daemon, test_settings}; + +pub struct Setup { + pub daemon: Arc, + pub runtime: FakeRuntime, + pub clock: Arc, + pub bot: BotId, + _dir: tempfile::TempDir, +} + +pub async fn setup() -> Setup { + let parts = new_daemon(test_settings()); + let (daemon, runtime, clock, dir) = (parts.daemon, parts.runtime, parts.clock, parts.dir); + let crew = crews::create(&daemon, CrewsCreateParams { name: "Ops".into() }).expect("crew"); + let bot = bots::create( + &daemon, + BotsCreateParams { + crew_id: crew.id, + name: "Scout".into(), + role: String::new(), + instructions: String::new(), + color: None, + }, + ) + .expect("bot"); + tokio::spawn(supervisor::run(Arc::clone(&daemon))); + Setup { + daemon, + runtime, + clock, + bot: bot.id, + _dir: dir, + } +} + +impl Setup { + pub fn state(&self) -> BotState { + self.daemon + .supervisor + .status(&self.bot) + .map(|(state, _)| state) + .expect("slot") + } + + /// Waits (on the paused clock) until the bot reaches `state`. + pub async fn until(&self, state: BotState) { + for _ in 0..600 { + if self.daemon.supervisor.status(&self.bot).map(|(s, _)| s) == Some(state) { + return; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("bot never reached {state:?}; it is {:?}", self.state()); + } + + /// Writes a message line the way the courier does. + pub fn message(&self, text: &str) -> u64 { + let line = + json!({ "type": "user", "uuid": "u", "message": { "role": "user", "content": text } }); + self.daemon + .supervisor + .write_message(&self.bot, format!("{line}\n").into()) + .expect("running") + } +} + +/// The value after `flag` on the process's command line. +pub fn arg_after(process: &FakeProcess, flag: &str) -> Option { + let args = process.args(); + let at = args.iter().position(|arg| arg == flag)?; + args.get(at + 1).cloned() +} diff --git a/crates/botloftd/tests/courier.rs b/crates/botloftd/tests/courier.rs index 4ac55bdf..9f8f9490 100644 --- a/crates/botloftd/tests/courier.rs +++ b/crates/botloftd/tests/courier.rs @@ -1,13 +1,18 @@ -//! Owner messages through the courier into a fake inbox (spec 9): waiting -//! for the bot, order, retries, restarts and archiving. Retry times follow -//! the manual clock; the courier itself polls on the real one. +//! Messages through the courier into the bots' stdin (spec 9): waiting for +//! the bot, order, read receipts, processes that end before reading, +//! attachments and archiving. Retry times follow the manual clock; the +//! courier itself polls on the real one. mod common; use std::time::Duration; +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use botloft_core::protocol::BotState; use botloftd::clock::Clock as _; -use common::bots::{pipe, session_start}; +use common::bots::text_of; +use common::stream; use common::{Client, TestDaemon}; use serde_json::{Value, json}; @@ -45,36 +50,46 @@ async fn delivery(app: &mut Client, state: &str) -> Value { } #[tokio::test] -async fn an_owner_message_waits_for_the_bot_and_then_reaches_its_inbox() { +async fn an_owner_message_reaches_the_bot_as_its_user_and_is_read() { let t = TestDaemon::start_supervised().await; let mut app = t.session().await; let bot = crew_and_bot(&mut app).await; - let process = t.runtime.process(1).await; + let process = t.process_of(&bot).await; let message = send(&mut app, &bot, " Olá!\nTudo bem? ").await; assert_eq!(message["body"], "Olá!\nTudo bem?"); - assert_eq!(message["fromKind"], "owner"); - assert_eq!(message["kind"], "note"); + assert_eq!( + (&message["fromKind"], &message["kind"]), + (&json!("owner"), &json!("note")) + ); assert_eq!(app.notification("message.created").await, message); let pending = delivery(&mut app, "pending").await; assert_eq!(pending["messageId"], message["id"]); + let item = app.notification("chat.item").await; + assert_eq!( + item["item"]["body"], + json!({ "kind": "inbound", "message": message }) + ); + assert_eq!(item["activity"]["text"], "You: Olá! Tudo bem?"); - // Still launching: the courier waits and counts nothing. - tokio::time::sleep(Duration::from_millis(100)).await; - assert_eq!(t.inbox.attempts(), 0); - - session_start(&t, &process, 1).await; + t.until_state(&bot, BotState::Idle).await; t.clock.advance(Duration::from_secs(5)); - let post = t.inbox.post(1).await; - assert_eq!(post.address, pipe(1)); - assert_eq!(post.token, "tok-1"); + let line = process.wait_lines(1).await.remove(0); + assert_eq!(line["type"], "user"); assert_eq!( - post.text, - "[botloft] from the owner · crew Ops\n\nOlá!\nTudo bem?" + text_of(&line), + "Olá!\nTudo bem?", + "no envelope for the owner" ); - delivery(&mut app, "sending").await; let sent = delivery(&mut app, "sent").await; - assert_eq!(sent["attempts"], 0); + assert_eq!( + (&sent["attempts"], &sent["readAt"]), + (&json!(0), &Value::Null) + ); + + process.emit(stream::replay(&line)).await; + let read = delivery_where(&mut app, |changed| !changed["readAt"].is_null()).await; + assert_eq!(read["id"], sent["id"]); let listed = app .call("messages.list", json!({ "botId": bot["id"] })) @@ -93,45 +108,48 @@ async fn messages_to_one_bot_arrive_in_order() { let t = TestDaemon::start_supervised().await; let mut app = t.session().await; let bot = crew_and_bot(&mut app).await; - let process = t.runtime.process(1).await; + let process = t.process_of(&bot).await; for body in ["one", "two", "three"] { send(&mut app, &bot, body).await; } - session_start(&t, &process, 1).await; + t.until_state(&bot, BotState::Idle).await; t.clock.advance(Duration::from_secs(5)); - for (n, body) in ["one", "two", "three"].iter().enumerate() { - assert!(t.inbox.post(n + 1).await.text.ends_with(body)); - } + let lines = process.wait_lines(3).await; + let texts: Vec<_> = lines.iter().map(text_of).collect(); + assert_eq!(texts, ["one", "two", "three"]); + let uuids: std::collections::HashSet<_> = lines.iter().map(|l| l["uuid"].clone()).collect(); + assert_eq!(uuids.len(), 3, "each message has its own uuid"); } #[tokio::test] -async fn failed_writes_back_off_then_give_up_and_can_be_retried() { +async fn what_a_dead_process_never_read_goes_back_until_it_gives_up() { let t = TestDaemon::start_supervised().await; let mut app = t.session().await; let bot = crew_and_bot(&mut app).await; - let process = t.runtime.process(1).await; - session_start(&t, &process, 1).await; - t.inbox.fail_next(3); + t.until_state(&bot, BotState::Idle).await; send(&mut app, &bot, "hello").await; - // Three attempts (the test limit), 1 s then 2 s apart. - for (attempts, wait) in [(1, 1), (2, 2)] { - let retry = delivery_where(&mut app, |changed| { - changed["state"] == "pending" && changed["attempts"] == attempts + // Three attempts (the test limit): each process ends before reading. + for (attempt, wait) in [(1, 1), (2, 2)] { + let process = t.process_of(&bot).await; + process.wait_lines(1).await; + delivery(&mut app, "sent").await; + process.exit(1).await; + let back = delivery_where(&mut app, |changed| { + changed["state"] == "pending" && changed["attempts"] == attempt }) .await; - assert_eq!(retry["lastError"], "fake failure"); - let due_in = retry["nextAttemptAt"].as_i64().expect("time") - t.clock.now_ms(); + assert!(back["lastError"].as_str().expect("error").contains("ended")); + let due_in = back["nextAttemptAt"].as_i64().expect("time") - t.clock.now_ms(); assert_eq!(due_in, wait * 1000); + t.until_state(&bot, BotState::Idle).await; t.clock.advance(Duration::from_secs(wait as u64)); } + let last = t.process_of(&bot).await; + last.wait_lines(1).await; + last.exit(1).await; let dead = delivery(&mut app, "dead").await; assert_eq!(dead["attempts"], 3); - let status = app - .call("system.status", json!(null)) - .await - .expect("status"); - assert_eq!(status["deliveries"], json!({ "pending": 0, "dead": 1 })); let retried = app .call("deliveries.retry", json!({ "deliveryId": dead["id"] })) @@ -141,8 +159,11 @@ async fn failed_writes_back_off_then_give_up_and_can_be_retried() { (&retried["state"], &retried["attempts"]), (&json!("pending"), &json!(0)) ); - assert!(t.inbox.post(1).await.text.ends_with("hello")); - delivery(&mut app, "sent").await; + // The bot was still restarting at the retry: the courier waits 5 s. + t.until_state(&bot, BotState::Idle).await; + t.clock.advance(Duration::from_secs(5)); + let fresh = t.process_of(&bot).await; + assert_eq!(text_of(&fresh.wait_lines(1).await[0]), "hello"); let again = app .call("deliveries.retry", json!({ "deliveryId": dead["id"] })) .await @@ -151,30 +172,60 @@ async fn failed_writes_back_off_then_give_up_and_can_be_retried() { } #[tokio::test] -async fn a_bot_restart_during_a_send_does_not_count_as_a_failure() { +async fn a_message_the_bot_began_is_not_sent_again() { let t = TestDaemon::start_supervised().await; let mut app = t.session().await; let bot = crew_and_bot(&mut app).await; - let first = t.runtime.process(1).await; - session_start(&t, &first, 1).await; - - t.inbox.hold(); - t.inbox.fail_next(1); - send(&mut app, &bot, "survive the restart").await; - delivery(&mut app, "sending").await; + t.until_state(&bot, BotState::Idle).await; + let first = t.process_of(&bot).await; + send(&mut app, &bot, "only once").await; + let line = first.wait_lines(1).await.remove(0); + first.emit(stream::replay(&line)).await; + delivery_where(&mut app, |changed| !changed["readAt"].is_null()).await; first.exit(1).await; - let second = t.runtime.process(2).await; - t.inbox.release(); + t.until_state(&bot, BotState::Idle).await; + t.clock.advance(Duration::from_secs(10)); + tokio::time::sleep(Duration::from_millis(100)).await; + let second = t.process_of(&bot).await; + assert!(second.input_lines().is_empty()); +} - let back = delivery(&mut app, "pending").await; +#[tokio::test] +async fn attachments_are_saved_and_images_go_inline() { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let bot = crew_and_bot(&mut app).await; + t.until_state(&bot, BotState::Idle).await; + let process = t.process_of(&bot).await; + let params = json!({ "botId": bot["id"], "body": "", "attachments": [ + { "name": "screen.png", "mediaType": "image/png", "data": BASE64.encode(b"png") }, + { "name": "../../report.pdf", "mediaType": "application/pdf", "data": BASE64.encode(b"pdf") }, + ] }); + let message = app + .call("messages.send", params) + .await + .expect("files alone"); + let attachments = message["attachments"].as_array().expect("attachments"); + assert_eq!(attachments.len(), 2); + assert_eq!(attachments[1]["name"], "report.pdf"); + let workspace = std::path::PathBuf::from(bot["workspace"].as_str().expect("workspace")); + for attachment in attachments { + let path = workspace.join(attachment["path"].as_str().expect("path")); + assert!(path.is_file(), "{} missing", path.display()); + } + + let line = process.wait_lines(1).await.remove(0); + let text = text_of(&line); + assert!( + text.starts_with("\n\nAttached files, saved in your folder: attachments/"), + "{text}" + ); + assert!(text.ends_with("/report.pdf")); + assert_eq!(line["message"]["content"][1]["type"], "image"); assert_eq!( - back["attempts"], 0, - "the old inbox going away is not a failure" + line["message"]["content"][1]["source"]["data"], + BASE64.encode(b"png") ); - session_start(&t, &second, 2).await; - t.clock.advance(Duration::from_secs(5)); - let post = t.inbox.post(1).await; - assert_eq!((post.address, post.token), (pipe(2), "tok-2".to_owned())); } #[tokio::test] @@ -221,6 +272,13 @@ async fn invalid_messages_are_refused() { send_err(&mut app, json!({ "botId": bot["id"], "body": long })).await, VALIDATION ); + let file = json!({ "name": "a.txt", "mediaType": "text/plain", "data": BASE64.encode(b"a") }); + let many = json!({ "botId": bot["id"], "body": "x", "attachments": vec![file; 11] }); + assert_eq!(send_err(&mut app, many).await, VALIDATION); + let big = json!({ "botId": bot["id"], "body": "x", "attachments": [ + { "name": "big.bin", "mediaType": "", "data": BASE64.encode(vec![0u8; 1024 * 1024 + 1]) } + ] }); + assert_eq!(send_err(&mut app, big).await, VALIDATION); let missing = json!({ "botId": "bot_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0", "body": "hi" }); assert_eq!(send_err(&mut app, missing).await, NOT_FOUND); let err = app diff --git a/crates/botloftd/tests/mcp.rs b/crates/botloftd/tests/mcp.rs index cf4d43e3..f4fa1f9b 100644 --- a/crates/botloftd/tests/mcp.rs +++ b/crates/botloftd/tests/mcp.rs @@ -66,7 +66,13 @@ async fn claude_code_discovers_the_server_and_lists_the_tools() { .collect(); assert_eq!( names, - ["crew_roster", "send_message", "complete_task", "my_tasks"] + [ + "crew_roster", + "send_message", + "complete_task", + "my_tasks", + "permission_prompt" + ] ); let unknown = client.request("prompts/list", json!({})).await; @@ -109,7 +115,7 @@ async fn earlier_revisions_start_with_initialize() { let listed = mcp::post(t.addr, &legacy, &list).await; assert_eq!( listed.body["result"]["tools"].as_array().map(Vec::len), - Some(4) + Some(5) ); let unknown = json!({ "jsonrpc": "2.0", "id": 2, "method": "prompts/list" }).to_string(); let unknown = mcp::post(t.addr, &legacy, &unknown).await; diff --git a/crates/botloftd/tests/rpc_crews_bots.rs b/crates/botloftd/tests/rpc_crews_bots.rs index 824b1fc7..44e5ea4d 100644 --- a/crates/botloftd/tests/rpc_crews_bots.rs +++ b/crates/botloftd/tests/rpc_crews_bots.rs @@ -44,16 +44,17 @@ async fn create_a_crew_and_a_bot_and_get_a_ready_workspace() { assert_eq!(bot["handle"], "revisao"); assert_eq!(bot["state"], "offline"); assert_eq!(bot["color"], "#FF7A59"); + assert_eq!(bot["lastActivity"], Value::Null); assert_eq!(watcher.notification("bot.changed").await, bot); let ws = daemon.paths.bot_workspace("site-da-loja", "revisao"); assert_eq!(bot["workspace"], ws.to_string_lossy().as_ref()); let settings: Value = serde_json::from_str(&read(ws.join(".claude/settings.json"))).expect("json"); - assert_eq!(settings["crossSessionInbound"], "accept"); assert_eq!( - settings["hooks"]["SessionStart"][0]["hooks"][0]["args"], - json!(["hook", "session-start"]) + settings["hooks"], + Value::Null, + "headless bots need no hooks" ); let deny = settings["permissions"]["deny"][0] .as_str() diff --git a/crates/botloftd/tests/rpc_session.rs b/crates/botloftd/tests/rpc_session.rs index 93a37977..74abc387 100644 --- a/crates/botloftd/tests/rpc_session.rs +++ b/crates/botloftd/tests/rpc_session.rs @@ -23,7 +23,7 @@ async fn health_answers_without_authentication() { stream.read_to_string(&mut response).await.expect("read"); assert!(response.starts_with("HTTP/1.1 200"), "{response}"); assert!(response.contains(r#""status":"ok""#), "{response}"); - assert!(response.contains(r#""protocol":1"#), "{response}"); + assert!(response.contains(r#""protocol":2"#), "{response}"); } #[tokio::test] @@ -31,7 +31,7 @@ async fn hello_returns_the_daemon_version_and_protocol() { let daemon = TestDaemon::start().await; let mut client = daemon.client().await; let result = client.hello(TOKEN).await.expect("hello"); - assert_eq!(result["protocol"], 1); + assert_eq!(result["protocol"], 2); assert_eq!(result["daemonVersion"], env!("CARGO_PKG_VERSION")); } @@ -63,7 +63,7 @@ async fn another_protocol_version_is_refused() { let err = client .call( "session.hello", - json!({ "token": TOKEN, "client": { "name": "t", "version": "0" }, "protocol": 2 }), + json!({ "token": TOKEN, "client": { "name": "t", "version": "0" }, "protocol": 1 }), ) .await .expect_err("refused"); @@ -124,7 +124,7 @@ async fn errors_after_hello_keep_the_connection_open() { .call("system.status", json!(null)) .await .expect("still open"); - assert_eq!(status["protocol"], 1); + assert_eq!(status["protocol"], 2); assert!(status["uptimeMs"].as_i64().is_some()); assert_eq!(status["claudeVersion"], serde_json::Value::Null); } diff --git a/crates/botloftd/tests/rpc_terminal.rs b/crates/botloftd/tests/rpc_terminal.rs deleted file mode 100644 index 3a1bfaf6..00000000 --- a/crates/botloftd/tests/rpc_terminal.rs +++ /dev/null @@ -1,193 +0,0 @@ -//! Terminal, hooks and restarts over the network: the app's view of M2. - -mod common; - -use base64::Engine as _; -use base64::engine::general_purpose::STANDARD as BASE64; -use botloftd::hooks::client::post; -use common::{Client, TestDaemon}; -use serde_json::{Value, json}; - -fn decode(frame: &Value) -> Vec { - BASE64 - .decode(frame["data"].as_str().expect("data")) - .expect("base64") -} - -async fn crew_and_bot(app: &mut Client) -> Value { - let crew = app - .call("crews.create", json!({ "name": "Ops" })) - .await - .expect("crew"); - let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "", "instructions": "" }); - app.call("bots.create", bot).await.expect("bot") -} - -/// Waits for `bot.state` to report `state`, skipping earlier states. -async fn state(app: &mut Client, state: &str) -> Value { - loop { - let event = app.notification("bot.state").await; - if event["state"] == state { - return event; - } - } -} - -#[tokio::test] -async fn hooks_from_the_bot_change_the_state_the_app_sees() { - let daemon = TestDaemon::start_supervised().await; - let mut app = daemon.session().await; - let bot = crew_and_bot(&mut app).await; - let process = daemon.runtime.process(1).await; - let launching = state(&mut app, "launching").await; - assert_eq!(launching["botId"], bot["id"]); - - let token = process.env("BOTLOFT_BOT_TOKEN").expect("token"); - let port = daemon.addr.port(); - let body = json!({ "payload": { "hook_event_name": "SessionStart", "source": "startup" } }); - let body = serde_json::to_vec(&body).expect("json"); - tokio::task::spawn_blocking(move || post(port, "/hooks/session-start", &token, &body)) - .await - .expect("join") - .expect("hook accepted"); - let idle = state(&mut app, "idle").await; - assert_eq!(idle["generation"], launching["generation"]); - - let listed = app.call("bots.list", json!(null)).await.expect("list"); - assert_eq!(listed[0]["state"], "idle"); - assert_eq!(listed[0]["generation"], launching["generation"]); - - let wrong = - tokio::task::spawn_blocking(move || post(port, "/hooks/stop", "not-a-token", b"{}")) - .await - .expect("join"); - assert!(wrong.expect_err("refused").contains("401")); -} - -#[tokio::test] -async fn attach_replays_the_screen_then_streams_and_reattach_skips_what_was_seen() { - let daemon = TestDaemon::start_supervised().await; - let mut app = daemon.session().await; - let bot = crew_and_bot(&mut app).await; - let process = daemon.runtime.process(1).await; - state(&mut app, "launching").await; - process.output(b"hello\r\n").await; - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - - let attached = app - .call("terminal.attach", json!({ "botId": bot["id"] })) - .await - .expect("attach"); - assert_eq!(attached["reset"], true); - assert_eq!(attached["offset"], 0); - // The 7 buffered bytes are history; what comes after is live. - assert_eq!(attached["liveOffset"], 7); - let replay = app.notification("terminal.data").await; - assert_eq!(decode(&replay), b"hello\r\n"); - - process.output(b"more").await; - let live = app.notification("terminal.data").await; - assert_eq!(live["offset"], 7); - assert_eq!(decode(&live), b"more"); - - let written = json!({ "botId": bot["id"], "data": BASE64.encode("hi\r") }); - app.call("terminal.write", written).await.expect("write"); - assert_eq!(process.input(), b"hi\r"); - let resized = json!({ "botId": bot["id"], "cols": 100, "rows": 30 }); - app.call("terminal.resize", resized).await.expect("resize"); - assert_eq!( - process.sizes().last().map(|s| (s.cols, s.rows)), - Some((100, 30)) - ); - - // Another window picks up where it left off: nothing to replay. - let mut other = daemon.session().await; - let resume = json!({ "botId": bot["id"], "generation": attached["generation"], "offset": 11 }); - let again = other - .call("terminal.attach", resume) - .await - .expect("reattach"); - assert_eq!(again["reset"], false); - assert_eq!(again["offset"], 11); - assert_eq!(again["liveOffset"], 11); - - app.call("terminal.detach", json!({ "botId": bot["id"] })) - .await - .expect("detach"); - process.output(b"!").await; - let seen_by_other = other.notification("terminal.data").await; - assert_eq!(decode(&seen_by_other), b"!"); -} - -#[tokio::test] -async fn a_restart_shows_up_as_a_new_generation() { - let daemon = TestDaemon::start_supervised().await; - let mut app = daemon.session().await; - let bot = crew_and_bot(&mut app).await; - let first = daemon.runtime.process(1).await; - let before = state(&mut app, "launching").await; - app.call("terminal.attach", json!({ "botId": bot["id"] })) - .await - .expect("attach"); - - let restarted = app - .call("bots.restart", json!({ "botId": bot["id"], "fresh": true })) - .await - .expect("restart"); - assert_eq!(restarted["id"], bot["id"]); - let second = daemon.runtime.process(2).await; - assert!(first.killed()); - let after = state(&mut app, "launching").await; - assert_ne!(after["generation"], before["generation"]); - - second.output(b"new screen").await; - let data = app.notification("terminal.data").await; - assert_eq!(data["generation"], after["generation"]); - assert_eq!(data["offset"], 0); -} - -#[tokio::test] -async fn terminal_calls_check_the_bot_and_its_process() { - let daemon = TestDaemon::start().await; - let mut app = daemon.session().await; - let bot = crew_and_bot(&mut app).await; - - let write = json!({ "botId": bot["id"], "data": BASE64.encode("x") }); - let err = app - .call("terminal.write", write) - .await - .expect_err("not running"); - assert_eq!(err.code, -32003); - let bad = json!({ "botId": bot["id"], "data": "***" }); - assert_eq!( - app.call("terminal.write", bad) - .await - .expect_err("base64") - .code, - -32004 - ); - let huge = json!({ "botId": bot["id"], "cols": 0, "rows": 30 }); - assert_eq!( - app.call("terminal.resize", huge) - .await - .expect_err("size") - .code, - -32004 - ); - let missing = json!({ "botId": "bot_01J9Z3K8M4Q7R2T5V8X1Y4Z6A0" }); - assert_eq!( - app.call("terminal.attach", missing) - .await - .expect_err("missing") - .code, - -32002 - ); - - let paused = json!({ "botId": bot["id"], "paused": true }); - app.call("bots.setPaused", paused).await.expect("pause"); - let err = app - .call("bots.restart", json!({ "botId": bot["id"] })) - .await - .expect_err("paused"); - assert_eq!(err.code, -32003); -} diff --git a/crates/botloftd/tests/supervisor.rs b/crates/botloftd/tests/supervisor.rs index 6793e1c5..17a1322a 100644 --- a/crates/botloftd/tests/supervisor.rs +++ b/crates/botloftd/tests/supervisor.rs @@ -1,103 +1,75 @@ -//! Supervisor behavior on a FakeRuntime and a paused clock (spec 7 and 16). +//! Supervisor lifecycle on a FakeRuntime and a paused clock (spec 7 and 16): +//! starting, restarts, sessions, pausing and archiving. mod common; use std::sync::Arc; use std::time::Duration; -use botloft_core::ids::BotId; use botloft_core::protocol::{ BotIdParams, BotState, BotsCreateParams, BotsRestartParams, BotsSetPausedParams, CrewsCreateParams, }; -use botloftd::runtime::fake::{FakeProcess, FakeRuntime}; use botloftd::service::{bots, crews}; -use botloftd::state::Daemon; -use botloftd::supervisor::{self, Hook}; +use botloftd::supervisor; +use common::stream; +use common::supervised::{arg_after, setup}; use common::{new_daemon, test_settings}; -struct Setup { - daemon: Arc, - runtime: FakeRuntime, - bot: BotId, - _dir: tempfile::TempDir, -} - -async fn setup() -> Setup { - let parts = new_daemon(test_settings()); - let (daemon, runtime, dir) = (parts.daemon, parts.runtime, parts.dir); - let crew = crews::create(&daemon, CrewsCreateParams { name: "Ops".into() }).expect("crew"); - let bot = bots::create( - &daemon, - BotsCreateParams { - crew_id: crew.id, - name: "Scout".into(), - role: String::new(), - instructions: String::new(), - color: None, - }, - ) - .expect("bot"); - tokio::spawn(supervisor::run(Arc::clone(&daemon))); - Setup { - daemon, - runtime, - bot: bot.id, - _dir: dir, - } -} - -impl Setup { - fn state(&self) -> BotState { - self.daemon - .supervisor - .status(&self.bot) - .map(|(state, _)| state) - .expect("slot") - } - - /// Waits (on the paused clock) until the bot reaches `state`. - async fn until(&self, state: BotState) { - for _ in 0..600 { - if self.daemon.supervisor.status(&self.bot).map(|(s, _)| s) == Some(state) { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - panic!("bot never reached {state:?}; it is {:?}", self.state()); - } - - /// Sends a hook the way `/hooks` does: with the process's own token. - fn hook(&self, process: &FakeProcess, hook: Hook) { - let token = process.env("BOTLOFT_BOT_TOKEN").expect("token in env"); - let (bot, generation) = self - .daemon - .supervisor - .hook_owner(&token) - .expect("live token"); - self.daemon.supervisor.on_hook(&bot, generation, hook); - } -} - #[tokio::test(start_paused = true)] -async fn a_new_bot_starts_with_the_spec_command_line_and_a_clean_environment() { +async fn a_new_bot_runs_headless_in_a_new_session_with_a_clean_environment() { let s = setup().await; let process = s.runtime.process(1).await; s.until(BotState::Launching).await; + s.until(BotState::Idle).await; let args = process.args(); + assert_eq!(args[0], "-p"); + assert_eq!( + arg_after(&process, "--input-format").as_deref(), + Some("stream-json") + ); + assert_eq!( + arg_after(&process, "--output-format").as_deref(), + Some("stream-json") + ); + for flag in [ + "--verbose", + "--include-partial-messages", + "--replay-user-messages", + "--strict-mcp-config", + ] { + assert!( + args.iter().any(|arg| arg == flag), + "{flag} missing from {args:?}" + ); + } + let session = arg_after(&process, "--session-id").expect("a new session"); + assert_eq!(session.len(), 36); + assert_eq!(arg_after(&process, "--resume"), None); + assert_eq!( + arg_after(&process, "--setting-sources").as_deref(), + Some("project,local") + ); + assert_eq!( + arg_after(&process, "--permission-mode").as_deref(), + Some("default") + ); + assert_eq!( + arg_after(&process, "--permission-prompt-tool").as_deref(), + Some("mcp__botloft__permission_prompt") + ); assert_eq!( - args[0], "--settings", - "first start has nothing to --continue" + arg_after(&process, "--allowedTools").as_deref(), + Some("mcp__botloft") ); - assert!(args[1].ends_with("settings.json") && args[2] == "--mcp-config"); + assert!(arg_after(&process, "--mcp-config").is_some_and(|path| path.ends_with("mcp.json"))); assert!(process.spec.cwd.ends_with("scout")); assert_eq!( process.env("BOTLOFT_BOT_ID").as_deref(), Some(s.bot.as_str()) ); assert_eq!(process.env("BOTLOFT_PORT").as_deref(), Some("45710")); - assert!(process.env("BOTLOFT_HOME").is_some() && process.env("BOTLOFT_BIN").is_some()); assert_eq!( process.env("CLAUDECODE"), None, @@ -107,45 +79,13 @@ async fn a_new_bot_starts_with_the_spec_command_line_and_a_clean_environment() { } #[tokio::test(start_paused = true)] -async fn hooks_drive_the_state_and_input_answers_a_permission_prompt() { - let s = setup().await; - let process = s.runtime.process(1).await; - s.until(BotState::Launching).await; - - s.hook(&process, Hook::SessionStart { inbox: None }); - assert_eq!(s.state(), BotState::Idle); - s.hook(&process, Hook::PromptSubmit); - assert_eq!(s.state(), BotState::Busy); - s.hook( - &process, - Hook::Notification { - kind: "permission_prompt".into(), - }, - ); - assert_eq!(s.state(), BotState::NeedsApproval); - s.daemon - .supervisor - .write(&s.bot, "1".into()) - .expect("write"); - assert_eq!(s.state(), BotState::Busy); - assert_eq!(process.input(), b"1"); - s.hook( - &process, - Hook::StopFailure { - error: "rate_limit".into(), - }, - ); - assert_eq!(s.state(), BotState::RateLimited); - s.hook(&process, Hook::Stop); - assert_eq!(s.state(), BotState::Idle); -} - -#[tokio::test(start_paused = true)] -async fn a_crash_restarts_with_backoff_and_continue_and_a_new_token() { +async fn a_crash_restarts_with_backoff_in_the_same_session_and_a_new_token() { let s = setup().await; let first = s.runtime.process(1).await; - s.until(BotState::Launching).await; - s.hook(&first, Hook::SessionStart { inbox: None }); + s.until(BotState::Idle).await; + let session = arg_after(&first, "--session-id").expect("session"); + s.message("hi"); + first.emit(stream::init(&session)).await; let old_token = first.env("BOTLOFT_BOT_TOKEN").expect("token"); let (_, first_generation) = s.daemon.supervisor.status(&s.bot).expect("slot"); @@ -155,63 +95,44 @@ async fn a_crash_restarts_with_backoff_and_continue_and_a_new_token() { let second = s.runtime.process(2).await; s.until(BotState::Launching).await; - assert_eq!(second.args()[0], "--continue"); + assert_eq!(arg_after(&second, "--resume"), Some(session.clone())); + assert_eq!(arg_after(&second, "--session-id"), None); assert!( - s.daemon.supervisor.hook_owner(&old_token).is_none(), + s.daemon.supervisor.token_owner(&old_token).is_none(), "old token is revoked" ); let (_, generation) = s.daemon.supervisor.status(&s.bot).expect("slot"); assert!(generation > first_generation); + let stored = s.daemon.store().session_id(&s.bot).expect("read"); + assert_eq!( + stored, + Some(session), + "the session survives a daemon restart too" + ); } #[tokio::test(start_paused = true)] async fn a_resumed_session_that_dies_at_once_comes_back_fresh() { let s = setup().await; let first = s.runtime.process(1).await; - s.until(BotState::Launching).await; - s.hook(&first, Hook::SessionStart { inbox: None }); + s.until(BotState::Idle).await; + let session = arg_after(&first, "--session-id").expect("session"); first.exit(0).await; let resumed = s.runtime.process(2).await; - assert_eq!(resumed.args()[0], "--continue"); + assert_eq!(arg_after(&resumed, "--resume"), Some(session.clone())); resumed.exit(1).await; let fresh = s.runtime.process(3).await; - assert_eq!(fresh.args()[0], "--settings"); -} - -#[tokio::test(start_paused = true)] -async fn auth_errors_wait_for_the_owner() { - let s = setup().await; - let process = s.runtime.process(1).await; - s.until(BotState::Launching).await; - s.hook( - &process, - Hook::StopFailure { - error: "authentication_failed".into(), - }, - ); - assert_eq!(s.state(), BotState::AuthError); - process.exit(1).await; - - tokio::time::sleep(Duration::from_secs(600)).await; - assert_eq!(s.runtime.processes().len(), 1, "no restart loop"); - assert_eq!(s.state(), BotState::AuthError); - - let restart = BotsRestartParams { - bot_id: s.bot.clone(), - fresh: None, - }; - bots::restart(&s.daemon, restart).expect("restart"); - s.runtime.process(2).await; - s.until(BotState::Launching).await; + let new_session = arg_after(&fresh, "--session-id").expect("a new session"); + assert_ne!(new_session, session); } #[tokio::test(start_paused = true)] async fn pausing_stops_the_bot_and_resuming_starts_it() { let s = setup().await; let first = s.runtime.process(1).await; - s.until(BotState::Launching).await; + s.until(BotState::Idle).await; let pause = |paused| BotsSetPausedParams { bot_id: s.bot.clone(), @@ -232,7 +153,7 @@ async fn pausing_stops_the_bot_and_resuming_starts_it() { async fn archiving_stops_the_bot_for_good() { let s = setup().await; let process = s.runtime.process(1).await; - s.until(BotState::Launching).await; + s.until(BotState::Idle).await; bots::archive( &s.daemon, BotIdParams { @@ -247,11 +168,11 @@ async fn archiving_stops_the_bot_for_good() { } #[tokio::test(start_paused = true)] -async fn a_fresh_restart_replaces_the_process_without_continue() { +async fn a_fresh_restart_starts_a_new_session() { let s = setup().await; let first = s.runtime.process(1).await; - s.until(BotState::Launching).await; - s.hook(&first, Hook::SessionStart { inbox: None }); + s.until(BotState::Idle).await; + let session = arg_after(&first, "--session-id").expect("session"); let restart = BotsRestartParams { bot_id: s.bot.clone(), @@ -260,29 +181,8 @@ async fn a_fresh_restart_replaces_the_process_without_continue() { bots::restart(&s.daemon, restart).expect("restart"); let second = s.runtime.process(2).await; assert!(first.killed()); - assert_eq!(second.args()[0], "--settings"); -} - -#[tokio::test(start_paused = true)] -async fn output_lands_in_the_terminal_and_the_size_carries_over() { - let s = setup().await; - let first = s.runtime.process(1).await; - s.until(BotState::Launching).await; - // ConPTY's startup cursor query is answered by the daemon itself. - first.output(b"\x1b[6nhello\r\n").await; - tokio::time::sleep(Duration::from_millis(50)).await; - assert_eq!(first.input(), b"\x1b[1;1R"); - - let terminal = s.daemon.supervisor.terminal(&s.bot); - let attached = terminal.attach(None, None); - assert_eq!(&attached.replay[..], b"\x1b[6nhello\r\n"); - - let size = botloftd::runtime::TermSize { cols: 80, rows: 24 }; - s.daemon.supervisor.resize(&s.bot, size); - assert_eq!(first.sizes(), vec![size]); - first.exit(1).await; - let second = s.runtime.process(2).await; - assert_eq!(second.spec.size, size); + let new_session = arg_after(&second, "--session-id").expect("a new session"); + assert_ne!(new_session, session); } #[tokio::test(start_paused = true)] diff --git a/crates/botloftd/tests/supervisor_turns.rs b/crates/botloftd/tests/supervisor_turns.rs new file mode 100644 index 00000000..b8f69308 --- /dev/null +++ b/crates/botloftd/tests/supervisor_turns.rs @@ -0,0 +1,99 @@ +//! How the bot's own output and approvals move its state (spec 7.2), on a +//! FakeRuntime and a paused clock. + +mod common; + +use std::time::Duration; + +use botloft_core::protocol::{BotState, BotsRestartParams}; +use botloftd::clock::Clock as _; +use botloftd::service::bots; +use common::stream; +use common::supervised::setup; +use serde_json::json; + +#[tokio::test(start_paused = true)] +async fn turns_and_approvals_drive_the_state() { + let s = setup().await; + let process = s.runtime.process(1).await; + s.until(BotState::Idle).await; + let (_, generation) = s.daemon.supervisor.status(&s.bot).expect("slot"); + let generation = generation.expect("running"); + + s.message("first"); + s.message("queued behind the first"); + assert_eq!(s.state(), BotState::Busy); + assert_eq!(process.input_lines().len(), 2); + + s.daemon.supervisor.approval_opened(&s.bot, generation); + assert_eq!(s.state(), BotState::NeedsApproval); + s.daemon.supervisor.approval_closed(&s.bot, generation); + assert_eq!(s.state(), BotState::Busy); + + process.emit(stream::result(false)).await; + tokio::time::sleep(Duration::from_millis(20)).await; + assert_eq!(s.state(), BotState::Busy, "one turn is still queued"); + process.emit(stream::result(false)).await; + s.until(BotState::Idle).await; + // News about a process that is gone changes nothing. + s.daemon.supervisor.approval_opened(&s.bot, generation + 1); + assert_eq!(s.state(), BotState::Idle); +} + +#[tokio::test(start_paused = true)] +async fn sign_in_errors_stop_the_bot_until_the_owner_restarts_it() { + let s = setup().await; + let process = s.runtime.process(1).await; + s.until(BotState::Idle).await; + s.message("hi"); + process + .emit(stream::api_error( + "authentication_failed", + "Invalid API key", + )) + .await; + s.until(BotState::AuthError).await; + assert!(process.killed(), "restarting cannot fix a sign-in"); + + tokio::time::sleep(Duration::from_secs(600)).await; + assert_eq!(s.runtime.processes().len(), 1, "no restart loop"); + assert_eq!(s.state(), BotState::AuthError); + + let restart = BotsRestartParams { + bot_id: s.bot.clone(), + fresh: None, + }; + bots::restart(&s.daemon, restart).expect("restart"); + s.runtime.process(2).await; + s.until(BotState::Launching).await; +} + +#[tokio::test(start_paused = true)] +async fn a_rate_limit_holds_the_bot_until_it_resets() { + let s = setup().await; + let process = s.runtime.process(1).await; + s.until(BotState::Idle).await; + s.message("hi"); + // `resetsAt` is in seconds. + let resets = s.clock.now_ms() / 1000 + 30; + process + .emit(json!({ + "type": "rate_limit_event", + "rate_limit_info": { "status": "rejected", "resetsAt": resets, "rateLimitType": "five_hour", + "unifiedWindows": { "five_hour": { "utilization": 1.0, "resetsAt": resets } } }, + })) + .await; + s.until(BotState::RateLimited).await; + process.emit(stream::result(true)).await; + tokio::time::sleep(Duration::from_secs(10)).await; + assert_eq!(s.state(), BotState::RateLimited); + let usage = s.daemon.usage().expect("usage"); + assert_eq!( + (usage.status.as_str(), usage.resets_at), + ("rejected", Some(resets * 1000)) + ); + assert_eq!(usage.windows[0].name, "five_hour"); + + s.clock.advance(Duration::from_secs(31)); + s.until(BotState::Idle).await; +} diff --git a/crates/botloftd/tests/tasks.rs b/crates/botloftd/tests/tasks.rs index bdae4a0e..f2ef2a8e 100644 --- a/crates/botloftd/tests/tasks.rs +++ b/crates/botloftd/tests/tasks.rs @@ -8,8 +8,7 @@ mod common; use std::time::Duration; use common::Client; -use common::bots::pipe; -use common::bots::{ready_bot, two_bots}; +use common::bots::{ready_bot, text_of, two_bots}; use serde_json::{Value, json}; fn task_id(out: &Value) -> String { @@ -41,10 +40,9 @@ async fn a_task_reaches_the_assignee_and_its_result_comes_back() { let id = task_id(&sent); assert_eq!(sent["due"], "due in 30 min"); - let asked = c.t.inbox.post(1).await; - assert_eq!(asked.address, pipe(2)); + let asked = text_of(&c.writer_process.wait_lines(1).await[0]); assert_eq!( - asked.text, + asked, format!( "[botloft] from @lead · crew Ops · task {id} · due in 30 min\n\ Reply with send_message(to: \"lead\"). When the task is done, call \ @@ -71,10 +69,9 @@ async fn a_task_reaches_the_assignee_and_its_result_comes_back() { .await .expect("complete"); assert_eq!(done["status"], "done"); - let result = c.t.inbox.post(2).await; - assert_eq!(result.address, pipe(1)); + let result = text_of(&c.lead_process.wait_lines(1).await[0]); assert_eq!( - result.text, + result, format!( "[botloft] from @writer · crew Ops · result of task {id} · done\n\ Reply with send_message(to: \"writer\").\n\nIt is in shared/intro.md." @@ -158,16 +155,14 @@ async fn an_overdue_task_expires_and_can_still_be_completed() { .await .expect("send"); let id = task_id(&sent); - c.t.inbox.post(1).await; + c.writer_process.wait_lines(1).await; c.t.clock.advance(Duration::from_secs(61)); - let notice = c.t.inbox.post(2).await; - assert_eq!(notice.address, pipe(1)); + let notice = text_of(&c.lead_process.wait_lines(1).await[0]); assert!( - notice.text.starts_with(&format!( + notice.starts_with(&format!( "[botloft] from Botloft · crew Ops · task {id} · expired\n\nTask {id} for @writer passed its deadline" )), - "{}", - notice.text + "{notice}" ); assert_eq!(task(&mut c.app, &id).await["status"], "expired"); @@ -178,8 +173,8 @@ async fn an_overdue_task_expires_and_can_still_be_completed() { ) .await .expect("late result"); - let result = c.t.inbox.post(3).await; - assert!(result.text.contains("result of task") && result.text.ends_with("Late, but done.")); + let result = text_of(&c.lead_process.wait_lines(2).await[1]); + assert!(result.contains("result of task") && result.ends_with("Late, but done.")); } #[tokio::test] @@ -261,7 +256,7 @@ async fn bots_only_reach_their_own_crew() { .call("crews.create", json!({ "name": "Other" })) .await .expect("crew"); - let mut stranger = ready_bot(&c.t, &mut c.app, &other, "Writer", 3).await; + let (_, stranger_process, mut stranger) = ready_bot(&c.t, &mut c.app, &other, "Writer").await; assert_ne!(other["id"], c.crew["id"]); let sent = c @@ -272,10 +267,10 @@ async fn bots_only_reach_their_own_crew() { ) .await .expect("send"); - assert_eq!( - c.t.inbox.post(1).await.address, - pipe(2), - "the writer of Ops" + c.writer_process.wait_lines(1).await; + assert!( + stranger_process.input_lines().is_empty(), + "only the writer of Ops" ); let roster = c.lead.tool("crew_roster", json!({})).await.expect("roster"); assert_eq!(roster["bots"].as_array().map(Vec::len), Some(1)); diff --git a/docs/spec.md b/docs/spec.md index b51bbc0f..8beb6e5a 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -279,7 +279,7 @@ Todo item tem `id` (`cht_`), `botId`, `kind`, `createdAt` e `updatedAt`. ### 8.3 Ao vivo -- `chat.item {item}`: item novo ou atualizado (tool que terminou, aprovação respondida). +- `chat.item {item, activity}`: item novo ou atualizado (tool que terminou, aprovação respondida). `activity` é a nova linha da conversa na barra lateral, quando mudou. - `chat.delta {botId, text}`: pedaço do texto que o bot está escrevendo, na ordem. O app junta os pedaços num balão provisório, trocado pelo `reply` quando ele chega. Um app que conecta no meio de um turno não vê o texto parcial já passado, só o que vier depois e o `reply` final. ### 8.4 Privacidade @@ -313,7 +313,7 @@ Uma linha JSON por mensagem, terminada em `\n`, em UTF-8: {"type":"user","uuid":"","message":{"role":"user","content":[]}} ``` -- `uuid` é o ULID da delivery no formato de UUID. O Claude Code o devolve no replay (visto com 2.1.284). +- `uuid` é um UUID v4 novo a cada envio, guardado na delivery (`turn_uuid`). O Claude Code o devolve no replay (visto com 2.1.284). - Blocos: um `text` com o texto (9.3) e, para imagens anexadas, blocos `image` com `source: {type: "base64", media_type, data}`. - Mensagem escrita durante um turno entra na fila do Claude Code e vira o turno seguinte (visto com 2.1.284). O courier não precisa esperar o bot ficar parado. - O formato de entrada do `stream-json` **não é documentado**; foi verificado com teste real (seção 19). @@ -467,7 +467,7 @@ Pragmas: `journal_mode=WAL`, `foreign_keys=ON`, `busy_timeout=5000`. Migrations | `bots` | `id, crew_id, name, handle, slug, role, instructions, color, paused, token_hash, session_id, created_at, archived_at` | | `messages` | `id, crew_id, from_kind (owner/bot/system), from_bot_id, to_bot_id, kind (note/task/result/system), body, task_id, created_at` | | `attachments` | `id, message_id, name, media_type, size, path, created_at` | -| `deliveries` | `id, message_id, bot_id, state, attempts, next_attempt_at, lease_until, last_error, sent_generation, read_at, updated_at` | +| `deliveries` | `id, message_id, bot_id, state, attempts, next_attempt_at, lease_until, last_error, sent_generation, turn_uuid, read_at, updated_at` | | `tasks` | `id, crew_id, requester_bot_id, assignee_bot_id, status, deadline_at, hops, origin_task_id, result, created_at, updated_at` | | `chat_items` | `id, bot_id, kind, data (JSON), created_at, updated_at` | | `approvals` | `id, bot_id, tool_use_id, tool_name, input, status, note, created_at, answered_at` | From 8219f04f524bbd7d943a044206abc9858b4801d6 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:50:06 -0300 Subject: [PATCH 3/8] feat(daemon): read attachments back so the chat can show them attachments.read returns a file the owner sent, as it is now in the bot's folder, so the app can show images after a reload. A file the bot removed is not_found; one that grew past attachment_max_mb is refused. --- app/src/lib/protocol.gen.ts | 12 +++ crates/botloft-core/src/protocol/export.rs | 7 ++ crates/botloft-core/src/protocol/messaging.rs | 26 +++++++ crates/botloft-core/src/protocol/mod.rs | 1 + crates/botloft-store/src/messages.rs | 16 +++- crates/botloft-store/src/messages/tests.rs | 7 ++ crates/botloftd/src/rpc/dispatch.rs | 5 +- crates/botloftd/src/service/attachments.rs | 56 +++++++++++++- crates/botloftd/tests/attachments.rs | 74 +++++++++++++++++++ crates/botloftd/tests/courier.rs | 38 ---------- docs/spec.md | 2 + 11 files changed, 200 insertions(+), 44 deletions(-) create mode 100644 crates/botloftd/tests/attachments.rs diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 942ea8f5..35bc9432 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -282,6 +282,17 @@ export type AttachmentUpload = { name: string, mediaType: string, */ data: string, }; +export type AttachmentIdParams = { attachmentId: AttachmentId, }; + +/** + * A saved attachment's bytes, for the app to show (spec 9.5). + */ +export type AttachmentData = { mediaType: string, +/** + * The file's bytes, base64. + */ +data: string, }; + /** * Text sent to a bot, by the owner, another bot or the daemon. */ @@ -516,6 +527,7 @@ export interface RpcMethods { "approvals.answer": { params: ApprovalsAnswerParams; result: Approval }; "messages.send": { params: MessagesSendParams; result: Message }; "messages.list": { params: MessagesListParams; result: Array }; + "attachments.read": { params: AttachmentIdParams; result: AttachmentData }; "deliveries.list": { params: DeliveriesListParams; result: Array }; "deliveries.retry": { params: DeliveryIdParams; result: Delivery }; "tasks.list": { params: TasksListParams; result: Array }; diff --git a/crates/botloft-core/src/protocol/export.rs b/crates/botloft-core/src/protocol/export.rs index 21ecf0c2..76ca96a9 100644 --- a/crates/botloft-core/src/protocol/export.rs +++ b/crates/botloft-core/src/protocol/export.rs @@ -113,6 +113,8 @@ fn export_bindings() { out.decl::(); out.decl::(); out.decl::(); + out.decl::(); + out.decl::(); out.decl::(); out.decl::(); out.decl::(); @@ -205,6 +207,11 @@ fn export_bindings() { &out.name::(), &out.name::>(), ); + out.method( + method::ATTACHMENTS_READ, + &out.name::(), + &out.name::(), + ); out.method( method::DELIVERIES_LIST, &out.name::(), diff --git a/crates/botloft-core/src/protocol/messaging.rs b/crates/botloft-core/src/protocol/messaging.rs index b067b461..c0eb64e3 100644 --- a/crates/botloft-core/src/protocol/messaging.rs +++ b/crates/botloft-core/src/protocol/messaging.rs @@ -108,6 +108,32 @@ impl std::fmt::Debug for AttachmentUpload { } } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct AttachmentIdParams { + pub attachment_id: AttachmentId, +} + +/// A saved attachment's bytes, for the app to show (spec 9.5). +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(test, derive(ts_rs::TS))] +pub struct AttachmentData { + pub media_type: String, + /// The file's bytes, base64. + pub data: String, +} + +impl std::fmt::Debug for AttachmentData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AttachmentData") + .field("media_type", &self.media_type) + .field("data", &format_args!("<{} base64 chars>", self.data.len())) + .finish() + } +} + /// Getting one message into one bot's process. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/botloft-core/src/protocol/mod.rs b/crates/botloft-core/src/protocol/mod.rs index 0d0a06e2..c4d4fa38 100644 --- a/crates/botloft-core/src/protocol/mod.rs +++ b/crates/botloft-core/src/protocol/mod.rs @@ -84,6 +84,7 @@ pub mod method { pub const APPROVALS_ANSWER: &str = "approvals.answer"; pub const MESSAGES_SEND: &str = "messages.send"; pub const MESSAGES_LIST: &str = "messages.list"; + pub const ATTACHMENTS_READ: &str = "attachments.read"; pub const DELIVERIES_LIST: &str = "deliveries.list"; pub const DELIVERIES_RETRY: &str = "deliveries.retry"; pub const TASKS_LIST: &str = "tasks.list"; diff --git a/crates/botloft-store/src/messages.rs b/crates/botloft-store/src/messages.rs index 28124a8d..cc26f03e 100644 --- a/crates/botloft-store/src/messages.rs +++ b/crates/botloft-store/src/messages.rs @@ -2,7 +2,7 @@ //! its attachments, its delivery, the task it creates and the recipient's //! chat item, in one transaction (spec 9.1). -use botloft_core::ids::{BotId, ChatItemId, CrewId, MessageId, TaskId}; +use botloft_core::ids::{AttachmentId, BotId, ChatItemId, CrewId, MessageId, TaskId}; use botloft_core::protocol::{ Attachment, ChatBody, ChatItem, Delivery, InboundItem, Message, Task, }; @@ -171,6 +171,20 @@ impl Store { Ok(with_attachments(&self.conn, message.into_iter().collect())?.pop()) } + /// An attachment and the bot whose folder holds it. + pub fn attachment(&self, id: &AttachmentId) -> Result> { + let found = self + .conn + .query_row( + "SELECT a.id, a.name, a.media_type, a.size, a.path, m.to_bot_id \ + FROM attachments a JOIN messages m ON m.id = a.message_id WHERE a.id = ?1", + [id.as_str()], + |row| Ok((attachment_from_row(row)?, parse_column(row, 5)?)), + ) + .optional()?; + Ok(found) + } + /// Newest first, in the order they were stored. pub fn messages(&self, filter: MessageFilter<'_>) -> Result> { let mut stmt = self.conn.prepare(&format!( diff --git a/crates/botloft-store/src/messages/tests.rs b/crates/botloft-store/src/messages/tests.rs index e8585d60..208a2402 100644 --- a/crates/botloft-store/src/messages/tests.rs +++ b/crates/botloft-store/src/messages/tests.rs @@ -61,6 +61,13 @@ fn attachments_and_the_chat_item_come_with_the_message() { }) .expect("list"); assert_eq!(listed[0].attachments, message.attachments); + let first = &message.attachments[0]; + assert_eq!( + fx.store.attachment(&first.id).expect("read"), + Some((first.clone(), fx.bots[0].id.clone())) + ); + let missing = botloft_core::ids::AttachmentId::generate(); + assert_eq!(fx.store.attachment(&missing).expect("read"), None); } #[test] diff --git a/crates/botloftd/src/rpc/dispatch.rs b/crates/botloftd/src/rpc/dispatch.rs index 79822210..bea706f8 100644 --- a/crates/botloftd/src/rpc/dispatch.rs +++ b/crates/botloftd/src/rpc/dispatch.rs @@ -7,7 +7,9 @@ use serde_json::Value; use super::jsonrpc::{RpcError, empty_params}; use crate::approvals; -use crate::service::{self, ApiResult, bots, chat, crews, deliveries, messages, tasks}; +use crate::service::{ + self, ApiResult, attachments, bots, chat, crews, deliveries, messages, tasks, +}; use crate::state::Daemon; pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result { @@ -28,6 +30,7 @@ pub fn dispatch(daemon: &Daemon, name: &str, params: Option) -> Result reply(approvals::answer(daemon, parse(params)?)), method::MESSAGES_SEND => reply(messages::send(daemon, parse(params)?)), method::MESSAGES_LIST => reply(messages::list(daemon, parse(params)?)), + method::ATTACHMENTS_READ => reply(attachments::read(daemon, parse(params)?)), method::DELIVERIES_LIST => reply(deliveries::list(daemon, parse(params)?)), method::DELIVERIES_RETRY => reply(deliveries::retry(daemon, parse(params)?)), method::TASKS_LIST => reply(tasks::list(daemon, parse(params)?)), diff --git a/crates/botloftd/src/service/attachments.rs b/crates/botloftd/src/service/attachments.rs index cbdf03e5..689c3226 100644 --- a/crates/botloftd/src/service/attachments.rs +++ b/crates/botloftd/src/service/attachments.rs @@ -1,15 +1,17 @@ //! Files the owner sends with a message (spec 9.5): saved in the bot's -//! `attachments\\` folder before the message is stored. +//! `attachments\\` folder before the message is stored, and +//! read back for the app to show. -use std::path::Path; +use std::path::{Component, Path}; use base64::Engine as _; use base64::engine::general_purpose::STANDARD as BASE64; use botloft_core::ids::AttachmentId; -use botloft_core::protocol::{Attachment, AttachmentUpload}; +use botloft_core::protocol::{Attachment, AttachmentData, AttachmentIdParams, AttachmentUpload}; use botloft_core::{slug, validate}; -use super::{ApiError, ApiResult}; +use super::{ApiError, ApiResult, bots, crews}; +use crate::state::Daemon; const ATTACHMENTS_DIR: &str = "attachments"; const DEFAULT_MEDIA_TYPE: &str = "application/octet-stream"; @@ -64,6 +66,52 @@ pub(crate) fn save( Ok(saved) } +/// `attachments.read`: a file the owner sent, as it is now in the bot's +/// folder. Archived bots keep theirs; the bot may have changed or removed it. +pub fn read(daemon: &Daemon, params: AttachmentIdParams) -> ApiResult { + let id = params.attachment_id; + let store = daemon.store(); + let (attachment, bot_id) = store + .attachment(&id)? + .ok_or_else(|| ApiError::NotFound(format!("attachment {id} does not exist")))?; + let bot = bots::find(&store, &bot_id)?; + let crew = crews::find(&store, &bot.crew_id)?; + drop(store); + let relative = Path::new(&attachment.path); + // The daemon wrote the path; a stored one that climbs out is refused all the same. + if !relative + .components() + .all(|part| matches!(part, Component::Normal(_))) + { + return Err(ApiError::NotFound(format!( + "attachment {id} does not exist" + ))); + } + let path = daemon + .paths + .bot_workspace(&crew.slug, &bot.slug) + .join(relative); + let gone = || { + ApiError::NotFound(format!( + "{} is no longer in the bot's folder", + attachment.name + )) + }; + let size = std::fs::metadata(&path).map_err(|_| gone())?.len(); + if size > daemon.bots.attachment_max_bytes { + return Err(ApiError::validation(format!( + "{} is larger than {} MB now", + attachment.name, + daemon.bots.attachment_max_bytes / (1024 * 1024) + ))); + } + let bytes = std::fs::read(&path).map_err(|_| gone())?; + Ok(AttachmentData { + media_type: attachment.media_type, + data: BASE64.encode(bytes), + }) +} + /// A MIME type as given, or the generic one when it looks wrong. fn media_type(given: &str) -> String { let given = given.trim().to_ascii_lowercase(); diff --git a/crates/botloftd/tests/attachments.rs b/crates/botloftd/tests/attachments.rs new file mode 100644 index 00000000..f36779b5 --- /dev/null +++ b/crates/botloftd/tests/attachments.rs @@ -0,0 +1,74 @@ +//! Files the owner sends to a bot (spec 9.5): saved in its folder, listed +//! in the prompt, images inline, and read back for the app. + +mod common; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD as BASE64; +use botloft_core::protocol::BotState; +use common::TestDaemon; +use common::bots::text_of; +use serde_json::{Value, json}; + +const NOT_FOUND: i64 = -32002; + +#[tokio::test] +async fn attachments_are_saved_and_images_go_inline() { + let t = TestDaemon::start_supervised().await; + let mut app = t.session().await; + let crew = app + .call("crews.create", json!({ "name": "Ops" })) + .await + .expect("crew"); + let bot = json!({ "crewId": crew["id"], "name": "Scout", "role": "", "instructions": "" }); + let bot = app.call("bots.create", bot).await.expect("bot"); + t.until_state(&bot, BotState::Idle).await; + let process = t.process_of(&bot).await; + let params = json!({ "botId": bot["id"], "body": "", "attachments": [ + { "name": "screen.png", "mediaType": "image/png", "data": BASE64.encode(b"png") }, + { "name": "../../report.pdf", "mediaType": "application/pdf", "data": BASE64.encode(b"pdf") }, + ] }); + let message = app + .call("messages.send", params) + .await + .expect("files alone"); + let attachments = message["attachments"].as_array().expect("attachments"); + assert_eq!(attachments.len(), 2); + assert_eq!(attachments[1]["name"], "report.pdf"); + let workspace = std::path::PathBuf::from(bot["workspace"].as_str().expect("workspace")); + for attachment in attachments { + let path = workspace.join(attachment["path"].as_str().expect("path")); + assert!(path.is_file(), "{} missing", path.display()); + } + + let line = process.wait_lines(1).await.remove(0); + let text = text_of(&line); + assert!( + text.starts_with("\n\nAttached files, saved in your folder: attachments/"), + "{text}" + ); + assert!(text.ends_with("/report.pdf")); + assert_eq!(line["message"]["content"][1]["type"], "image"); + assert_eq!( + line["message"]["content"][1]["source"]["data"], + BASE64.encode(b"png") + ); + + // The app reads the files back to show them, while they are there. + let read = |id: &Value| json!({ "attachmentId": id }); + let image = app + .call("attachments.read", read(&attachments[0]["id"])) + .await + .expect("read"); + assert_eq!( + image, + json!({ "mediaType": "image/png", "data": BASE64.encode(b"png") }) + ); + std::fs::remove_file(workspace.join(attachments[1]["path"].as_str().expect("path"))) + .expect("remove"); + let gone = app + .call("attachments.read", read(&attachments[1]["id"])) + .await + .expect_err("removed"); + assert_eq!(gone.code, NOT_FOUND); +} diff --git a/crates/botloftd/tests/courier.rs b/crates/botloftd/tests/courier.rs index 9f8f9490..812f326a 100644 --- a/crates/botloftd/tests/courier.rs +++ b/crates/botloftd/tests/courier.rs @@ -190,44 +190,6 @@ async fn a_message_the_bot_began_is_not_sent_again() { assert!(second.input_lines().is_empty()); } -#[tokio::test] -async fn attachments_are_saved_and_images_go_inline() { - let t = TestDaemon::start_supervised().await; - let mut app = t.session().await; - let bot = crew_and_bot(&mut app).await; - t.until_state(&bot, BotState::Idle).await; - let process = t.process_of(&bot).await; - let params = json!({ "botId": bot["id"], "body": "", "attachments": [ - { "name": "screen.png", "mediaType": "image/png", "data": BASE64.encode(b"png") }, - { "name": "../../report.pdf", "mediaType": "application/pdf", "data": BASE64.encode(b"pdf") }, - ] }); - let message = app - .call("messages.send", params) - .await - .expect("files alone"); - let attachments = message["attachments"].as_array().expect("attachments"); - assert_eq!(attachments.len(), 2); - assert_eq!(attachments[1]["name"], "report.pdf"); - let workspace = std::path::PathBuf::from(bot["workspace"].as_str().expect("workspace")); - for attachment in attachments { - let path = workspace.join(attachment["path"].as_str().expect("path")); - assert!(path.is_file(), "{} missing", path.display()); - } - - let line = process.wait_lines(1).await.remove(0); - let text = text_of(&line); - assert!( - text.starts_with("\n\nAttached files, saved in your folder: attachments/"), - "{text}" - ); - assert!(text.ends_with("/report.pdf")); - assert_eq!(line["message"]["content"][1]["type"], "image"); - assert_eq!( - line["message"]["content"][1]["source"]["data"], - BASE64.encode(b"png") - ); -} - #[tokio::test] async fn archiving_a_bot_drops_what_is_still_queued_for_it() { let t = TestDaemon::start_supervised().await; diff --git a/docs/spec.md b/docs/spec.md index 8beb6e5a..e8bd5659 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -353,6 +353,7 @@ Reply with send_message(to: "revisor"). When the task is done, call complete_tas - Imagens PNG, JPEG, GIF e WebP de até 5 MB também seguem inline como blocos `image`, para o bot vê-las sem abrir arquivo. - O texto da mensagem ganha, no fim, a lista do que foi salvo (`Attached files, saved in your folder: attachments/2026-09-28/relatorio.pdf`), para o bot abrir o resto com as ferramentas dele (PDF, planilha, código...). - Anexos ficam na pasta do bot até alguém apagar; o chat mostra nome, tipo e tamanho, e o app abre a pasta. +- Imagens aparecem como miniatura. O app lê o arquivo de volta com `attachments.read`, que devolve os bytes como estão agora na pasta do bot (o bot pode ter mudado ou apagado o arquivo; apagado dá `not_found`). Arquivo maior que `attachment_max_mb` não é lido. ## 10. Tools MCP dos bots (`POST /mcp`) @@ -435,6 +436,7 @@ Endpoint: `ws://127.0.0.1:45710/rpc`. Mensagens seguem JSON-RPC 2.0: requests co | `approvals.answer` | `approvalId, allow, note?` | `Approval` | | `messages.send` | `botId, body, attachments?` (`[{name, mediaType, data}]`, data em base64) | `Message` | | `messages.list` | `crewId?, botId?, before?, limit?` | `Message[]` | +| `attachments.read` | `attachmentId` | `{mediaType, data}`, data em base64 (9.5) | | `deliveries.list` | `state?, botId?` | `Delivery[]` | | `deliveries.retry` | `deliveryId` | `Delivery` | | `tasks.list` | `crewId?, status?` | `Task[]` | From 86063b7b50cc255442713e3708fdd5e172c2991a Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:50:14 -0300 Subject: [PATCH 4/8] feat(app): open links from bot replies in the default browser A link followed inside the webview would replace the app. open_url hands http and https links to the default browser and refuses anything else, since other schemes can start programs. --- app/src-tauri/src/lib.rs | 53 +++++++++++++++++++++++++++++++++++++++- app/src/lib/fakeHost.ts | 20 +++++++++++++-- app/src/lib/host.ts | 2 ++ app/src/lib/tauriHost.ts | 1 + docs/spec.md | 1 + 5 files changed, 74 insertions(+), 3 deletions(-) diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 3797bffd..449c1646 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -51,6 +51,35 @@ async fn open_path(path: String) -> Result<(), String> { .await } +/// Whether `url` is a plain web link, safe to hand to the default browser. +fn is_web_link(url: &str) -> bool { + let rest = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")); + rest.is_some_and(|rest| !rest.is_empty()) + && url.len() <= 2048 + && !url + .chars() + .any(|ch| ch.is_whitespace() || ch.is_control() || ch == '"') +} + +/// Opens a web link from a bot's reply in the default browser. Only +/// `http` and `https`: anything else could start a program. +#[tauri::command] +async fn open_url(url: String) -> Result<(), String> { + if !is_web_link(&url) { + return Err("only web links can be opened".into()); + } + blocking(move || { + std::process::Command::new("explorer.exe") + .arg(&url) + .spawn() + .map(drop) + .map_err(|err| format!("cannot open the browser: {err}")) + }) + .await +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -58,8 +87,30 @@ pub fn run() { daemon_status, daemon_start, read_owner_token, - open_path + open_path, + open_url ]) .run(tauri::generate_context!()) .expect("error while running the Botloft app"); } + +#[cfg(test)] +mod tests { + use super::is_web_link; + + #[test] + fn only_plain_web_links_open() { + assert!(is_web_link("https://code.claude.com/docs")); + assert!(is_web_link("http://127.0.0.1:3000/page?a=1&b=2")); + for url in [ + "file:///C:/Windows/System32/calc.exe", + r"C:\Windows\notepad.exe", + "https://", + "https://a b", + "https://a\"b", + "ms-settings:privacy", + ] { + assert!(!is_web_link(url), "{url}"); + } + } +} diff --git a/app/src/lib/fakeHost.ts b/app/src/lib/fakeHost.ts index dee3d9c1..bccb494b 100644 --- a/app/src/lib/fakeHost.ts +++ b/app/src/lib/fakeHost.ts @@ -1,11 +1,22 @@ // A `Host` for tests: a daemon that is running unless told otherwise. import type { DaemonStatus, Host } from "./host"; +import { PROTOCOL_VERSION } from "./protocol.gen"; export class FakeHost implements Host { - status: DaemonStatus = { state: "running", port: 45710, version: "0.1.0", protocol: 1 }; + status: DaemonStatus = { + state: "running", + port: 45710, + version: "0.1.0", + protocol: PROTOCOL_VERSION, + }; /** What `startDaemon` leaves behind. */ - afterStart: DaemonStatus = { state: "running", port: 45710, version: "0.1.0", protocol: 1 }; + afterStart: DaemonStatus = { + state: "running", + port: 45710, + version: "0.1.0", + protocol: PROTOCOL_VERSION, + }; token: string | Error = "a".repeat(64); readonly opened: string[] = []; starts = 0; @@ -32,6 +43,11 @@ export class FakeHost implements Host { return Promise.resolve(); } + openUrl(url: string): Promise { + this.opened.push(url); + return Promise.resolve(); + } + window = { minimize: () => Promise.resolve(), toggleMaximize: () => { diff --git a/app/src/lib/host.ts b/app/src/lib/host.ts index ebef9167..9b26d4f5 100644 --- a/app/src/lib/host.ts +++ b/app/src/lib/host.ts @@ -27,5 +27,7 @@ export interface Host { readOwnerToken(): Promise; /** Opens a folder in Explorer. */ openPath(path: string): Promise; + /** Opens an http(s) link in the default browser. */ + openUrl(url: string): Promise; window: AppWindow; } diff --git a/app/src/lib/tauriHost.ts b/app/src/lib/tauriHost.ts index 45e3a4a2..efdab5e3 100644 --- a/app/src/lib/tauriHost.ts +++ b/app/src/lib/tauriHost.ts @@ -32,6 +32,7 @@ export function tauriHost(): Host { startDaemon: () => invoke("daemon_start"), readOwnerToken: () => invoke("read_owner_token"), openPath: (path) => invoke("open_path", { path }), + openUrl: (url) => invoke("open_url", { url }), window: { minimize: () => window.minimize(), toggleMaximize: () => window.toggleMaximize(), diff --git a/docs/spec.md b/docs/spec.md index e8bd5659..24bec466 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -555,6 +555,7 @@ Layout, como um app de mensagens: | `daemon_restart` | `botloftd service restart` | | `read_owner_token` | lê `secrets\owner.token` (só no app local) | | `open_path` | abre uma pasta no Explorer; recusa arquivos, que o Explorer executaria | +| `open_url` | abre no navegador padrão um link de uma resposta do bot; só `http` e `https`, porque qualquer outro esquema pode iniciar um programa. Seguir o link dentro do app trocaria a janela pela página | | overlay na taskbar | não é comando próprio: o app usa `setOverlayIcon` da janela (permissão `core:window:allow-set-overlay-icon`) e marca o ícone com um ponto enquanto algo espera o dono: aprovação pendente, bot em `auth_error`, ou mensagem não entregue a um bot ativo. Entregas mortas para bot arquivado não contam: foram abandonadas de propósito | O app acha o daemon como o daemon acha a si mesmo (seção 5): `BOTLOFT_HOME` ou `%LOCALAPPDATA%\Botloft`, com a porta lida do `config.toml` dessa pasta (45710 se ausente). Um daemon de dev com seu próprio `BOTLOFT_HOME` é encontrado sem configuração extra. A CSP libera `ws://127.0.0.1:*` pelo mesmo motivo. From af1a0fc86914653eafe4c54f581a40f8eb4f72d0 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:50:22 -0300 Subject: [PATCH 5/8] feat(app): talk to bots in a chat instead of a terminal Each bot opens on its conversation, laid out like a messaging app: - the owner's messages on the right with delivery and read receipts, other bots' and Botloft's on the left - replies in markdown, streamed live while the bot writes them - tool calls as compact lines that open to show input and output - approval cards with Allow, Deny and a note for the bot - a composer where Enter sends, Shift+Enter breaks the line, and files can be picked, pasted or dropped; images show as thumbnails and are read back with attachments.read The sidebar lists bots as conversations with their state and last activity, and bot details moved to a side panel. xterm.js and the terminal feature are gone. --- app/package.json | 5 +- app/pnpm-lock.yaml | 921 ++++++++++++++++++- app/src/App.test.tsx | 58 +- app/src/dev/preview.ts | 90 +- app/src/dev/seedChats.ts | 128 +++ app/src/features/bots/BotHeader.tsx | 26 +- app/src/features/bots/BotStateBadge.tsx | 2 +- app/src/features/bots/BotView.tsx | 153 ++- app/src/features/chat/ApprovalCard.tsx | 109 +++ app/src/features/chat/AttachmentList.tsx | 151 +++ app/src/features/chat/BotRun.tsx | 98 ++ app/src/features/chat/ChatComposer.tsx | 201 ++++ app/src/features/chat/ChatView.tsx | 181 ++++ app/src/features/chat/InboundRow.tsx | 91 ++ app/src/features/chat/Markdown.tsx | 68 ++ app/src/features/chat/NoticeRow.tsx | 26 + app/src/features/chat/ToolLines.tsx | 120 +++ app/src/features/chat/chat.test.tsx | 195 ++++ app/src/features/chat/images.ts | 78 ++ app/src/features/chat/rows.test.ts | 62 ++ app/src/features/chat/rows.ts | 63 ++ app/src/features/chat/useChat.ts | 118 +++ app/src/features/chat/useFiles.ts | 92 ++ app/src/features/crews/Sidebar.tsx | 81 +- app/src/features/messages/DeliveryStatus.tsx | 11 +- app/src/features/messages/messages.test.tsx | 24 +- app/src/features/onboarding/link.test.ts | 3 +- app/src/features/terminal/TerminalView.tsx | 116 --- app/src/features/terminal/session.test.ts | 137 --- app/src/features/terminal/session.ts | 161 ---- app/src/index.css | 99 ++ app/src/lib/base64.ts | 23 +- app/src/lib/fake.ts | 27 +- app/src/lib/fakeChat.ts | 179 ++++ app/src/lib/fakeConversation.ts | 80 +- app/src/lib/fakeTerminal.ts | 121 --- app/src/lib/format.ts | 38 + app/src/lib/rpc.test.ts | 12 +- app/src/store/app.ts | 11 +- app/src/test/app.tsx | 2 - 40 files changed, 3267 insertions(+), 894 deletions(-) create mode 100644 app/src/dev/seedChats.ts create mode 100644 app/src/features/chat/ApprovalCard.tsx create mode 100644 app/src/features/chat/AttachmentList.tsx create mode 100644 app/src/features/chat/BotRun.tsx create mode 100644 app/src/features/chat/ChatComposer.tsx create mode 100644 app/src/features/chat/ChatView.tsx create mode 100644 app/src/features/chat/InboundRow.tsx create mode 100644 app/src/features/chat/Markdown.tsx create mode 100644 app/src/features/chat/NoticeRow.tsx create mode 100644 app/src/features/chat/ToolLines.tsx create mode 100644 app/src/features/chat/chat.test.tsx create mode 100644 app/src/features/chat/images.ts create mode 100644 app/src/features/chat/rows.test.ts create mode 100644 app/src/features/chat/rows.ts create mode 100644 app/src/features/chat/useChat.ts create mode 100644 app/src/features/chat/useFiles.ts delete mode 100644 app/src/features/terminal/TerminalView.tsx delete mode 100644 app/src/features/terminal/session.test.ts delete mode 100644 app/src/features/terminal/session.ts create mode 100644 app/src/lib/fakeChat.ts delete mode 100644 app/src/lib/fakeTerminal.ts diff --git a/app/package.json b/app/package.json index 06cbe98d..8099a096 100644 --- a/app/package.json +++ b/app/package.json @@ -19,12 +19,11 @@ "@fontsource/ibm-plex-mono": "^5.3.0", "@fontsource/ibm-plex-sans": "^5.3.0", "@tauri-apps/api": "^2.12.0", - "@xterm/addon-fit": "^0.11.0", - "@xterm/addon-webgl": "^0.19.0", - "@xterm/xterm": "^6.0.0", "lucide-react": "^1.48.0", "react": "^19.3.0", "react-dom": "^19.3.0", + "react-markdown": "^10.1.0", + "remark-gfm": "^4.0.1", "zustand": "^5.0.15" }, "devDependencies": { diff --git a/app/pnpm-lock.yaml b/app/pnpm-lock.yaml index 6da6a843..080c32e9 100644 --- a/app/pnpm-lock.yaml +++ b/app/pnpm-lock.yaml @@ -175,15 +175,6 @@ importers: '@tauri-apps/api': specifier: ^2.12.0 version: 2.12.0 - '@xterm/addon-fit': - specifier: ^0.11.0 - version: 0.11.0 - '@xterm/addon-webgl': - specifier: ^0.19.0 - version: 0.19.0 - '@xterm/xterm': - specifier: ^6.0.0 - version: 6.0.0 lucide-react: specifier: ^1.48.0 version: 1.48.0(react@19.3.0) @@ -193,6 +184,12 @@ importers: react-dom: specifier: ^19.3.0 version: 19.3.0(react@19.3.0) + react-markdown: + specifier: ^10.1.0 + version: 10.1.0(@types/react@19.3.0)(react@19.3.0) + remark-gfm: + specifier: ^4.0.1 + version: 4.0.1 zustand: specifier: ^5.0.15 version: 5.0.15(@types/react@19.3.0)(react@19.3.0) @@ -690,12 +687,27 @@ packages: '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': + resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/hast@3.0.5': + resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/ms@2.1.0': + resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/node@24.19.0': resolution: {integrity: sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==} @@ -707,6 +719,12 @@ packages: '@types/react@19.3.0': resolution: {integrity: sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript/typescript-aix-ppc64@7.0.2': resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} engines: {node: '>=16.20.0'} @@ -827,6 +845,9 @@ packages: cpu: [x64] os: [win32] + '@ungap/structured-clone@1.4.0': + resolution: {integrity: sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==} + '@vitejs/plugin-react@6.1.1': resolution: {integrity: sha512-yxLaQV9gkhS8ezJqCM6+ndU7mDY6gqAg75NQ+0IjwEI8IYOmQCgkRwHKVSfWXW076DsqMo0Dk+0FK1U+M5RgFw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -857,15 +878,6 @@ packages: '@vitest/spy@5.0.2': resolution: {integrity: sha512-Ijc7T1nT9efNb5LxvjaBrEqw3f/QwUv5EE0nKqZxgqsaV/FxAAZ8baGylA8X/Z2oS4Lp+K74Jr6dTJsDKxJDeg==} - '@xterm/addon-fit@0.11.0': - resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} - - '@xterm/addon-webgl@0.19.0': - resolution: {integrity: sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==} - - '@xterm/xterm@6.0.0': - resolution: {integrity: sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==} - ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -881,13 +893,34 @@ packages: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + bidi-js@1.1.0: resolution: {integrity: sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + css-tree@3.2.1: resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} @@ -899,9 +932,21 @@ packages: resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: + resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -910,6 +955,9 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -924,6 +972,13 @@ packages: es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + escape-string-regexp@5.0.0: + resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} + engines: {node: '>=12'} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -931,6 +986,9 @@ packages: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -948,10 +1006,38 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hast-util-to-jsx-runtime@2.3.6: + resolution: {integrity: sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + html-encoding-sniffer@7.0.0: resolution: {integrity: sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==} engines: {node: ^22.13.0 || >=24.0.0} + html-url-attributes@3.0.1: + resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + + inline-style-parser@0.2.7: + resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==} + + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -1119,6 +1205,9 @@ packages: resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} engines: {node: '>= 12.0.0'} + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + lru-cache@11.5.3: resolution: {integrity: sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==} engines: {node: 20 || >=22} @@ -1138,9 +1227,147 @@ packages: magic-string@1.4.2: resolution: {integrity: sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==} + markdown-table@3.0.4: + resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + + mdast-util-find-and-replace@3.0.2: + resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} + + mdast-util-from-markdown@2.0.3: + resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.1.0: + resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} + + mdast-util-gfm-strikethrough@2.0.1: + resolution: {integrity: sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.1.0: + resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.2.0: + resolution: {integrity: sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + mdast-util-to-markdown@2.1.3: + resolution: {integrity: sha512-wgyJtgUkUcdU7zci7uuwc/tzoAhm0TswWhaXuvnWolqug+8jY7bgNkBBL0dYBnHgc/tHL7lCzRdPsdUxUKKBqw==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + micromark-core-commonmark@2.0.4: + resolution: {integrity: sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.2: + resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-factory-destination@2.0.1: + resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} + + micromark-factory-label@2.0.1: + resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} + + micromark-factory-space@2.1.0: + resolution: {integrity: sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==} + + micromark-factory-title@2.0.1: + resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} + + micromark-factory-whitespace@2.0.1: + resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-chunked@2.0.1: + resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} + + micromark-util-classify-character@2.0.1: + resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} + + micromark-util-combine-extensions@2.0.1: + resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} + + micromark-util-decode-numeric-character-reference@2.0.2: + resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} + + micromark-util-decode-string@2.0.1: + resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} + + micromark-util-edit-map@1.0.0: + resolution: {integrity: sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-html-tag-name@2.0.1: + resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} + + micromark-util-normalize-identifier@2.0.1: + resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} + + micromark-util-resolve-all@2.0.1: + resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-subtokenize@2.1.0: + resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.3: + resolution: {integrity: sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==} + + micromark@4.0.3: + resolution: {integrity: sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.19: resolution: {integrity: sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -1150,6 +1377,9 @@ packages: resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} + parse-entities@4.0.2: + resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} @@ -1168,6 +1398,9 @@ packages: resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1180,10 +1413,28 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-markdown@10.1.0: + resolution: {integrity: sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==} + peerDependencies: + '@types/react': '>=18' + react: '>=18' + react@19.3.0: resolution: {integrity: sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==} engines: {node: '>=0.10.0'} + remark-gfm@4.0.1: + resolution: {integrity: sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.2: + resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -1204,9 +1455,21 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + style-to-js@1.1.21: + resolution: {integrity: sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==} + + style-to-object@1.0.14: + resolution: {integrity: sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==} + tailwindcss@4.3.3: resolution: {integrity: sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==} @@ -1241,6 +1504,12 @@ packages: resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} engines: {node: '>=20'} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + typescript@7.0.2: resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} engines: {node: '>=16.20.0'} @@ -1253,6 +1522,30 @@ packages: resolution: {integrity: sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==} engines: {node: '>=22.19.0'} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite@8.3.1: resolution: {integrity: sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1387,6 +1680,9 @@ packages: use-sync-external-store: optional: true + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@asamuzakjp/css-color@7.1.2': @@ -1696,10 +1992,28 @@ snapshots: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + '@types/debug@4.1.13': + dependencies: + '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.9 + '@types/estree@1.0.9': {} + '@types/hast@3.0.5': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/ms@2.1.0': {} + '@types/node@24.19.0': dependencies: undici-types: 7.24.6 @@ -1712,6 +2026,10 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + '@typescript/typescript-aix-ppc64@7.0.2': optional: true @@ -1772,6 +2090,8 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@ungap/structured-clone@1.4.0': {} + '@vitejs/plugin-react@6.1.1(vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0))': dependencies: '@rolldown/pluginutils': 1.0.1 @@ -1788,12 +2108,6 @@ snapshots: '@vitest/spy@5.0.2': {} - '@xterm/addon-fit@0.11.0': {} - - '@xterm/addon-webgl@0.19.0': {} - - '@xterm/xterm@6.0.0': {} - ansi-regex@5.0.1: {} ansi-styles@5.2.0: {} @@ -1804,12 +2118,26 @@ snapshots: assertion-error@2.0.1: {} + bail@2.0.2: {} + bidi-js@1.1.0: dependencies: require-from-string: 2.0.2 + ccount@2.0.1: {} + chai@6.2.2: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + + comma-separated-tokens@2.0.3: {} + css-tree@3.2.1: dependencies: mdn-data: 2.27.1 @@ -1824,12 +2152,24 @@ snapshots: transitivePeerDependencies: - '@noble/hashes' + debug@4.4.3: + dependencies: + ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: + dependencies: + character-entities: 2.0.2 + dequal@2.0.3: {} detect-libc@2.1.2: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + dom-accessibility-api@0.5.16: {} enhanced-resolve@5.25.1: @@ -1841,12 +2181,18 @@ snapshots: es-module-lexer@2.3.2: {} + escape-string-regexp@5.0.0: {} + + estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 expect-type@1.4.0: {} + extend@3.0.2: {} + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -1856,12 +2202,53 @@ snapshots: graceful-fs@4.2.11: {} + hast-util-to-jsx-runtime@2.3.6: + dependencies: + '@types/estree': 1.0.9 + '@types/hast': 3.0.5 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.2.0 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + style-to-js: 1.1.21 + unist-util-position: 5.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.5 + html-encoding-sniffer@7.0.0: dependencies: '@exodus/bytes': 1.16.0 transitivePeerDependencies: - '@noble/hashes' + html-url-attributes@3.0.1: {} + + inline-style-parser@0.2.7: {} + + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + + is-decimal@2.0.1: {} + + is-hexadecimal@2.0.1: {} + + is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} jiti@2.7.0: {} @@ -1991,6 +2378,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.33.0 lightningcss-win32-x64-msvc: 1.33.0 + longest-streak@3.1.0: {} + lru-cache@11.5.3: {} lucide-react@1.48.0(react@19.3.0): @@ -2007,12 +2396,378 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 + markdown-table@3.0.4: {} + + mdast-util-find-and-replace@3.0.2: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + mdast-util-from-markdown@2.0.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.3 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-decode-string: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.2 + micromark-util-character: 2.1.1 + + mdast-util-gfm-footnote@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.3 + micromark-util-normalize-identifier: 2.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.3 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.4 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.3 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.3 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.1.0: + dependencies: + mdast-util-from-markdown: 2.0.3 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.1.0 + mdast-util-gfm-strikethrough: 2.0.1 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.2.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.3 + parse-entities: 4.0.2 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.3 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.3 + mdast-util-to-markdown: 2.1.3 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.1 + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.4.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.3: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-character: 2.1.1 + micromark-util-classify-character: 2.0.1 + micromark-util-decode-string: 2.0.1 + micromark-util-html-tag-name: 2.0.1 + unist-util-visit: 5.1.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + micromark-core-commonmark@2.0.4: + dependencies: + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-factory-destination: 2.0.1 + micromark-factory-label: 2.0.1 + micromark-factory-space: 2.1.0 + micromark-factory-title: 2.0.1 + micromark-factory-whitespace: 2.0.1 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-edit-map: 1.0.0 + micromark-util-html-tag-name: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-classify-character: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-table@2.1.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.2 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-destination@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-label@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-space@2.1.0: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-types: 2.0.3 + + micromark-factory-title@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-factory-whitespace@2.0.1: + dependencies: + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-chunked@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-classify-character@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-combine-extensions@2.0.1: + dependencies: + micromark-util-chunked: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-decode-numeric-character-reference@2.0.2: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-decode-string@2.0.1: + dependencies: + decode-named-character-reference: 1.3.0 + micromark-util-character: 2.1.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-symbol: 2.0.1 + + micromark-util-edit-map@1.0.0: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-encode@2.0.1: {} + + micromark-util-html-tag-name@2.0.1: {} + + micromark-util-normalize-identifier@2.0.1: + dependencies: + micromark-util-symbol: 2.0.1 + + micromark-util-resolve-all@2.0.1: + dependencies: + micromark-util-types: 2.0.3 + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-subtokenize@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.1 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.3: {} + + micromark@4.0.3: + dependencies: + '@types/debug': 4.1.13 + debug: 4.4.3 + decode-named-character-reference: 1.3.0 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.4 + micromark-factory-space: 2.1.0 + micromark-util-character: 2.1.1 + micromark-util-chunked: 2.0.1 + micromark-util-combine-extensions: 2.0.1 + micromark-util-decode-numeric-character-reference: 2.0.2 + micromark-util-edit-map: 1.0.0 + micromark-util-encode: 2.0.1 + micromark-util-normalize-identifier: 2.0.1 + micromark-util-resolve-all: 2.0.1 + micromark-util-sanitize-uri: 2.0.1 + micromark-util-subtokenize: 2.1.0 + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.3 + transitivePeerDependencies: + - supports-color + + ms@2.1.3: {} + nanoid@3.3.19: {} obug@2.2.1: {} + parse-entities@4.0.2: + dependencies: + '@types/unist': 2.0.11 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.3.0 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse5@8.0.1: dependencies: entities: 8.1.0 @@ -2033,6 +2788,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 17.0.2 + property-information@7.2.0: {} + punycode@2.3.1: {} react-dom@19.3.0(react@19.3.0): @@ -2042,8 +2799,60 @@ snapshots: react-is@17.0.2: {} + react-markdown@10.1.0(@types/react@19.3.0)(react@19.3.0): + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + '@types/react': 19.3.0 + devlop: 1.1.0 + hast-util-to-jsx-runtime: 2.3.6 + html-url-attributes: 3.0.1 + mdast-util-to-hast: 13.2.1 + react: 19.3.0 + remark-parse: 11.0.0 + remark-rehype: 11.1.2 + unified: 11.0.5 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + react@19.3.0: {} + remark-gfm@4.0.1: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.1.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.3 + micromark-util-types: 2.0.3 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.2: + dependencies: + '@types/hast': 3.0.5 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.1 + unified: 11.0.5 + vfile: 6.0.3 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.3 + unified: 11.0.5 + require-from-string@2.0.2: {} rolldown@1.2.11: @@ -2075,8 +2884,23 @@ snapshots: source-map-js@1.2.1: {} + space-separated-tokens@2.0.2: {} + std-env@4.2.0: {} + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + style-to-js@1.1.21: + dependencies: + style-to-object: 1.0.14 + + style-to-object@1.0.14: + dependencies: + inline-style-parser: 0.2.7 + tailwindcss@4.3.3: {} tapable@2.3.3: {} @@ -2104,6 +2928,10 @@ snapshots: dependencies: punycode: 2.3.1 + trim-lines@3.0.1: {} + + trough@2.2.0: {} + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -2131,6 +2959,49 @@ snapshots: undici@8.11.2: {} + unified@11.0.5: + dependencies: + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + vite@8.3.1(@types/node@24.19.0)(jiti@2.7.0): dependencies: lightningcss: 1.33.0 @@ -2199,3 +3070,5 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 react: 19.3.0 + + zwitch@2.0.4: {} diff --git a/app/src/App.test.tsx b/app/src/App.test.tsx index d989b281..e8aff173 100644 --- a/app/src/App.test.tsx +++ b/app/src/App.test.tsx @@ -1,15 +1,10 @@ import { act, cleanup, fireEvent, render, screen, within } from "@testing-library/react"; -import { afterEach, describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test } from "vitest"; import { App } from "./App"; import type { Client } from "./lib/client"; import { FakeBotloft } from "./lib/fake"; import { FakeHost } from "./lib/fakeHost"; -// xterm.js needs a real browser; the terminal has its own tests. -vi.mock("./features/terminal/TerminalView", () => ({ - TerminalView: ({ botId }: { botId: string }) =>
, -})); - afterEach(cleanup); function renderApp(fake = new FakeBotloft(), host = new FakeHost()) { @@ -55,9 +50,10 @@ describe("app", () => { expect(within(view).getByText("Starting")).toBeDefined(); const bot = [...fake.bots.values()][0]; act(() => fake.setBotState(bot?.id ?? "", "needs_approval")); - // The badge, and a callout that says what to do. - expect(within(view).getAllByText("Needs approval")).toHaveLength(2); - expect(within(view).getByRole("alert").textContent).toContain("permission prompt"); + // The request itself shows in the chat; the header says why it waits. + expect(within(view).getByText("Needs approval")).toBeDefined(); + act(() => fake.setBotState(bot?.id ?? "", "auth_error")); + expect(within(view).getByRole("alert").textContent).toContain("not signed in"); }); test("the daemon's validation errors stay in the form", async () => { @@ -130,51 +126,31 @@ describe("app", () => { act(() => fake.setConnection({ kind: "waiting", retryAt: 0 })); expect(screen.getByText("Reconnecting…")).toBeDefined(); }); - test("a bot's view shows its terminal, and details in a second tab", async () => { + test("a bot opens on its chat, with its details in a side panel", async () => { const fake = new FakeBotloft(); const crew = fake.addCrew("Ops"); const scout = fake.addBot(crew.id, "Scout", "Finds sources"); renderApp(fake); await screen.findByRole("heading", { level: 1, name: "Ops" }); fireEvent.click(within(sidebar()).getByRole("button", { name: /Scout/ })); - expect(screen.getByTestId(`terminal-${scout.id}`)).toBeDefined(); - fireEvent.click(screen.getByRole("tab", { name: "Details" })); - expect(screen.getByText(scout.workspace)).toBeDefined(); - // The terminal stays mounted behind the other tab. - expect(screen.getByTestId(`terminal-${scout.id}`)).toBeDefined(); - }); - - test("a start that takes long points at the trust prompt", async () => { - const fake = new FakeBotloft(); - const crew = fake.addCrew("Ops"); - const scout = fake.addBot(crew.id, "Scout"); - renderApp(fake); - await screen.findByRole("heading", { level: 1, name: "Ops" }); - fireEvent.click(within(sidebar()).getByRole("button", { name: /Scout/ })); - vi.useFakeTimers(); - try { - act(() => fake.setBotState(scout.id, "launching", 2)); - expect(screen.queryByText("Still starting")).toBeNull(); - act(() => vi.advanceTimersByTime(4000)); - expect(screen.getByText("Still starting")).toBeDefined(); - act(() => fake.setBotState(scout.id, "idle")); - expect(screen.queryByText("Still starting")).toBeNull(); - } finally { - vi.useRealTimers(); - } + expect(await screen.findByText("Start a conversation with Scout")).toBeDefined(); + expect(screen.queryByText(scout.workspace)).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Show details" })); + const details = screen.getByRole("complementary", { name: "About Scout" }); + expect(within(details).getByText(scout.workspace)).toBeDefined(); + fireEvent.click(within(details).getByRole("button", { name: "Close details" })); + expect(screen.queryByRole("complementary", { name: "About Scout" })).toBeNull(); }); - test("a bot that never started says how to start it", async () => { + test("a paused bot still takes messages, and says they wait", async () => { const fake = new FakeBotloft(); const crew = fake.addCrew("Ops"); await fake.call("crews.setPaused", { crewId: crew.id, paused: true }); - const scout = fake.addBot(crew.id, "Scout"); + fake.addBot(crew.id, "Scout"); renderApp(fake); await screen.findByRole("heading", { level: 1, name: "Ops" }); fireEvent.click(within(sidebar()).getByRole("button", { name: /Scout/ })); - expect(screen.getByText(/has not started since the daemon did/).textContent).toContain( - "Resume it to start it.", - ); - expect(screen.queryByTestId(`terminal-${scout.id}`)).toBeNull(); + expect(await screen.findByText(/What you send waits until it runs again/)).toBeDefined(); + expect(screen.getByLabelText("Message to Scout")).toBeDefined(); }); }); diff --git a/app/src/dev/preview.ts b/app/src/dev/preview.ts index ed2469f9..99a897e9 100644 --- a/app/src/dev/preview.ts +++ b/app/src/dev/preview.ts @@ -10,6 +10,7 @@ import { FakeBotloft } from "../lib/fake"; import { FakeHost } from "../lib/fakeHost"; import type { Host } from "../lib/host"; import { PROTOCOL_VERSION } from "../lib/protocol.gen"; +import { seedChats } from "./seedChats"; export function previewProps(search: string): { host: Host; connect: Connect } { const params = new URLSearchParams(search); @@ -59,9 +60,12 @@ function seed(fake: FakeBotloft): void { fake.setBotState(writer.id, "busy", 2); fake.setBotState(reviewer.id, "needs_approval", 5); fake.setBotState(analyst.id, "rate_limited", 1); - fake.terminals.output(scout.id, SCOUT_SCREEN); - fake.terminals.output(reviewer.id, PROMPT_SCREEN); - conversation(fake, scout.id, writer.id, reviewer.id); + seedChats(fake, { + scout: scout.id, + writer: writer.id, + reviewer: reviewer.id, + analyst: analyst.id, + }); const ops = fake.addCrew("Ops"); const deploy = fake.addBot(ops.id, "Deploy", "Ships the site on Fridays"); const watcher = fake.addBot(ops.id, "Watcher", "Keeps an eye on the error log"); @@ -69,83 +73,3 @@ function seed(fake: FakeBotloft): void { fake.setBotState(deploy.id, "offline"); fake.setBotState(watcher.id, "offline"); } - -/** A morning's worth of messages and tasks between three bots. */ -function conversation(fake: FakeBotloft, scout: string, writer: string, reviewer: string): void { - const talk = fake.conversation; - void fake.call("messages.send", { - botId: scout, - body: "Summarize the three newest papers in shared/inbox.", - }); - const owner = [...talk.deliveries.values()].at(-1); - if (owner) { - talk.deliver(owner.id, "sent"); - } - const summary = talk.task(scout, writer, { - status: "done", - result: "Draft in shared/drafts/week-39.md, 2 pages.", - }); - const asked = talk.say({ - from: scout, - to: writer, - kind: "task", - taskId: summary.id, - body: "Turn shared/summaries/week-39.md into this week's report draft.", - }); - talk.deliver(asked.delivery.id, "sent"); - const done = talk.say({ - from: writer, - to: scout, - kind: "result", - taskId: summary.id, - body: "Draft in shared/drafts/week-39.md, 2 pages.", - }); - talk.deliver(done.delivery.id, "sent"); - const review = talk.task(writer, reviewer, { hops: 2 }); - const check = talk.say({ - from: writer, - to: reviewer, - kind: "task", - taskId: review.id, - body: "Check the numbers in section 2 against the sources.", - }); - talk.deliver(check.delivery.id, "sent"); - const late = talk.say({ - from: scout, - to: reviewer, - body: "Two of the links in the draft moved; the new ones are in shared/links.md.", - }); - talk.deliver(late.delivery.id, "dead", "the bot's inbox did not answer in 10 s"); -} - -const ESC = String.fromCharCode(27); -const NEWLINE = String.fromCharCode(13, 10); -const dim = (text: string) => `${ESC}[2m${text}${ESC}[0m`; -const accent = (text: string) => `${ESC}[38;5;209m${text}${ESC}[0m`; -const bold = (text: string) => `${ESC}[1m${text}${ESC}[0m`; - -const SCOUT_SCREEN = [ - `${bold("scout")} ${dim("· research")}`, - "", - `${accent(">")} Summarize the three newest papers in shared/inbox`, - "", - `${accent("●")} Reading shared/inbox/2026-09-27-retrieval.pdf`, - `${accent("●")} Reading shared/inbox/2026-09-26-agents.pdf`, - `${accent("●")} Wrote shared/summaries/week-39.md ${dim("(42 lines)")}`, - "", - "Done. The summary is in shared/summaries/week-39.md.", - "", - `${accent(">")} `, -].join(NEWLINE); - -const PROMPT_SCREEN = [ - `${bold("revisao")} ${dim("· research")}`, - "", - `${accent("●")} Checking the claims in shared/summaries/week-39.md`, - "", - `${ESC}[33mAllow this command?${ESC}[0m npm run check-links`, - " 1. Yes", - " 2. Yes, and don't ask again for this command", - " 3. No", - "", -].join(NEWLINE); diff --git a/app/src/dev/seedChats.ts b/app/src/dev/seedChats.ts new file mode 100644 index 00000000..047ef34e --- /dev/null +++ b/app/src/dev/seedChats.ts @@ -0,0 +1,128 @@ +// Dev only: a morning's work between four research bots, for the preview. + +import type { FakeBotloft } from "../lib/fake"; +import type { BotId } from "../lib/protocol.gen"; + +/** A small bar chart, as a PNG the owner "attached". */ +const CHART_PNG = + "iVBORw0KGgoAAAANSUhEUgAAAGAAAABACAIAAABqVuVZAAAAmElEQVR42u3bQQmAMBiA0RWRtRFb7OxpEQxjFUvYQlgBG4z/MBDZgy/Bu3+ptUedEgJAgAABAvRFZ0nBAAECBAgQIECARpfLFQwQIECAAAECBAgQIECAAAECNAjo3nIwQIAAAQIECBAgQOOAlmMNBggQIECAAAECBAgQIECAAAECBAgQIECAAAGaEajWfdpcz65nQIAA/aoXK4mdiO7oCAwAAAAASUVORK5CYII="; + +const SUMMARY = `Done. The summary is in \`shared/summaries/week-39.md\`. + +**Highlights** + +1. *Retrieval at scale*: hybrid search beats dense-only by 7 points on long documents. +2. *Agents that plan*: writing the plan down first cut tool calls by a third. +3. *Small evals*: fixed test sets drift less than sampled ones. + +| Paper | Pages | Worth a deep read | +|---|---|---| +| Retrieval at scale | 14 | Yes | +| Agents that plan | 22 | Yes | +| Small evals | 9 | Maybe | + +Your chart matches the numbers in the first paper. I asked @writer to turn this into the weekly report.`; + +interface Crew { + scout: BotId; + writer: BotId; + reviewer: BotId; + analyst: BotId; +} + +const MINUTE = 60 * 1000; + +export function seedChats(fake: FakeBotloft, crew: Crew): void { + const { chat, conversation: talk } = fake; + const end = Date.now(); + const at = (minutesAgo: number) => { + fake.now = end - minutesAgo * MINUTE; + }; + + at(95); + void fake.call("messages.send", { + botId: crew.scout, + body: "Summarize the three newest papers in shared/inbox. Here's last week's chart for reference.", + attachments: [{ name: "chart-week-38.png", mediaType: "image/png", data: CHART_PNG }], + }); + const asked = fake.conversation.messages.at(-1); + if (asked) { + talk.read(talk.deliveryOf(asked.id).id); + } + at(94); + const files = [ + ["Glob", "shared/inbox/*.pdf"], + ["Read", "2026-09-27-retrieval.pdf"], + ["Read", "2026-09-26-agents.pdf"], + ["Read", "2026-09-25-evals.pdf"], + ["Write", "week-39.md"], + ] as const; + for (const [name, summary] of files) { + const call = chat.tool(crew.scout, name, { summary, input: JSON.stringify({ path: summary }) }); + chat.finish(call, name === "Write" ? "Wrote 42 lines" : "ok"); + } + at(92); + chat.reply(crew.scout, SUMMARY); + const handoff = chat.tool(crew.scout, "mcp__botloft__send_message", { + summary: "to @writer", + input: JSON.stringify({ to: "writer", kind: "task", body: "Turn week-39.md into the report" }), + }); + chat.finish(handoff, '{"ok":true}'); + + at(91); + const task = talk.task(crew.scout, crew.writer, { hops: 1 }); + const note = talk.say({ + from: crew.scout, + to: crew.writer, + kind: "task", + taskId: task.id, + body: "Turn shared/summaries/week-39.md into this week's report draft.", + }); + talk.read(note.delivery.id); + chat.turn(crew.scout); + at(90); + chat.finish(chat.tool(crew.writer, "Read", { summary: "week-39.md" }), "42 lines"); + chat.reply(crew.writer, "Starting the draft. I'll keep the three highlights as sections."); + chat.tool(crew.writer, "Write", { summary: "week-39-report.md" }); + + at(40); + const review = talk.task(crew.writer, crew.reviewer, { hops: 2 }); + const check = talk.say({ + from: crew.writer, + to: crew.reviewer, + kind: "task", + taskId: review.id, + body: "Check the numbers in section 2 against the sources.", + }); + 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", + }); + chat.tool(crew.reviewer, "Bash", { summary: "Check the links", input: command }); + chat.ask(crew.reviewer, "Bash", "npm run check-links", command); + at(12); + const late = talk.say({ + from: crew.scout, + to: crew.reviewer, + body: "Two of the links in the draft moved; the new ones are in shared/links.md.", + }); + talk.deliver(late.delivery.id, "dead", "the bot did not start in time"); + + at(30); + void fake.call("messages.send", { + botId: crew.analyst, + body: "Can you check the growth numbers in section 2?", + }); + at(29); + chat.finish(chat.tool(crew.analyst, "Read", { summary: "week-39-report.md" }), "118 lines"); + chat.add(crew.analyst, { + kind: "notice", + level: "warning", + text: "The account reached its usage limit. Messages wait until it resets.", + }); + chat.turn(crew.analyst, "rate_limit"); + fake.now = end; +} diff --git a/app/src/features/bots/BotHeader.tsx b/app/src/features/bots/BotHeader.tsx index 3eae0047..63a5410f 100644 --- a/app/src/features/bots/BotHeader.tsx +++ b/app/src/features/bots/BotHeader.tsx @@ -2,6 +2,7 @@ import { Archive, Ellipsis, FolderOpen, + PanelRight, Pause, Pencil, Play, @@ -22,7 +23,17 @@ import { BotStateBadge } from "./BotStateBadge"; type Open = "edit" | "archive" | "fresh" | null; /** The bot's name, state and actions. */ -export function BotHeader({ bot, crew }: { bot: Bot; crew: Crew }) { +export function BotHeader({ + bot, + crew, + detailsOpen, + onToggleDetails, +}: { + bot: Bot; + crew: Crew; + detailsOpen: boolean; + onToggleDetails(): void; +}) { const api = useApi(); const host = useHost(); const putBot = useApp((state) => state.putBot); @@ -40,11 +51,11 @@ export function BotHeader({ bot, crew }: { bot: Bot; crew: Crew }) { ); return ( -
- +
+
-

{bot.name}

+

{bot.name}

@{bot.handle} @@ -66,6 +77,13 @@ export function BotHeader({ bot, crew }: { bot: Bot; crew: Crew }) { +
+
+
+
Role
+
{bot.role || "No role yet."}
+
+
+
Folder
+
+ {bot.workspace} +
+
+
+
Process
+
+ {bot.generation === null ? "not started" : `generation ${bot.generation}`} +
+
+
+
Instructions
+
+ {bot.instructions || "None yet."} +
+
+
+ ); } diff --git a/app/src/features/chat/ApprovalCard.tsx b/app/src/features/chat/ApprovalCard.tsx new file mode 100644 index 00000000..58b93344 --- /dev/null +++ b/app/src/features/chat/ApprovalCard.tsx @@ -0,0 +1,109 @@ +// A permission request (spec 10.1): what the bot wants to do, with Allow +// and Deny. Once answered it shrinks to one line. + +import { Ban, Check, Hand, TimerOff } from "lucide-react"; +import { useId, useState } from "react"; +import type { ApprovalItem, Bot } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { Button } from "../../ui/Button"; +import { attempt } from "../../ui/toast"; +import { pretty, toolLabel } from "./ToolLines"; + +function Answered({ approval }: { approval: ApprovalItem }) { + const label = toolLabel(approval.toolName); + const line = "flex min-w-0 items-center gap-2 px-1.5 py-1 text-sm"; + switch (approval.status) { + case "allowed": + return ( +

+ + You allowed {label} + {approval.summary} +

+ ); + case "denied": + return ( +

+ + You denied {label} + + {approval.note ? `“${approval.note}”` : approval.summary} + +

+ ); + default: + return ( +

+ + {label} was not approved in time +

+ ); + } +} + +export function ApprovalCard({ approval, bot }: { approval: ApprovalItem; bot: Bot }) { + const api = useApi(); + const [note, setNote] = useState(""); + const [busy, setBusy] = useState(false); + const noteId = useId(); + if (approval.status !== "pending") { + return ; + } + const label = toolLabel(approval.toolName); + const answer = async (allow: boolean) => { + setBusy(true); + const trimmed = note.trim(); + await attempt(allow ? "Could not allow it" : "Could not deny it", () => + api.call("approvals.answer", { + approvalId: approval.approvalId, + allow, + ...(allow || !trimmed ? {} : { note: trimmed }), + }), + ); + setBusy(false); + }; + + return ( +
+

+ + {bot.name} wants to use {label} +

+ {approval.summary && ( +

+ {approval.summary} +

+ )} +
+ Full input +
+          {pretty(approval.input)}
+        
+
+ + setNote(event.target.value)} + placeholder="Why not? Sent to the bot if you deny (optional)" + className="mt-3 h-8 w-full rounded-[3px] border border-line-strong bg-canvas px-2.5 text-sm outline-none placeholder:text-muted focus:border-accent" + /> +
+ + +
+
+ ); +} diff --git a/app/src/features/chat/AttachmentList.tsx b/app/src/features/chat/AttachmentList.tsx new file mode 100644 index 00000000..134030c4 --- /dev/null +++ b/app/src/features/chat/AttachmentList.tsx @@ -0,0 +1,151 @@ +// Files sent with a message: images as thumbnails that open larger, the +// rest as cards with name, type and size (spec 15.3). + +import { File, FileImage, FileText, FolderOpen, LoaderCircle } from "lucide-react"; +import { useState } from "react"; +import { fileSize } from "../../lib/format"; +import type { Attachment } from "../../lib/protocol.gen"; +import { useHost } from "../../store/context"; +import { Dialog } from "../../ui/Dialog"; +import { attempt } from "../../ui/toast"; +import { useImage } from "./images"; + +/** The folder an attachment was saved in, inside the bot's workspace. */ +export function attachmentFolder(workspace: string, attachment: Attachment): string { + const parts = attachment.path.split("/").slice(0, -1); + return [workspace, ...parts].join("\\"); +} + +function kindLabel(mediaType: string): string { + const [kind, sub] = mediaType.split("/"); + if (!sub || mediaType === "application/octet-stream") { + return "File"; + } + return (sub.split(/[.+-]/).at(-1) ?? kind ?? "file").toUpperCase(); +} + +function FileCard({ + attachment, + workspace, + note, +}: { + attachment: Attachment; + workspace: string | null; + note?: string; +}) { + const host = useHost(); + const Icon = attachment.mediaType.startsWith("image/") + ? FileImage + : attachment.mediaType.startsWith("text/") || attachment.mediaType.endsWith("pdf") + ? FileText + : File; + return ( +
+ +
+

+ {attachment.name} +

+

+ {note ?? `${kindLabel(attachment.mediaType)} · ${fileSize(attachment.size)}`} +

+
+ {workspace && ( + + )} +
+ ); +} + +function Thumbnail({ + attachment, + workspace, +}: { + attachment: Attachment; + workspace: string | null; +}) { + const source = useImage(attachment); + const [open, setOpen] = useState(false); + if (source.kind === "none") { + return ; + } + if (source.kind === "missing") { + return ( + + ); + } + if (source.kind === "loading") { + return ( +
+ +
+ ); + } + return ( + <> + + {open && ( + setOpen(false)}> + {attachment.name} + + )} + + ); +} + +export function AttachmentList({ + attachments, + workspace, + align = "start", +}: { + attachments: Attachment[]; + /** The bot's folder, to show a file in it; `null` when unknown. */ + workspace: string | null; + align?: "start" | "end"; +}) { + if (attachments.length === 0) { + return null; + } + return ( +
    + {attachments.map((attachment) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/app/src/features/chat/BotRun.tsx b/app/src/features/chat/BotRun.tsx new file mode 100644 index 00000000..63f02cfd --- /dev/null +++ b/app/src/features/chat/BotRun.tsx @@ -0,0 +1,98 @@ +// What the bot did in one turn, under its avatar: replies in markdown, +// tool calls together, approvals, and how the turn ended. The last run +// also shows the reply being written. + +import { CircleAlert } from "lucide-react"; +import { duration, when } from "../../lib/format"; +import type { Bot, ChatItem, TurnItem } from "../../lib/protocol.gen"; +import { BotAvatar } from "../bots/BotAvatar"; +import { ApprovalCard } from "./ApprovalCard"; +import { Markdown } from "./Markdown"; +import { runParts } from "./rows"; +import { ToolLines } from "./ToolLines"; + +export interface Live { + /** Text of the reply being written. */ + draft: string; + /** The bot is in a turn. */ + working: boolean; +} + +function TurnEnd({ turn }: { turn: TurnItem }) { + if (turn.error) { + return ( +

+ + The turn stopped: {turn.error.replaceAll("_", " ")} +

+ ); + } + const cost = turn.costUsd === null ? "" : ` · about $${turn.costUsd.toFixed(2)} of usage`; + return ( +

+ Done in {duration(turn.durationMs)} +

+ ); +} + +function Working() { + return ( +

+ {[0, 150, 300].map((delay) => ( + + ))} +

+ ); +} + +export function BotRun({ + items, + bot, + live, +}: { + items: ChatItem[]; + bot: Bot; + live?: Live | undefined; +}) { + const first = items[0]; + return ( +
  • + +
    +
    + {bot.name} + {first && ( + + )} +
    + {runParts(items).map((part) => { + const head = part[0] as ChatItem; + switch (head.body.kind) { + case "tool": + return ; + case "reply": + return ; + case "approval": + return ; + case "turn": + return ; + default: + return null; + } + })} + {live?.draft && ( +
    + +
    + )} + {live && !live.draft && live.working && } +
    +
  • + ); +} diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx new file mode 100644 index 00000000..22f06958 --- /dev/null +++ b/app/src/features/chat/ChatComposer.tsx @@ -0,0 +1,201 @@ +// Writing to the bot: text, and files picked, pasted or dropped on the +// chat. Enter sends and Shift+Enter starts a new line (spec 15.1). + +import { ArrowUp, File as FileIcon, Paperclip, X } from "lucide-react"; +import { + type ClipboardEvent, + type FormEvent, + type KeyboardEvent, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { errorText } from "../../lib/api"; +import { fileSize } from "../../lib/format"; +import { type Bot, FIELD_LIMITS } from "../../lib/protocol.gen"; +import { useApi } from "../../store/context"; +import { rememberImage } from "./images"; +import type { Files, PendingFile } from "./useFiles"; + +const MAX_HEIGHT_PX = 240; + +function Chip({ file, onRemove }: { file: PendingFile; onRemove(): void }) { + return ( +
  • + {file.preview ? ( + + ) : ( + + )} +
    +

    + {file.name} +

    +

    {fileSize(file.size)}

    +
    + +
  • + ); +} + +export function ChatComposer({ + bot, + files, + stopped, + onSent, +}: { + bot: Bot; + files: Files; + /** The bot is paused or its crew is. */ + stopped: boolean; + onSent(): void; +}) { + const api = useApi(); + const [text, setText] = useState(""); + const [busy, setBusy] = useState(false); + const field = useRef(null); + const picker = useRef(null); + const fieldId = useId(); + const tooLong = text.length > FIELD_LIMITS.message; + const empty = !text.trim() && files.files.length === 0; + + // Grows with the text up to a limit, then scrolls. + useLayoutEffect(() => { + const element = field.current; + if (element) { + element.style.height = "auto"; + if (text) { + element.style.height = `${Math.min(element.scrollHeight, MAX_HEIGHT_PX)}px`; + } + } + }, [text]); + + const send = async (event?: FormEvent) => { + event?.preventDefault(); + if (empty || tooLong || busy) { + return; + } + setBusy(true); + files.setError(null); + const pending = files.files; + try { + const attachments = pending.map(({ name, mediaType, data }) => ({ name, mediaType, data })); + const message = await api.call("messages.send", { + botId: bot.id, + body: text, + ...(attachments.length > 0 ? { attachments } : {}), + }); + // The daemon keeps the order it was given. + message.attachments.forEach((attachment, index) => { + const preview = pending[index]?.preview; + if (preview) { + rememberImage(attachment.id, preview); + } + }); + setText(""); + files.clear(); + onSent(); + } catch (failure) { + files.setError(errorText(failure)); + } finally { + setBusy(false); + field.current?.focus(); + } + }; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { + event.preventDefault(); + void send(); + } + }; + + const onPaste = (event: ClipboardEvent) => { + const pasted = [...event.clipboardData.files]; + if (pasted.length > 0) { + event.preventDefault(); + void files.add(pasted); + } + }; + + return ( +
    + {stopped && ( +

    + {bot.name} is paused. What you send waits until it runs again. +

    + )} +
    + {files.files.length > 0 && ( +
      + {files.files.map((file) => ( + files.remove(file.key)} /> + ))} +
    + )} + +