diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index acf1bb7..c2bd60e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,3 +26,4 @@ jobs: - run: python scripts/check_bundle.py - run: python scripts/check_dashboard_pty.py - run: python scripts/check_workspace_pty.py + - run: python scripts/check_claude_native_pty.py diff --git a/Cargo.lock b/Cargo.lock index a4bff90..5084f02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -32,6 +32,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "atomic" version = "0.6.1" @@ -122,6 +128,12 @@ 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 = "cfg_aliases" version = "0.2.2" @@ -309,6 +321,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "either" version = "1.18.0" @@ -616,7 +634,7 @@ version = "1.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" dependencies = [ - "nix", + "nix 0.29.0", "winapi", ] @@ -647,11 +665,14 @@ version = "0.1.0" dependencies = [ "chrono", "crossterm", + "portable-pty", "ratatui", "regex", "serde", "serde_json", "sha2", + "uuid", + "vt100", ] [[package]] @@ -672,6 +693,18 @@ dependencies = [ "windows-sys", ] +[[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 0.1.1", + "libc", +] + [[package]] name = "nix" version = "0.29.0" @@ -680,7 +713,7 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" dependencies = [ "bitflags 2.13.2", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.2.2", "libc", "memoffset", ] @@ -907,6 +940,27 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" +[[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 0.28.0", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1186,6 +1240,17 @@ dependencies = [ "zmij", ] +[[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", +] + [[package]] name = "sha2" version = "0.10.9" @@ -1197,6 +1262,22 @@ dependencies = [ "digest", ] +[[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 = "signal-hook" version = "0.3.18" @@ -1364,7 +1445,7 @@ dependencies = [ "libc", "log", "memmem", - "nix", + "nix 0.29.0", "num-derive", "num-traits", "ordered-float", @@ -1505,6 +1586,7 @@ dependencies = [ "atomic", "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -1514,6 +1596,27 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vt100" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ff75fb8fa83e609e685106df4faeffdf3a735d3c74ebce97ec557d5d36fd9" +dependencies = [ + "itoa", + "unicode-width", + "vte", +] + +[[package]] +name = "vte" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" +dependencies = [ + "arrayvec", + "memchr", +] + [[package]] name = "vtparse" version = "0.6.2" @@ -1692,6 +1795,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index 40abdf4..5456831 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,3 +14,6 @@ sha2 = "0.10" regex = "1" ratatui = "0.30.2" crossterm = "0.29" +portable-pty = "0.9.0" +vt100 = "0.16.2" +uuid = { version = "1", features = ["serde", "v4"] } diff --git a/README.md b/README.md index f886184..5629503 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,22 @@ cargo run --locked -- workspace --demo --project . --state /tmp/bee-demo-state cargo run --locked -- workspace --demo --project . --once --no-color ``` -Contrato e teclas em [workspace](docs/specs/workspace.md). A direção aprovada usa -os logins dos CLIs oficiais como processos locais, com lançamento real conjunto -de Claude e Codex; integração real e isolamento continuam pendentes -([ADR 0018](docs/decisions/0018-unified-workspace.md)). +O primeiro recorte real abre o **Claude Code original** em um PTY dentro da TUI. +Faça login pelo próprio `claude` antes; a Memory Bee não solicita nem armazena +credenciais. Use um projeto e uma pasta de estado explícitos: + +```sh +cargo run --locked -- workspace --claude --project . --state /tmp/bee-claude-state +cargo run --locked -- workspace --claude --project . --state /tmp/bee-claude-state --resume +``` + +`/exit` encerra o Claude; `Ctrl+G` abre ajuda da Memory Bee e `Ctrl+C` vai ao Claude. +A pasta privada guarda apenas IDs de sessão e códigos de saída, enquanto o Claude +mantém a conversa nativa. Este recorte ainda mostra a interface do Claude dentro +da moldura Bee; memória/exportação integrada, perfis isolados e interface de +conversa própria continuam pendentes. Contrato e teclas em +[workspace](docs/specs/workspace.md). O lançamento da experiência completa com +Claude e Codex segue condicionado aos aceites do [ADR 0018](docs/decisions/0018-unified-workspace.md). O dashboard anterior permanece disponível. ## Desenvolvimento diff --git a/ROADMAP.md b/ROADMAP.md index 1b578da..041bb8e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -34,9 +34,9 @@ por trás, assinaturas existentes e lançamento conjunto. Isso amplia as etapas 1. Protótipo funcional com os dois agentes simulados, menus, streaming, permissões, erro, persistência e exportação: implementado como `workspace --demo`. -2. Prova real por agente/assinatura, incluindo dois perfis isolados: pendente; - hospedar o binário Claude Code original com login do usuário é um caminho - documentado, mas o controle da conversa pela nossa TUI ainda não foi provado. +2. Prova real por agente/assinatura, incluindo dois perfis isolados: em andamento. + `workspace --claude` hospeda o binário Claude Code original em PTY e retoma + seu ID nativo; conversa, permissões e perfis reais ainda exigem ensaio humano. 3. Experiência integrada aos dois harnesses, histórico e exportação: pendente. 4. Troca real entre agentes/contas com revisão e preservação da origem: pendente. 5. Lançamento conjunto após todos os aceites, sem substituir o requisito por @@ -52,6 +52,13 @@ uso da assinatura em um produto de terceiros. A prova deve preservar permissões reais, interrupção, retomada e exportação. Se a interface estruturada Claude não atender a esse contrato, registrar a lacuna antes do lançamento conjunto. +O mantenedor priorizou a implementação nativa **Claude primeiro**. O PTY com +sessão nativa é a primeira entrega desse caminho, disponível como modo experimental; +não altera o aceite do lançamento conjunto da experiência completa. O próximo +recorte deve ligar histórico/exportação revisável e ações à sessão Claude, além +de validar o fluxo real de conversa e permissões. O HTML ainda representa o plano +visual anterior; este Markdown e o ADR 0018 prevalecem para a sequência. + Aceite real: conversa, edição, execução, permitir/negar, interrupção e retomada com cada harness; isolamento de dois perfis; exportar após falha/limite; recuperar histórico sem reenvio; teclado/colagem/resize/sem cor e restauração. Demo e testes diff --git a/docs/EXECUTION.md b/docs/EXECUTION.md index a1fc6db..ab9f7f3 100644 --- a/docs/EXECUTION.md +++ b/docs/EXECUTION.md @@ -5,7 +5,7 @@ ## Situação atual - Existe documentação de produto, workflow e um HTML de planejamento. -- CLI de inspeção, descoberta, seleção e exportação somente contexto implementadas; referência Git opcional implementada; código selecionado v2 implementado; verify/apply explícito implementados; inspeção/exportação Codex por arquivo e descoberta por projeto sob raiz explícita implementadas, experimentais; preparação de retomada Claude/Codex e lançamento confirmado por token implementados, experimentais; dashboard de terminal (ratatui) com lista, detalhe, exportação de contexto/exclusões, aplicação e prompt/lançamento confirmado implementados; workspace unificado disponível apenas com simulação explícita (ADR 0018); cena animada, exportação de código pela TUI e contas reais pendentes. +- CLI de inspeção, descoberta, seleção e exportação somente contexto implementadas; referência Git opcional implementada; código selecionado v2 implementado; verify/apply explícito implementados; inspeção/exportação Codex por arquivo e descoberta por projeto sob raiz explícita implementadas, experimentais; preparação de retomada Claude/Codex e lançamento confirmado por token implementados, experimentais; dashboard de terminal (ratatui) com lista, detalhe, exportação de contexto/exclusões, aplicação e prompt/lançamento confirmado implementados; workspace unificado com demo explícita e primeiro modo Claude local sobre CLI oficial/PTY, experimental (ADR 0018); cena animada, exportação nativa pela TUI e perfis reais pendentes. - Nome decidido: **Memory Bee** (`memory-bee`), com mascote abelha e colmeia dos projetos, conforme o [ADR 0015](decisions/0015-memory-bee-identity.md); crate, binário, pacotes e documentos renomeados para `memory-bee` (registros anteriores mantêm o nome da época). Núcleo em Rust, primeiro alvo macOS arm64 e leitor Claude Code escolhidos. Licença pendente. - Cargo, toolchain Rust 1.98.1 e checks canônicos definidos no README; CI em macOS/Linux. - Remoto atual: [lippdev/memory-bee](https://github.com/lippdev/memory-bee), público. @@ -28,25 +28,19 @@ de patches e consulta ao Git ficam para a etapa 04. ## Próxima tarefa de produto -**Refinamento visual — próxima entrega local.** A orientação mais recente do -mantenedor autoriza a frente visual em paralelo à prova dos harnesses. Referência -HTML e [plano visual](specs/workspace-visual.md) concluídos; próximo recorte: -renderizador Rust com abelha à direita do campo amarelo e estados por ação, -seguido da conexão aos eventos da demo. Não iniciar esse porte na entrega -restrita ao protótipo/plano. Exportação e troca de agente ainda precisam de -animações próprias. - -**TUI unificada — prova de integração com assinaturas.** A orientação de -2026-09-25 substitui a prioridade anterior da cena animada: interface permanente -Memory Bee, assinaturas existentes e lançamento real somente com Claude e Codex. -Primeiro recorte `workspace --demo` implementado; não declarar programação real. -Próximo: provar a hospedagem do CLI Claude original autenticado pelo próprio -usuário e fazer prova controlada de ambos os processos (conversa, edição, -execução, permissões, interrupção, retomada e dois perfis isolados). Protocolo -Codex experimental existe, sem conexão à TUI nem -validação real. Não copiar credenciais, não substituir assinatura por API -paga e não lançar só um provedor. Ver ADR 0018 e contrato `specs/workspace.md`. -Cena, exportação de código pela TUI e worktree na interface continuam pendentes. +**Claude nativo primeiro — próxima entrega de produto.** O mantenedor priorizou +Claude antes de Codex. `workspace --claude` já hospeda a CLI original em PTY, +com sessão nativa, entrada/saída interativa e retomada por ID; é experimental. +Próximo: ensaio humano de conversa, edição, execução, permitir/negar, interrupção +e retomada com assinatura existente; ligar histórico/exportação revisável e ações +da Memory Bee à sessão Claude. O modo atual guarda apenas IDs, não a memória. +Perfis reais, interface de conversa própria e Codex integrado permanecem pendentes. +O lançamento da experiência completa ainda exige os dois agentes (ADR 0018). + +**Refinamento visual.** Referência HTML e [plano visual](specs/workspace-visual.md) +concluídos; renderizador Rust, abelha por ação e animações de exportação/troca +permanecem pendentes. O HTML apresenta o plano visual anterior à prioridade +Claude primeiro; este registro e o roadmap Markdown prevalecem. A renomeação no código está feita (seção "Renomeação — memory-bee"). O GitHub já informa `lippdev/memory-bee`; a URL local antiga redireciona. Não foi feita @@ -637,3 +631,25 @@ escopo da entrega atual nem a próxima tarefa aprovada. Autorrevisão; nenhuma revisão independente ou chamada a modelo nesta revisão. - Próximo: ensaio controlado com os CLIs oficiais em ambiente explícito, cobrindo conversa, eventos, permissões e interrupção antes de integrar à tela. + +## Claude Code local na TUI — primeiro recorte nativo + +- Escopo: `workspace --claude --project --state ` abre o Claude Code + instalado em PTY, usando autenticação e permissões da CLI original. A Memory + Bee encaminha entrada/colagem/resize e desenha a saída; `--resume` reutiliza o + último ID nativo. Estado separado guarda só IDs e códigos de saída, com pasta + privada e trava. Demo e dashboard anteriores continuam disponíveis. +- Evidências: `cargo fmt --all -- --check`, `cargo clippy --locked --all-targets + -- -D warnings`, `cargo test --locked`, `cargo build --locked`, + `python3 scripts/check_claude_native_pty.py`, links relativos e + `git diff --check` passaram localmente. Teste sintético exercitou CLI falsa, + entrada, retomada, privacidade, recusa sem TTY e restauração. Ensaio controlado + iniciou e encerrou o Claude instalado com `/exit`, sem enviar prompt/modelo; + não comprova fluxo de conversa nem permissões. CI macOS/Linux configurado para + repetir o teste sintético. +- Autorrevisão de escopo, armazenamento e fronteira de autenticação; sem revisão + independente. Ensaio manual 51 pendente. Não há ainda exportação integrada, + eventos estruturados, UI de conversa própria, perfis isolados nem Codex real. +- Próximo: executar o roteiro 51 com conversa/permissões reais e vincular a sessão + Claude ao histórico/exportação revisável, mantendo origem e omissões explícitas. + Lançamento completo com ambos os agentes permanece pendente. diff --git a/docs/MANUAL_TESTS.md b/docs/MANUAL_TESTS.md index 31ef354..2ddc5ce 100644 --- a/docs/MANUAL_TESTS.md +++ b/docs/MANUAL_TESTS.md @@ -839,3 +839,33 @@ histórico. Após inatividade, digitar acorda. Conferir movimento desligado, sem cor, tema claro/escuro, larguras 140/100/60/40 e janela estreita. A cena inicial continua disponível. Nenhum teste, arquivo ou agente real é executado pelo HTML. Esse roteiro não valida o renderizador Rust, ainda pendente. + +## 51. Claude Code nativo dentro da Memory Bee (pendente) + +Em um projeto descartável, faça login primeiro pelo Claude Code original e +confirme que ele funciona fora da Memory Bee. Depois rode: + +```sh +cargo build --locked +mkdir -p /tmp/bee-claude-project +target/debug/memory-bee workspace --claude --project /tmp/bee-claude-project --state /tmp/bee-claude-state +target/debug/memory-bee workspace --claude --project /tmp/bee-claude-project --state /tmp/bee-claude-state --resume +``` + +Na primeira sessão, enviar tarefa simples, observar resposta e, só no projeto +descartável, pedir uma edição/execução que solicite permissão. Ensaiar **Negar** e +**Permitir** separadamente. Verificar `Ctrl+C` durante resposta, colagem, +redimensionamento, `Ctrl+G` e `/exit`; conferir restauração do terminal. Na +segunda chamada, verificar que a conversa nativa retorna sem reenviar o prompt. +Conferir `claude-native.json` e permissões 0700/0600, sem registrar conta, tokens +ou conteúdo da conversa no repositório. Estado com outro projeto e chamada sem +TTY devem ser recusados. Anotar versão do Claude, resultado de cada ação e se +houve uso da assinatura já autenticada. + +O teste automatizado sintético, sem login/modelo, é +`python3 scripts/check_claude_native_pty.py`; aprovação nele não conclui este +ensaio. Exportação da memória pelo modo nativo e perfis isolados seguem pendentes. + +| Item novo | Estado | Evidência manual | +|---|---|---| +| 51: conversa, permissões, interrupção e retomada Claude reais | Pendente | — | diff --git a/docs/decisions/0018-unified-workspace.md b/docs/decisions/0018-unified-workspace.md index f13c0f8..1cc1a40 100644 --- a/docs/decisions/0018-unified-workspace.md +++ b/docs/decisions/0018-unified-workspace.md @@ -112,3 +112,20 @@ de tokens externos na Memory Bee. Um terminal incorporado pode ser um transporte ou um escape para interações nativas, mas não substitui sozinho o aceite da conversa unificada e da exportação. Nenhum login ou modelo foi acionado nesta revisão documental. + +## Recorte Claude primeiro — 2026-09-25 + +O mantenedor mudou a ordem de implementação: tornar Claude nativo na TUI antes +de integrar Codex. A experiência completa ainda depende dos dois agentes, mas +o modo Claude pode ser entregue e ensaiado separadamente, identificado como +experimental. Isso substitui a restrição anterior de não disponibilizar um +provedor isoladamente durante o desenvolvimento. + +Escolhemos iniciar o binário Claude Code original em PTY, com o login já feito +na CLI oficial. `--session-id` cria uma referência controlada pela Memory Bee; +`--resume` retoma esse ID no mesmo projeto. A TUI encaminha entrada e desenha +a saída; a pasta privada guarda apenas IDs/códigos, sem token ou transcrição. +O transporte preserva a interface de permissões do próprio Claude e não exige +API paga separada. É um passo concreto para testar assinatura e conversa nativa, +mas não comprova uma UI Bee própria para eventos, exportação ou perfis. Essas +lacunas, o ensaio humano e a integração Codex permanecem no aceite completo. diff --git a/docs/specs/workspace.md b/docs/specs/workspace.md index 20a4fbd..25b711d 100644 --- a/docs/specs/workspace.md +++ b/docs/specs/workspace.md @@ -1,15 +1,39 @@ -# Workspace unificado — demonstração experimental +# Workspace unificado — demonstração e Claude local experimental -Contrato do primeiro recorte do [ADR 0018](../decisions/0018-unified-workspace.md). -Não é programação real. Claude/Codex, ferramentas, mudanças e permissões são -simulados, sem processos de provedores, rede ou edição do projeto. +Contrato dos primeiros recortes do [ADR 0018](../decisions/0018-unified-workspace.md). +`workspace --demo` não é programação real: Claude/Codex, ferramentas, mudanças e +permissões são simulados. `workspace --claude` inicia o Claude Code instalado. Para a versão real, a TUI deverá hospedar os CLIs oficiais como processos locais. O usuário autentica cada um pelo seu próprio fluxo. A Memory Bee não lê nem armazena tokens; associa sessões, eventos e exportações ao processo e ao perfil selecionado. App Server é o transporte candidato para Codex; para Claude, testar o binário original em PTY e verificar se a CLI permite uma conversa própria com -permissões fiéis. O transporte Claude ainda não está decidido nem implementado. +permissões fiéis. O PTY é o primeiro transporte Claude implementado; ainda não +fornece eventos estruturados nem conversa renderizada pela Memory Bee. + +## Claude local experimental + +```sh +memory-bee workspace --claude --project . --state /tmp/bee-claude-state +memory-bee workspace --claude --project . --state /tmp/bee-claude-state --resume +``` + +Exige `claude` no PATH, TTY interativo, projeto existente e pasta de estado +privada (pai existente). Inicia o binário original com `--session-id ` ou +`--resume `, sem copiar tokens. O login, a conversa, as ferramentas e as +decisões de permissão pertencem ao Claude Code. A Memory Bee encaminha teclado, +colagem e resize ao PTY, desenha sua saída em moldura própria e restaura o terminal +ao sair. `Ctrl+G` abre ajuda Bee; `Ctrl+C` vai ao Claude; `/exit` encerra a sessão. + +`claude-native.json` guarda projeto canônico, IDs e códigos de saída (até 128 +sessões, 64 KiB), sem transcrição. A pasta é 0700 e arquivos são 0600 em Unix; +`claude-native.lock` impede segundo escritor. Após crash, verificar que não há +processo ativo antes de remover a trava antiga. `--resume` usa o último ID desta +pasta; não reenvia prompt. A sessão nativa pode não existir quando se sai antes +de enviar uma mensagem. Exportação/revisão de memória, isolamento de perfis, +decisões em UI própria e integração Codex estão pendentes. O modo não comprova +conversa/permissões reais por si só; ver [roteiro manual](../MANUAL_TESTS.md). ```sh cargo run --locked -- workspace --demo --project . --state /tmp/bee-demo-state @@ -20,8 +44,9 @@ O pai de `--state` precisa existir. Pasta nova é criada 0700, arquivos 0600; pasta existente precisa ser privada. `--once` usa memória, não aceita `--state`, não grava nem lê sessões existentes. `--width/--height` (1–500) só com `--once`. `--agent claude|codex` escolhe apenas a primeira sessão de um estado novo. -`--theme dark|light`, `--no-color` e `NO_COLOR` controlam aparência. Flags repetidas, -modo real, projeto ausente e uso interativo sem TTY retornam 64; falha de I/O, 1. +`--theme dark|light`, `--no-color` e `NO_COLOR` controlam aparência. Na demo, +flags repetidas, projeto ausente e uso interativo sem TTY retornam 64; falha de +I/O, 1. Modo sem `--demo` nem `--claude` segue recusado. Projeto é normalizado para caminho absoluto e precisa ser diretório existente. ## Experiência e teclas @@ -92,5 +117,7 @@ pelo usuário continua sendo dado real local, mesmo numa simulação: não versi proveniência, exclusões e telas estreitas. `scripts/check_workspace_pty.py` percorre colagem, decisão negada, exportação/schema/verify, resize, interrupção e restauração em PTY com projeto sintético. O [roteiro manual](../MANUAL_TESTS.md) mantém ensaios -humanos separados. Adaptador Codex experimental é apenas protocolo não conectado; -Claude real, contas, isolamento real, reinício nativo e lançamento conjunto pendentes. +humanos separados. `scripts/check_claude_native_pty.py` usa CLI falsa para testar +entrada, ID, retomada, estado privado e restauração do terminal. Adaptador Codex +experimental é apenas protocolo não conectado; ensaio de conversa Claude real, +contas, isolamento real, exportação nativa e lançamento conjunto pendentes. diff --git a/scripts/check_claude_native_pty.py b/scripts/check_claude_native_pty.py new file mode 100644 index 0000000..15670bc --- /dev/null +++ b/scripts/check_claude_native_pty.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Exercise Memory Bee's Claude terminal with a fake CLI; no model calls.""" +import fcntl +import json +import os +from pathlib import Path +import pty +import select +import struct +import subprocess +import tempfile +import termios +import time + + +def one_run(binary, project, state, env, resume=False): + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 20, 80, 0, 0)) + before = termios.tcgetattr(slave) + argv = [str(binary), 'workspace', '--claude', '--project', str(project), '--state', str(state), '--no-color'] + if resume: + argv.append('--resume') + proc = subprocess.Popen(argv, stdin=slave, stdout=slave, stderr=slave, env=env) + output = bytearray() + + def drain_until(token, seconds=5): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if select.select([master], [], [], 0.05)[0]: + chunk = os.read(master, 65536) + output.extend(chunk) + if b'\x1b[6n' in chunk: + os.write(master, b'\x1b[1;1R') + if token in output: + return + if proc.poll() is not None: + break + raise AssertionError(f'did not see synthetic {token!r}; exit={proc.poll()}') + + try: + drain_until(b'READY') + assert (state / 'claude-native.json').exists() + os.write(master, b'hello\r') + drain_until(b'GOT:') + proc.wait(timeout=5) + assert proc.returncode == 0 + assert termios.tcgetattr(slave) == before, 'terminal state not restored' + assert not (state / 'claude-native.lock').exists() + return json.loads((state / 'claude-native.json').read_text()) + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + os.close(master) + os.close(slave) + + +def main(): + repo = Path(__file__).resolve().parent.parent + binary = repo / 'target/debug/memory-bee' + with tempfile.TemporaryDirectory(prefix='memory-bee-claude-native-') as tmp: + root = Path(tmp) + fake_dir = root / 'bin' + fake_dir.mkdir() + fake = fake_dir / 'claude' + fake.write_text('#!/bin/sh\nprintf "%s|%s|%s\\n" "$1" "$2" "$PWD" >> "$BEE_FAKE_ARGS"\nprintf "FAKE CLAUDE READY\\r\\n"\nIFS= read -r line\nprintf "GOT:%s\\r\\n" "$line"\n') + fake.chmod(0o700) + project = root / 'project' + project.mkdir() + state = root / 'state' + args_file = root / 'claude-args' + env = dict(os.environ, PATH=f'{fake_dir}:/usr/bin:/bin', TERM='xterm-256color', BEE_FAKE_ARGS=str(args_file)) + first = one_run(binary, project, state, env) + assert first['version'] == 1 + assert len(first['sessions']) == 1 + session_id = first['sessions'][0]['id'] + assert first['sessions'][0]['exit_code'] == 0 + second = one_run(binary, project, state, env, resume=True) + assert len(second['sessions']) == 1 + assert second['sessions'][0]['id'] == session_id + assert args_file.read_text().splitlines() == [ + f'--session-id|{session_id}|{project.resolve()}', + f'--resume|{session_id}|{project.resolve()}', + ], 'Claude must receive the native session ID in the selected project' + assert list(project.iterdir()) == [], 'fake CLI modified the project' + denied_state = root / 'denied' + result = subprocess.run([str(binary), 'workspace', '--claude', '--project', str(project), '--state', str(denied_state)], capture_output=True) + assert result.returncode == 64 and not denied_state.exists(), 'non-TTY launch should refuse before writing' + print('PASS: native Claude PTY, input, session ID, resume, private state and terminal restoration (fake CLI)') + + +if __name__ == '__main__': + main() diff --git a/src/main.rs b/src/main.rs index f185297..bb32e5c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -18,7 +18,7 @@ use std::{ process::ExitCode, }; -const USAGE: &str = "Usage:\n memory-bee workspace --demo --project (--state | --once [--width ] [--height ]) [--agent claude|codex] [--theme dark|light] [--no-color]\n memory-bee export-codex (--preview | --output ) [--exclude-line ]... [--project ] [--include-path ]...\n memory-bee inspect-codex \n memory-bee inspect [--leaf ]\n memory-bee sessions-codex --root --project \n memory-bee sessions --root --project \n memory-bee export (--preview | --output ) [--leaf ] [--exclude-line ]... [--project ] [--include-path ]...\n memory-bee verify \n memory-bee apply --project (--check | --write)\n memory-bee prepare-resume --target (claude | codex) --project [--worktree ] (--preview | --output [--launch ])\n memory-bee dashboard --project [--claude-root ] [--codex-root ] [--bundle ] [--theme (dark|light)] [--no-color] [--once [--width ] [--height ]]\nOffline; no model calls.\nCLI launch requires --launch and a matching confirmation from --preview.\nDashboard actions require a preview and explicit confirmation.\nExit: 0 success, 2 partial or attention needed, 3 possible secrets, 4 launched agent exited non-zero, 1 I/O or selection error, 64 usage error."; +const USAGE: &str = "Usage:\n memory-bee workspace --demo --project (--state | --once [--width ] [--height ]) [--agent claude|codex] [--theme dark|light] [--no-color]\n memory-bee workspace --claude --project --state [--resume] [--theme dark|light] [--no-color]\n memory-bee export-codex (--preview | --output ) [--exclude-line ]... [--project ] [--include-path ]...\n memory-bee inspect-codex \n memory-bee inspect [--leaf ]\n memory-bee sessions-codex --root --project \n memory-bee sessions --root --project \n memory-bee export (--preview | --output ) [--leaf ] [--exclude-line ]... [--project ] [--include-path ]...\n memory-bee verify \n memory-bee apply --project (--check | --write)\n memory-bee prepare-resume --target (claude | codex) --project [--worktree ] (--preview | --output [--launch ])\n memory-bee dashboard --project [--claude-root ] [--codex-root ] [--bundle ] [--theme (dark|light)] [--no-color] [--once [--width ] [--height ]]\nInspection and export are offline. workspace --claude starts the installed Claude Code CLI.\nCLI launch requires --launch and a matching confirmation from --preview.\nDashboard actions require a preview and explicit confirmation.\nExit: 0 success, 2 partial or attention needed, 3 possible secrets, 4 launched agent exited non-zero, 1 I/O or selection error, 64 usage error."; fn output(value: &impl Serialize) -> Result<(), (u8, String)> { let stdout = io::stdout(); let mut out = stdout.lock(); @@ -528,6 +528,8 @@ fn workspace(args: &[std::ffi::OsString]) -> Result { let mut project: Option = None; let mut state: Option = None; let mut demo = false; + let mut claude_live = false; + let mut resume = false; let mut once = false; let mut agent = Agent::Claude; let mut mode = tui::theme::Mode::Dark; @@ -542,6 +544,8 @@ fn workspace(args: &[std::ffi::OsString]) -> Result { } match flag { "--demo" => demo = true, + "--claude" => claude_live = true, + "--resume" => resume = true, "--once" => once = true, "--no-color" => no_color = true, "--project" | "--state" | "--agent" | "--theme" | "--width" | "--height" => { @@ -580,7 +584,10 @@ fn workspace(args: &[std::ffi::OsString]) -> Result { } i += 1; } - if !demo { + if (demo && claude_live) || (resume && !claude_live) { + return Err((64, USAGE.into())); + } + if !demo && !claude_live { memory_bee::workspace::live_gate().map_err(|e| (64, e))?; } let project = project @@ -597,6 +604,24 @@ fn workspace(args: &[std::ffi::OsString]) -> Result { if once && state.is_some() || !once && (seen.contains("--width") || seen.contains("--height")) { return Err((64, USAGE.into())); } + if claude_live { + if once || seen.contains("--agent") { + return Err((64, USAGE.into())); + } + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Err(( + 64, + "workspace --claude precisa de terminal interativo".into(), + )); + } + let state_path = + state.ok_or((64, "workspace --claude exige --state ".into()))?; + let (id, code) = + tui::claude_native::run(Path::new(&project), &state_path, resume, mode, no_color) + .map_err(|e| (1, e))?; + println!("Claude Code encerrou. Sessão nativa: {id}"); + return Ok(if code == 0 { 0 } else { 4 }); + } if once { let state = State::new(project, agent); let mut app = tui::workspace::App::new(state, None, mode, no_color); diff --git a/src/tui/claude_native.rs b/src/tui/claude_native.rs new file mode 100644 index 0000000..0b9d3ed --- /dev/null +++ b/src/tui/claude_native.rs @@ -0,0 +1,532 @@ +//! Memory Bee frame around the unmodified, interactive Claude Code CLI. +use super::theme::{Mode, Palette}; +use crossterm::{ + cursor::Show, + event::{ + self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind, + KeyModifiers, + }, + terminal::{self, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use memory_bee::workspace::claude_native::ClaudeStore; +use portable_pty::{CommandBuilder, MasterPty, NativePtySystem, PtySize, PtySystem}; +use ratatui::{ + Frame, Terminal, + backend::CrosstermBackend, + layout::{Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Paragraph}, +}; +use std::{ + ffi::OsStr, + io::{self, Read, Write}, + path::Path, + sync::mpsc::{self, Receiver}, + thread, + time::{Duration, Instant}, +}; +use uuid::Uuid; + +enum Output { + Bytes(Vec), + Closed, +} + +struct Pty { + master: Box, + child: Box, + writer: Box, + output: Receiver, + exited: bool, +} +impl Pty { + fn start( + executable: &OsStr, + project: &Path, + id: Uuid, + resume: bool, + size: PtySize, + no_color: bool, + ) -> Result { + let pair = NativePtySystem::default() + .openpty(size) + .map_err(|e| e.to_string())?; + let mut command = CommandBuilder::new(executable); + command.cwd(project.as_os_str()); + command.env("TERM", "xterm-256color"); + if resume { + command.arg("--resume"); + } else { + command.arg("--session-id"); + } + command.arg(id.to_string()); + if no_color { + command.env("NO_COLOR", "1"); + } + let mut child = pair + .slave + .spawn_command(command) + .map_err(|e| e.to_string())?; + drop(pair.slave); + let mut reader = match pair.master.try_clone_reader() { + Ok(reader) => reader, + Err(e) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(e.to_string()); + } + }; + let writer = match pair.master.take_writer() { + Ok(writer) => writer, + Err(e) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(e.to_string()); + } + }; + let (tx, output) = mpsc::sync_channel(64); + thread::spawn(move || { + let mut bytes = [0u8; 8192]; + loop { + match reader.read(&mut bytes) { + Ok(0) => break, + Ok(n) => { + if tx.send(Output::Bytes(bytes[..n].to_vec())).is_err() { + break; + } + } + Err(_) => break, + } + } + let _ = tx.send(Output::Closed); + }); + Ok(Self { + master: pair.master, + child, + writer, + output, + exited: false, + }) + } + fn write(&mut self, bytes: &[u8]) -> Result<(), String> { + self.writer.write_all(bytes).map_err(|e| e.to_string()) + } + fn resize(&mut self, rows: u16, cols: u16) -> Result<(), String> { + self.master + .resize(pty_size(rows, cols)) + .map_err(|e| e.to_string()) + } + fn try_wait(&mut self) -> Result, String> { + let status = self.child.try_wait().map_err(|e| e.to_string())?; + if status.is_some() { + self.exited = true; + } + Ok(status) + } +} +impl Drop for Pty { + fn drop(&mut self) { + if !self.exited { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +struct TerminalGuard; +impl TerminalGuard { + fn enter() -> Result { + terminal::enable_raw_mode().map_err(|e| e.to_string())?; + let guard = Self; + crossterm::execute!(io::stdout(), EnterAlternateScreen, EnableBracketedPaste) + .map_err(|e| e.to_string())?; + Ok(guard) + } +} +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = terminal::disable_raw_mode(); + let _ = crossterm::execute!( + io::stdout(), + DisableBracketedPaste, + LeaveAlternateScreen, + Show + ); + } +} + +fn viewport(size: Rect) -> (u16, u16) { + ( + size.height.saturating_sub(4).max(1), + size.width.saturating_sub(2).max(1), + ) +} +fn pty_size(rows: u16, cols: u16) -> PtySize { + PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + } +} +fn color(value: vt100::Color) -> Option { + match value { + vt100::Color::Default => None, + vt100::Color::Idx(index) => Some(Color::Indexed(index)), + vt100::Color::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)), + } +} +fn paint_screen(frame: &mut Frame, area: Rect, screen: &vt100::Screen, no_color: bool) { + let buffer = frame.buffer_mut(); + for row in 0..area.height { + for col in 0..area.width { + let Some(cell) = screen.cell(row, col) else { + continue; + }; + if cell.is_wide_continuation() { + continue; + } + let mut style = Style::default(); + if !no_color { + if let Some(fg) = color(cell.fgcolor()) { + style = style.fg(fg); + } + if let Some(bg) = color(cell.bgcolor()) { + style = style.bg(bg); + } + } + if cell.bold() { + style = style.add_modifier(Modifier::BOLD); + } + if cell.dim() { + style = style.add_modifier(Modifier::DIM); + } + if cell.italic() { + style = style.add_modifier(Modifier::ITALIC); + } + if cell.underline() { + style = style.add_modifier(Modifier::UNDERLINED); + } + if cell.inverse() { + style = style.add_modifier(Modifier::REVERSED); + } + let symbol = if cell.has_contents() { + cell.contents() + } else { + " " + }; + buffer.set_stringn( + area.x + col, + area.y + row, + symbol, + (area.width - col) as usize, + style, + ); + } + } +} +fn draw( + frame: &mut Frame, + screen: &vt100::Screen, + id: Uuid, + help: bool, + no_color: bool, + mode: Mode, +) { + let area = frame.area(); + if area.width < 30 || area.height < 8 { + frame.render_widget( + Paragraph::new("Memory Bee · aumente o terminal para 30×8"), + area, + ); + return; + } + let p = Palette::new(mode, no_color); + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(1), + ]) + .split(area); + frame.render_widget( + Paragraph::new(format!("⬢ Memory Bee · Claude real · {}", id)) + .style(Style::default().fg(p.accent).add_modifier(Modifier::BOLD)), + rows[0], + ); + let block = Block::default() + .title(" Claude Code original ") + .borders(Borders::ALL) + .border_style(Style::default().fg(p.accent)); + let inner = block.inner(rows[1]); + frame.render_widget(block, rows[1]); + paint_screen(frame, inner, screen, no_color); + if help { + let popup = Rect { + x: area.x + 2, + y: area.y + 2, + width: area.width.saturating_sub(4), + height: 5.min(area.height.saturating_sub(3)), + }; + frame.render_widget(ratatui::widgets::Clear, popup); + frame.render_widget(Paragraph::new("Claude usa o login e as permissões do próprio CLI.\nCtrl+G ou Esc fecha esta ajuda. Ctrl+C vai para Claude.\nPara sair da sessão, use /exit no Claude.") + .block(Block::default().title(" Memory Bee ").borders(Borders::ALL).border_style(Style::default().fg(p.accent))), popup); + } else { + let (cursor_row, cursor_col) = screen.cursor_position(); + if cursor_row < inner.height && cursor_col < inner.width { + frame.set_cursor_position((inner.x + cursor_col, inner.y + cursor_row)); + } + } + frame.render_widget( + Paragraph::new("Ctrl+G ajuda · /exit encerra Claude · sessão nativa preservada"), + rows[2], + ); +} + +fn key_bytes(key: KeyEvent, application_cursor: bool) -> Option> { + let bytes: Vec = match key.code { + KeyCode::Char(c) + if key.modifiers.contains(KeyModifiers::CONTROL) && c.is_ascii_alphabetic() => + { + vec![(c.to_ascii_uppercase() as u8) & 0x1f] + } + KeyCode::Char(c) => c.to_string().into_bytes(), + KeyCode::Enter => b"\r".to_vec(), + KeyCode::Backspace => vec![0x7f], + KeyCode::Tab => b"\t".to_vec(), + KeyCode::BackTab => b"\x1b[Z".to_vec(), + KeyCode::Esc => b"\x1b".to_vec(), + KeyCode::Up => if application_cursor { + b"\x1bOA" + } else { + b"\x1b[A" + } + .to_vec(), + KeyCode::Down => if application_cursor { + b"\x1bOB" + } else { + b"\x1b[B" + } + .to_vec(), + KeyCode::Right => if application_cursor { + b"\x1bOC" + } else { + b"\x1b[C" + } + .to_vec(), + KeyCode::Left => if application_cursor { + b"\x1bOD" + } else { + b"\x1b[D" + } + .to_vec(), + KeyCode::Home => b"\x1b[H".to_vec(), + KeyCode::End => b"\x1b[F".to_vec(), + KeyCode::PageUp => b"\x1b[5~".to_vec(), + KeyCode::PageDown => b"\x1b[6~".to_vec(), + KeyCode::Delete => b"\x1b[3~".to_vec(), + KeyCode::Insert => b"\x1b[2~".to_vec(), + KeyCode::F(n) if (1..=4).contains(&n) => { + format!("\x1bO{}", ["P", "Q", "R", "S"][(n - 1) as usize]).into_bytes() + } + KeyCode::F(n) if (5..=12).contains(&n) => format!( + "\x1b[{}~", + [15, 17, 18, 19, 20, 21, 23, 24][(n - 5) as usize] + ) + .into_bytes(), + _ => return None, + }; + if key.modifiers.contains(KeyModifiers::ALT) { + let mut result = vec![0x1b]; + result.extend(bytes); + Some(result) + } else { + Some(bytes) + } +} + +pub fn run( + project: &Path, + root: &Path, + resume: bool, + mode: Mode, + no_color: bool, +) -> Result<(Uuid, u32), String> { + let (store, mut state) = ClaudeStore::open(root, project)?; + let id = if resume { + state + .latest() + .ok_or("Nenhuma sessão Claude anterior nesta pasta")? + } else { + Uuid::new_v4() + }; + let _guard = TerminalGuard::enter()?; + let mut terminal = + Terminal::new(CrosstermBackend::new(io::stdout())).map_err(|e| e.to_string())?; + let size = terminal.size().map_err(|e| e.to_string())?; + if size.width < 30 || size.height < 8 { + return Err("Terminal Claude exige pelo menos 30×8".into()); + } + let (rows, cols) = viewport(size.into()); + let mut parser = vt100::Parser::new(rows, cols, 0); + let mut pty = Pty::start( + OsStr::new("claude"), + project, + id, + resume, + pty_size(rows, cols), + no_color, + )?; + if !resume { + store.start(&mut state, id)?; + } + let mut help = false; + let mut dirty = true; + let mut closed = false; + let mut status = None; + let mut exited_at = None; + let mut query_tail = Vec::new(); + loop { + while let Ok(message) = pty.output.try_recv() { + match message { + Output::Bytes(bytes) => { + parser.process(&bytes); + let old_len = query_tail.len(); + query_tail.extend_from_slice(&bytes); + for (pattern, reply) in [ + (b"\x1b[6n".as_slice(), None), + (b"\x1b[5n".as_slice(), Some(b"\x1b[0n".as_slice())), + ] { + for pos in 0..query_tail.len().saturating_sub(pattern.len() - 1) { + if pos + pattern.len() > old_len + && query_tail[pos..].starts_with(pattern) + { + if let Some(reply) = reply { + pty.write(reply)?; + } else { + let (row, col) = parser.screen().cursor_position(); + pty.write(format!("\x1b[{};{}R", row + 1, col + 1).as_bytes())?; + } + } + } + } + let keep = query_tail.len().min(3); + query_tail.drain(..query_tail.len() - keep); + dirty = true; + } + Output::Closed => closed = true, + } + } + if dirty { + terminal + .draw(|f| draw(f, parser.screen(), id, help, no_color, mode)) + .map_err(|e| e.to_string())?; + dirty = false; + } + if status.is_none() + && let Some(exit) = pty.try_wait()? + { + exited_at = Some(Instant::now()); + status = Some(exit.exit_code()); + } + if status.is_some() + && (closed || exited_at.is_some_and(|t| t.elapsed() > Duration::from_secs(1))) + { + break; + } + if event::poll(Duration::from_millis(40)).map_err(|e| e.to_string())? { + match event::read().map_err(|e| e.to_string())? { + Event::Key(key) + if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => + { + if key.code == KeyCode::Char('g') + && key.modifiers.contains(KeyModifiers::CONTROL) + { + help = !help; + dirty = true; + } else if help { + if key.code == KeyCode::Esc { + help = false; + dirty = true; + } + } else if let Some(bytes) = key_bytes(key, parser.screen().application_cursor()) + { + pty.write(&bytes)?; + } + } + Event::Paste(text) if !help => { + if parser.screen().bracketed_paste() { + pty.write(b"\x1b[200~")?; + } + pty.write(text.as_bytes())?; + if parser.screen().bracketed_paste() { + pty.write(b"\x1b[201~")?; + } + } + Event::Resize(width, height) => { + let (rows, cols) = viewport(Rect::new(0, 0, width, height)); + pty.resize(rows, cols)?; + parser.screen_mut().set_size(rows, cols); + terminal.clear().map_err(|e| e.to_string())?; + dirty = true; + } + _ => {} + } + } + } + let code = status.unwrap_or(1); + store.finish(&mut state, id, code)?; + Ok((id, code)) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn keys_reach_native_terminal_without_bee_shortcuts() { + use crossterm::event::KeyEvent; + assert_eq!( + key_bytes( + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + false + ), + Some(vec![3]) + ); + assert_eq!( + key_bytes(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), false), + Some(vec![13]) + ); + assert_eq!( + key_bytes(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), false), + Some(b"\x1b[A".to_vec()) + ); + } + #[cfg(unix)] + #[test] + fn fake_cli_runs_in_pty_and_echoes_input() { + use std::{fs, os::unix::fs::PermissionsExt}; + let root = std::env::temp_dir().join(format!("bee-pty-{}", Uuid::new_v4())); + fs::create_dir(&root).unwrap(); + let fake = root.join("claude-fake"); + fs::write(&fake, "#!/bin/sh\nprintf 'READY\\r\\n'\nIFS= read -r line\nprintf 'ECHO:%s\\r\\n' \"$line\"\n").unwrap(); + fs::set_permissions(&fake, fs::Permissions::from_mode(0o700)).unwrap(); + let id = Uuid::new_v4(); + let mut pty = + Pty::start(fake.as_os_str(), &root, id, false, pty_size(10, 40), true).unwrap(); + pty.write(b"hello\r").unwrap(); + let mut parser = vt100::Parser::new(10, 40, 0); + let until = Instant::now() + Duration::from_secs(3); + while Instant::now() < until { + if let Ok(Output::Bytes(bytes)) = pty.output.recv_timeout(Duration::from_millis(50)) { + parser.process(&bytes); + } + if parser.screen().contents().contains("ECHO:hello") { + break; + } + } + assert!(parser.screen().contents().contains("READY")); + assert!(parser.screen().contents().contains("ECHO:hello")); + assert_eq!(pty.child.wait().unwrap().exit_code(), 0); + pty.exited = true; + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 2adf9ea..843996e 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,6 +1,7 @@ //! Terminal dashboard using the CLI core with explicit action previews. pub mod actions; pub mod app; +pub mod claude_native; pub mod theme; pub mod ui; diff --git a/src/workspace/claude_native.rs b/src/workspace/claude_native.rs new file mode 100644 index 0000000..95c9289 --- /dev/null +++ b/src/workspace/claude_native.rs @@ -0,0 +1,195 @@ +//! Local references to Claude Code sessions started by Memory Bee. +//! Claude Code keeps the conversation and credentials; this file keeps only IDs. +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeSet, + fs::{self, File, OpenOptions}, + io::{Read, Write}, + path::{Path, PathBuf}, +}; +use uuid::Uuid; + +const MAX_BYTES: u64 = 64 * 1024; +const MAX_SESSIONS: usize = 128; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClaudeSession { + pub id: Uuid, + pub exit_code: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClaudeState { + pub version: u32, + pub project: String, + pub sessions: Vec, +} + +impl ClaudeState { + fn validate(&self, project: &str) -> Result<(), String> { + if self.version != 1 || self.project != project || self.sessions.len() > MAX_SESSIONS { + return Err("Estado Claude incompatível ou pertencente a outro projeto".into()); + } + let mut ids = BTreeSet::new(); + if self.sessions.iter().any(|s| !ids.insert(s.id)) { + return Err("IDs Claude repetidos no estado".into()); + } + Ok(()) + } + pub fn latest(&self) -> Option { + self.sessions.last().map(|s| s.id) + } +} + +pub struct ClaudeStore { + root: PathBuf, +} + +fn private_new(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path).map_err(|e| e.to_string()) +} + +impl ClaudeStore { + pub fn open(root: &Path, project: &Path) -> Result<(Self, ClaudeState), String> { + if !root.exists() { + let mut builder = fs::DirBuilder::new(); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(root).map_err(|e| e.to_string())?; + } + let metadata = fs::symlink_metadata(root).map_err(|e| e.to_string())?; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Err("Estado exige pasta real, sem symlink".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if metadata.permissions().mode() & 0o077 != 0 { + return Err("Pasta de estado deve ter permissão 0700".into()); + } + } + let root = root.canonicalize().map_err(|e| e.to_string())?; + let project = project.to_str().ok_or("Projeto deve ser UTF-8")?; + let store = Self { root }; + let mut lock = private_new(&store.root.join("claude-native.lock")) + .map_err(|_| "Estado Claude em uso ou trava antiga presente. Confirme que não há processo ativo antes de remover claude-native.lock.")?; + writeln!(lock, "{}", std::process::id()).map_err(|e| e.to_string())?; + let path = store.root.join("claude-native.json"); + let state = match fs::symlink_metadata(&path) { + Ok(meta) => { + if !meta.is_file() || meta.file_type().is_symlink() || meta.len() > MAX_BYTES { + return Err("Arquivo de estado Claude inválido".into()); + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if meta.permissions().mode() & 0o077 != 0 { + return Err("Arquivo de estado Claude deve ser privado (0600)".into()); + } + } + let mut bytes = Vec::new(); + File::open(&path) + .map_err(|e| e.to_string())? + .take(MAX_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() as u64 > MAX_BYTES { + return Err("Arquivo de estado Claude excede 64 KiB".into()); + } + serde_json::from_slice::(&bytes) + .map_err(|e| format!("Estado Claude inválido; original preservado: {e}"))? + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => ClaudeState { + version: 1, + project: project.into(), + sessions: Vec::new(), + }, + Err(e) => return Err(e.to_string()), + }; + state.validate(project)?; + Ok((store, state)) + } + pub fn start(&self, state: &mut ClaudeState, id: Uuid) -> Result<(), String> { + if state.sessions.len() >= MAX_SESSIONS { + return Err("Limite de 128 sessões Claude nesta pasta".into()); + } + let mut next = state.clone(); + next.sessions.push(ClaudeSession { + id, + exit_code: None, + }); + self.save(&next)?; + *state = next; + Ok(()) + } + pub fn finish(&self, state: &mut ClaudeState, id: Uuid, code: u32) -> Result<(), String> { + let mut next = state.clone(); + let session = next + .sessions + .iter_mut() + .find(|s| s.id == id) + .ok_or("Sessão Claude não encontrada no estado")?; + session.exit_code = Some(code); + self.save(&next)?; + *state = next; + Ok(()) + } + pub fn save(&self, state: &ClaudeState) -> Result<(), String> { + state.validate(&state.project)?; + let bytes = serde_json::to_vec(state).map_err(|e| e.to_string())?; + if bytes.len() as u64 > MAX_BYTES { + return Err("Estado Claude excede 64 KiB".into()); + } + let temp = self.root.join("claude-native.new"); + let mut file = + private_new(&temp).map_err(|_| "claude-native.new já existe; original preservado")?; + let result = file + .write_all(&bytes) + .and_then(|()| file.sync_all()) + .and_then(|()| fs::rename(&temp, self.root.join("claude-native.json"))); + if let Err(e) = result { + let _ = fs::remove_file(&temp); + return Err(e.to_string()); + } + Ok(()) + } +} +impl Drop for ClaudeStore { + fn drop(&mut self) { + let _ = fs::remove_file(self.root.join("claude-native.lock")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn session_references_are_private_scoped_and_resumable() { + let root = std::env::temp_dir().join(format!("bee-claude-{}", Uuid::new_v4())); + let project = std::env::current_dir().unwrap().canonicalize().unwrap(); + let (store, mut state) = ClaudeStore::open(&root, &project).unwrap(); + let id = Uuid::new_v4(); + store.start(&mut state, id).unwrap(); + assert_eq!(state.latest(), Some(id)); + assert!(ClaudeStore::open(&root, &project).is_err()); + store.finish(&mut state, id, 7).unwrap(); + drop(store); + let (store, loaded) = ClaudeStore::open(&root, &project).unwrap(); + assert_eq!(loaded.sessions[0].exit_code, Some(7)); + assert!(ClaudeStore::open(&root, Path::new("/different-project")).is_err()); + drop(store); + fs::remove_dir_all(root).unwrap(); + } +} diff --git a/src/workspace/mod.rs b/src/workspace/mod.rs index 337c1e4..5f33702 100644 --- a/src/workspace/mod.rs +++ b/src/workspace/mod.rs @@ -1,5 +1,6 @@ //! Unified workspace contracts. Demo and native providers are never conflated. pub mod adapter; +pub mod claude_native; pub mod codex; pub mod store;