From b6d68dd82349b69ec99d3f98d8595d736cb23459 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:38:01 +0000 Subject: [PATCH 1/7] feat: surface hookless native Claude prompts in hidden Bee workspace (#29) The hidden Claude PTY now feeds a private vt100 screen model. Without a SessionStart hook in 5 s the Bee names the likely native screen (trust, login, initial setup) without copying its text, and Ctrl+O explicitly opens the original Claude to finish setup. Turns without hooks for 20 s get a single hint. Ctrl+Q no longer types /exit into a possible native dialog: before SessionStart it ends the process, mid-turn it interrupts and exits after Stop. Refs #29 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MUyLMVv9GbqWRByEZ7LgEb --- ROADMAP.md | 7 +- docs/EXECUTION.md | 44 +++- docs/MANUAL_TESTS.md | 14 +- docs/specs/workspace.md | 29 ++- scripts/check_claude_hidden_pty.py | 94 +++++++- src/tui/claude_native.rs | 352 ++++++++++++++++++++++++----- 6 files changed, 473 insertions(+), 67 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1267ff2..0467b92 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -37,7 +37,8 @@ por trás, assinaturas existentes e lançamento conjunto. Isso amplia as etapas 2. Prova real por agente/assinatura, incluindo dois perfis isolados: em andamento. `workspace --claude` hospeda o binário Claude Code original em PTY oculto, usa hooks para conversa e permissões na Bee e retoma o ID nativo. Mensagem real - curta comprovada; ferramentas/perfis reais ainda exigem ensaio humano. + curta comprovada; prompts nativos sem hook são sinalizados e concluídos por + `Ctrl+O` (#29); ferramentas/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 @@ -56,8 +57,8 @@ atender a esse contrato, registrar a lacuna antes do lançamento conjunto. O mantenedor priorizou a implementação nativa **Claude primeiro**. O PTY oculto com hooks e UI Bee é a primeira entrega desse caminho, em modo experimental; não altera o aceite do lançamento conjunto da experiência completa. O próximo -recorte deve completar histórico/exportação revisável, cobrir prompts nativos que -não emitem hooks e ensaiar ferramentas/permissões reais. A moldura PTY anterior +recorte deve completar histórico/exportação revisável e ensaiar +ferramentas/permissões reais; prompts nativos sem hook já são sinalizados (#29). A moldura PTY anterior virou diagnóstico opcional. O transporte atual continua com o binário interativo original e login próprio; não usa `-p` nem API separada. O HTML ainda representa o plano diff --git a/docs/EXECUTION.md b/docs/EXECUTION.md index c0ac4e8..89121f2 100644 --- a/docs/EXECUTION.md +++ b/docs/EXECUTION.md @@ -1,6 +1,6 @@ # Estado de execução -Última atualização: 2026-09-25. Este arquivo é o ponto de retomada entre agentes. Não substitui a inspeção do Git nem os critérios do [roadmap](../ROADMAP.md). +Última atualização: 2026-09-28. Este arquivo é o ponto de retomada entre agentes. Não substitui a inspeção do Git nem os critérios do [roadmap](../ROADMAP.md). ## Situação atual @@ -74,8 +74,11 @@ de patches e consulta ao Git ficam para a etapa 04. rejeitou a exibição da tela nativa do Claude. `workspace --claude` agora hospeda a CLI interativa em PTY oculto e usa hooks para mostrar texto, ações e permissões na Bee. Uma resposta real curta foi recebida na UI; negação/aprovação e retomada -foram testadas com CLI falsa. Próximo: ensaio humano de ferramentas/permissões -reais, tratamento de prompts nativos sem hook e histórico/exportação revisável. +foram testadas com CLI falsa. Prompts nativos sem hook agora são detectados e +concluídos por `Ctrl+O` ([#29](https://github.com/lippdev/memory-bee/issues/29)). +Próximo: prova de ferramentas/permissões reais +([#30](https://github.com/lippdev/memory-bee/issues/30)) e histórico/exportação +revisável ([#31](https://github.com/lippdev/memory-bee/issues/31)). O modo ainda guarda só IDs; perfis reais e Codex integrado seguem pendentes. O lançamento da experiência completa ainda exige os dois agentes (ADR 0018). @@ -746,3 +749,38 @@ escopo da entrega atual nem a próxima tarefa aprovada. permissão; `Ctrl+C` e `Ctrl+Q` negam antes de interromper/sair. O roteiro 51 identifica `--claude-terminal` como diagnóstico. Ainda não há prova de ferramentas e permissões reais além da resposta curta já observada. + +## Claude oculto — prompts nativos sem hook (#29) + +- Data: 2026-09-28. Branch `claude/quirky-lovelace-pybgg3`, conforme a sessão + (não `codex/`, porque a sessão fixou esse nome). +- `workspace --claude` passa a alimentar um modelo de tela privado (`vt100`) + com a saída do PTY, respondendo consultas de cursor/estado. Sem `SessionStart` + em 5 s, a Bee mostra que o Claude aguarda uma ação nativa e nomeia o tipo + (confiança, login, configuração inicial ou tela sem evento) sem copiar o texto + da tela. `Ctrl+O` abre explicitamente o Claude original para concluir; pedidos + de permissão sempre voltam ao painel Bee. Turnos sem hooks por 20 s geram um + aviso único. +- `Ctrl+Q` deixou de digitar `/exit` + Enter em qualquer estado (um Enter + poderia confirmar um diálogo nativo, como a confiança do projeto): antes do + `SessionStart` encerra o processo; em turno ativo envia `Ctrl+C` e digita + `/exit` só após `Stop`; no prompt ocioso mantém `/exit`; fallback de 5 s. +- Evidências automatizadas: testes unitários da heurística (inclui não copiar + texto com credencial falsa) e da máquina de estados; `scripts/check_claude_hidden_pty.py` + ganhou CLI falsa com diálogo de confiança sem hook: bloqueio visível sem vazar + a tela, `Ctrl+O` → resposta `1` → `SessionStart` → retorno à Bee → `/exit`; e + `Ctrl+Q` bloqueado sem nada digitado no diálogo, saída 4 e terminal restaurado. + `cargo fmt --check`, `clippy -D warnings`, `cargo test --locked`, os quatro + scripts PTY e `check_bundle.py` passaram localmente (Linux). +- Caminho real observado pelo agente: Claude Code 2.1.283 sem login, projeto + descartável em pasta temporária. A Bee sinalizou "configuração inicial" sem + mostrar o texto nativo; `Ctrl+O` exibiu a tela de boas-vindas; `Ctrl+Q` saiu + em 0,27 s com terminal restaurado e trava removida. Não havia conta autenticada, + então o diálogo de confiança real e o login completo não foram ensaiados; + item 52 do roteiro segue pendente para o mantenedor. +- Limitações: a detecção é heurística (tempo + palavras-chave em inglês); um + diálogo nativo no meio do turno só gera aviso, não bloqueio. Autorrevisão; + sem revisão independente. +- Próximo: [#30](https://github.com/lippdev/memory-bee/issues/30) (ferramentas e + permissões reais) e [#31](https://github.com/lippdev/memory-bee/issues/31) + (histórico e exportação da sessão Claude). diff --git a/docs/MANUAL_TESTS.md b/docs/MANUAL_TESTS.md index ea93180..e0c17ac 100644 --- a/docs/MANUAL_TESTS.md +++ b/docs/MANUAL_TESTS.md @@ -892,8 +892,17 @@ Com permissão pendente, conferir que outra tecla não decide e que `Ctrl+C` ou Confirmar que a sessão continua acessível no Claude original. Registrar versão e resultados, sem copiar dados reais da conversa para o repositório. -Prompts de login/confiança não cobertos por hook podem ficar invisíveis; se a -tela não avançar, sair com `Ctrl+Q` e preparar o projeto diretamente no Claude. +Prompts nativos sem hook: em um projeto descartável **não confiado** (e, se +possível, num perfil ainda sem login), rodar o comando acima sem preparar o +projeto antes. Esperado: em cerca de 5 s a Bee informa "Claude aguarda +confiança do projeto" (ou login/configuração inicial) sem mostrar o texto nativo; +`Ctrl+O` abre o Claude original com moldura Bee; concluir a confiança/login ali; +a Bee registra "Preparação nativa concluída"; `Ctrl+O` volta à conversa Bee. +Repetir em outro projeto não confiado e pressionar `Ctrl+Q` enquanto bloqueado: +a Bee deve sair em até 1 s, o projeto continuar não confiado no Claude original +e o terminal ser restaurado. Durante um turno longo, conferir o aviso de 20 s e +que `Ctrl+Q` interrompe antes de sair. Nenhuma credencial deve aparecer fora da +tela nativa aberta por `Ctrl+O`. `--claude-terminal` mostra o modo antigo apenas para diagnóstico. O teste sintético é `python3 scripts/check_claude_hidden_pty.py`; não substitui este ensaio humano. Exportação integrada e perfis reais ainda pendentes. @@ -901,6 +910,7 @@ ensaio humano. Exportação integrada e perfis reais ainda pendentes. | Item novo | Estado | Evidência manual | |---|---|---| | 52: UI Bee sobre Claude real oculto, ferramentas, permissões e retomada | Pendente | — | +| 52: prompts nativos sem hook — confiança/login via `Ctrl+O` e `Ctrl+Q` seguro | Pendente | Agente observou só a configuração inicial de um Claude sem login (2026-09-28); não é ensaio humano | ## 53. Revisão do Pullfrog em PR (pendente) diff --git a/docs/specs/workspace.md b/docs/specs/workspace.md index 06a3f8b..7f6d4be 100644 --- a/docs/specs/workspace.md +++ b/docs/specs/workspace.md @@ -35,8 +35,25 @@ a TUI nativa visível para diagnóstico. Se houver permissão pendente, `Ctrl+C` `Ctrl+Q` negam antes de interromper ou sair; outras teclas além de `y`, `n` e Esc não decidem a permissão. O painel mostra o `tool_input` completo; quando não cabe para revisão, `y` também -nega. `Ctrl+Q` aguarda até cinco segundos por `/exit` e encerra o processo se -o CLI nativo estiver preso em um prompt invisível. +nega. + +Telas nativas sem hook (login, confiança do projeto, configuração inicial ou +outro diálogo) são detectadas pela ausência de progresso: sem `SessionStart` em +5 s, a Bee informa que o Claude aguarda uma ação no terminal original e nomeia o +tipo provável (confiança, login, configuração inicial ou tela sem evento) por +palavras-chave da tela privada, sem copiar o texto nativo para a conversa. Com a +sessão pronta, um turno sem hooks por 20 s gera um aviso único (pode estar +pensando ou numa tela nativa). A saída do PTY alimenta um modelo de tela privado +que responde consultas de terminal e só é desenhado quando o usuário pressiona +`Ctrl+O`, ação explícita para concluir a preparação ou diagnosticar; nesse modo +as teclas vão ao Claude, `Ctrl+O` volta à Bee e pedidos de permissão sempre +retornam ao painel Bee. A Memory Bee não registra a tela nativa. + +`Ctrl+Q` nunca digita em um diálogo nativo: antes do `SessionStart`, encerra o +processo imediatamente; com turno em andamento ou tela original aberta, envia +`Ctrl+C` e digita `/exit` só depois do `Stop` do turno (sem turno, aguarda o +encerramento); no prompt ocioso da Bee, digita `/exit`. +Em todos os casos, se o CLI não sair em cinco segundos, o processo é encerrado. `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; @@ -50,10 +67,10 @@ conversa/permissões reais por si só; ver [roteiro manual](../MANUAL_TESTS.md). **Direção atual:** o mantenedor não quer ver a TUI original do Claude. O modo padrão `--claude` já oculta essa tela e mostra componentes da Memory Bee. O terminal embutido anterior requer `--claude-terminal`. A UI Bee ainda é um -recorte funcional sem histórico/exportação nativos integrados; prompts de login, -confiança do projeto ou outros controles do Claude que não emitam hooks podem -ficar ocultos e bloquear a sessão. Preparar o projeto no Claude original antes -de usar a Bee e conferir o [roteiro manual](../MANUAL_TESTS.md). O [ADR 0018](../decisions/0018-unified-workspace.md) +recorte funcional sem histórico/exportação nativos integrados. Prompts nativos +sem hook agora são sinalizados e podem ser concluídos com `Ctrl+O`; a detecção é +heurística (tempo e palavras-chave) e o diálogo de confiança real ainda não foi +ensaiado com conta autenticada. Conferir o [roteiro manual](../MANUAL_TESTS.md). O [ADR 0018](../decisions/0018-unified-workspace.md) registra por que o transporte mantém o binário interativo sem usar `-p`. ```sh diff --git a/scripts/check_claude_hidden_pty.py b/scripts/check_claude_hidden_pty.py index 652843f..65033d8 100644 --- a/scripts/check_claude_hidden_pty.py +++ b/scripts/check_claude_hidden_pty.py @@ -14,7 +14,8 @@ FAKE = r'''#!/usr/bin/env python3 -import json, os, subprocess, sys +import json, os, signal, subprocess, sys +signal.signal(signal.SIGINT, signal.SIG_IGN) # Real Claude reads Ctrl+C as a key. args = sys.argv[1:] session_id = args[args.index('--resume') + 1] if '--resume' in args else args[args.index('--session-id') + 1] settings = json.loads(args[args.index('--settings') + 1]) @@ -37,6 +38,80 @@ def hook(name, **fields): hook('Stop') ''' +# Untrusted project: a native dialog waits without emitting any hook. +FAKE_TRUST = r'''#!/usr/bin/env python3 +import json, os, subprocess, sys +args = sys.argv[1:] +session_id = args[args.index('--resume') + 1] if '--resume' in args else args[args.index('--session-id') + 1] +def hook(name, **fields): + payload = json.dumps(dict(hook_event_name=name, session_id=session_id, **fields)) + subprocess.run([os.environ['BEE_BINARY'], '__claude-hook'], input=payload, text=True, capture_output=True, check=True) +print('Do you trust the files in this folder? 1. Yes 2. No', flush=True) +for line in sys.stdin: + with open(os.environ['BEE_FAKE_STDIN'], 'a') as f: + f.write(line.strip() + '\n') + if line.strip() == '1': + hook('SessionStart') + elif line.strip() == '/exit': + sys.exit(0) +''' + + +def spawn(binary, project, state, env): + master, slave = pty.openpty() + fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) + before = termios.tcgetattr(slave) + argv = [str(binary), 'workspace', '--claude', '--project', str(project), '--state', str(state), '--no-color'] + proc = subprocess.Popen(argv, stdin=slave, stdout=slave, stderr=slave, env=env) + output = bytearray() + + def until(token, seconds=12): + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if select.select([master], [], [], 0.05)[0]: + try: + output.extend(os.read(master, 65536)) + except OSError: + break + if token in output: + return + if proc.poll() is not None: + break + raise AssertionError(f'missing Bee UI token {token!r}; process exit={proc.poll()}; Bee output={output[-400:]!r}') + + return master, slave, before, proc, output, until + + +def untrusted(binary, project, state, env, quit_while_blocked): + master, slave, before, proc, output, until = spawn(binary, project, state, env) + try: + until('confiança'.encode()) + assert b'trust' not in output, 'native dialog leaked into the Bee view' + if quit_while_blocked: + started = time.monotonic() + os.write(master, b'\x11') + proc.wait(timeout=4) + assert time.monotonic() - started < 4 + assert proc.returncode == 4, proc.returncode + else: + os.write(master, b'\x0f') # Ctrl+O opens the original screen. + until(b'trust') + os.write(master, b'1\r') + until(b'encerra ') # Ready footer is shorter than the setup one. + os.write(master, b'\x0f') + until('concluída'.encode()) + os.write(master, b'\x11') # Idle prompt: Ctrl+Q types /exit. + proc.wait(timeout=10) + assert proc.returncode == 0, proc.returncode + assert termios.tcgetattr(slave) == before + assert not (state / 'claude-native.lock').exists() + finally: + if proc.poll() is None: + proc.kill() + proc.wait() + os.close(master) + os.close(slave) + def run(binary, project, state, env, resume, decision): master, slave = pty.openpty() @@ -112,7 +187,22 @@ def main(): denied = subprocess.run([str(binary), '__claude-hook'], input=json.dumps({'hook_event_name':'PermissionRequest'}), text=True, capture_output=True, env=dict(env, MEMORY_BEE_CLAUDE_SOCKET=str(root / 'missing.sock'))) assert denied.returncode == 0 assert json.loads(denied.stdout)['hookSpecificOutput']['decision']['behavior'] == 'deny' - print('PASS: Bee-only UI, hidden original output, hooks, deny/allow, Ctrl+Q denial, resume and terminal restoration') + with tempfile.TemporaryDirectory(prefix='bee-trust-') as temp: + root = Path(temp) + fake_dir = root / 'bin' + fake_dir.mkdir() + fake = fake_dir / 'claude' + fake.write_text(FAKE_TRUST) + fake.chmod(0o700) + project = root / 'project' + project.mkdir() + stdin_log = root / 'stdin' + env = dict(os.environ, PATH=f'{fake_dir}:/usr/bin:/bin', BEE_BINARY=str(binary), BEE_FAKE_STDIN=str(stdin_log)) + untrusted(binary, project, root / 'state-quit', env, True) + assert not stdin_log.exists(), 'Ctrl+Q typed into the native dialog' + untrusted(binary, project, root / 'state-setup', env, False) + assert stdin_log.read_text().splitlines() == ['1', '/exit'] + print('PASS: Bee-only UI, hidden original output, hooks, deny/allow, Ctrl+Q denial, resume, blocked native setup via Ctrl+O, safe Ctrl+Q and terminal restoration') if __name__ == '__main__': diff --git a/src/tui/claude_native.rs b/src/tui/claude_native.rs index a719df8..831c84d 100644 --- a/src/tui/claude_native.rs +++ b/src/tui/claude_native.rs @@ -363,6 +363,37 @@ fn key_bytes(key: KeyEvent, application_cursor: bool) -> Option> { } } +/// Feeds native output to a private screen model and answers terminal status +/// queries, so Claude Code behaves as in a real terminal without being shown. +fn feed_native( + parser: &mut vt100::Parser, + query_tail: &mut Vec, + bytes: &[u8], + pty: &mut Pty, +) -> Result<(), String> { + 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); + Ok(()) +} + pub fn run( project: &Path, root: &Path, @@ -409,28 +440,7 @@ pub fn run( 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); + feed_native(&mut parser, &mut query_tail, &bytes, &mut pty)?; dirty = true; } Output::Closed => closed = true, @@ -664,6 +674,31 @@ fn hook_settings(executable: &Path) -> String { serde_json::json!({"hooks":hooks}).to_string() } +/// Without a first hook in this window, Claude is waiting on a native screen. +#[cfg(unix)] +const SETUP_STALL: Duration = Duration::from_secs(5); +/// A turn without hooks for this long may be thinking or on a native screen. +#[cfg(unix)] +const TURN_STALL: Duration = Duration::from_secs(20); + +/// Names the native screen without copying its text into the Bee view. +#[cfg(unix)] +fn native_hint(screen: &str) -> &'static str { + let text = screen.to_lowercase(); + if text.contains("trust") { + "confiança do projeto" + } else if ["login", "log in", "sign in", "api key", "authenticat"] + .iter() + .any(|word| text.contains(word)) + { + "login ou autenticação" + } else if text.contains("text style") || text.contains("theme") { + "configuração inicial" + } else { + "tela nativa sem evento" + } +} + #[cfg(unix)] struct HiddenView { lines: Vec, @@ -671,17 +706,32 @@ struct HiddenView { pending: Option<(String, mpsc::Sender)>, ready: bool, quitting: bool, + /// Explicitly opened original screen, only to finish setup or diagnose. + native: bool, + /// Native screen that blocks startup and emits no hook. + blocked: Option<&'static str>, + started: Instant, + /// Last progress of a turn in flight; `None` between turns. + turn: Option, + turn_hinted: bool, + exit_after_turn: bool, } #[cfg(unix)] impl HiddenView { - fn new() -> Self { + fn new(now: Instant) -> Self { Self { lines: vec!["Iniciando Claude Code em segundo plano…".into()], input: String::new(), pending: None, ready: false, quitting: false, + native: false, + blocked: None, + started: now, + turn: None, + turn_hinted: false, + exit_after_turn: false, } } fn push(&mut self, line: impl Into) { @@ -690,16 +740,59 @@ impl HiddenView { self.lines.drain(..1000); } } - fn receive(&mut self, event: BridgeEvent, id: Uuid) { + /// Detects missing progress. Returns true when the view changed. + fn check_stall(&mut self, now: Instant, screen: impl FnOnce() -> String) -> bool { + if !self.ready && !self.quitting && now.duration_since(self.started) >= SETUP_STALL { + let hint = native_hint(&screen()); + if self.blocked == Some(hint) { + return false; + } + if self.blocked.is_none() { + self.push(format!( + "Claude aguarda {hint} no terminal original, sem evento para a Bee. \ + Ctrl+O abre o Claude original para concluir; Ctrl+Q encerra sem responder." + )); + } + self.blocked = Some(hint); + return true; + } + if self.ready + && !self.turn_hinted + && self.pending.is_none() + && self + .turn + .is_some_and(|at| now.duration_since(at) >= TURN_STALL) + { + self.turn_hinted = true; + self.push( + "Sem eventos do Claude há 20 s: pode estar pensando ou numa tela nativa. \ + Ctrl+O mostra o terminal original; Ctrl+C interrompe.", + ); + return true; + } + false + } + fn submitted(&mut self, now: Instant) { + self.turn = Some(now); + self.turn_hinted = false; + } + fn receive(&mut self, event: BridgeEvent, id: Uuid, now: Instant) { if event.value["session_id"].as_str() != Some(&id.to_string()) { if let Some(reply) = event.reply { let _ = reply.send(false); } return; } + if self.turn.is_some() { + self.turn = Some(now); + self.turn_hinted = false; + } match event.value["hook_event_name"].as_str().unwrap_or("") { "SessionStart" => { self.ready = true; + if self.blocked.take().is_some() { + self.push("Preparação nativa concluída. Ctrl+O volta à Bee se necessário."); + } self.push("Claude pronto. Digite sua mensagem abaixo."); } "MessageDisplay" => { @@ -731,15 +824,28 @@ impl HiddenView { if self.pending.is_some() { let _ = reply.send(false); } else { + // Decisions are always reviewed in the Bee panel. + self.native = false; self.pending = Some((request, reply)); } } } - "Stop" => self.push("Turno concluído."), - "StopFailure" => self.push("Claude encerrou o turno com erro."), + "Stop" => { + self.turn = None; + self.push("Turno concluído."); + } + "StopFailure" => { + self.turn = None; + self.push("Claude encerrou o turno com erro."); + } _ => {} } } + /// `/exit` is typed only at Claude's idle prompt; a native dialog could + /// take its Enter as confirmation. + fn exit_by_command(&self) -> bool { + self.ready && self.blocked.is_none() && self.turn.is_none() && !self.native + } } #[cfg(unix)] @@ -798,6 +904,8 @@ fn draw_hidden(frame: &mut Frame, view: &HiddenView, mode: Mode, no_color: bool) ); let prompt = if view.pending.is_some() { "Permissão pendente · veja o painel de revisão".into() + } else if let Some(hint) = view.blocked { + format!("Claude aguarda {hint} · Ctrl+O concluir no original · Ctrl+Q sair") } else if !view.ready { "Aguardando Claude iniciar…".into() } else { @@ -815,7 +923,9 @@ fn draw_hidden(frame: &mut Frame, view: &HiddenView, mode: Mode, no_color: bool) rows[2], ); frame.render_widget( - Paragraph::new("Enter envia · Ctrl+C interrompe · Ctrl+Q sai · sem aprovação Bee, negar") + Paragraph::new( + "Enter envia · Ctrl+C interrompe · Ctrl+O Claude original · Ctrl+Q sai · sem aprovação Bee, negar", + ) .style(Style::default().bg(bg)), rows[3], ); @@ -864,6 +974,48 @@ fn draw_hidden(frame: &mut Frame, view: &HiddenView, mode: Mode, no_color: bool) } } +/// Original Claude screen, opened only by Ctrl+O to finish setup or diagnose. +#[cfg(unix)] +fn draw_native_setup( + frame: &mut Frame, + screen: &vt100::Screen, + view: &HiddenView, + mode: Mode, + no_color: bool, +) { + let area = frame.area(); + let p = Palette::new(mode, no_color); + frame.render_widget(ratatui::widgets::Clear, area); + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(3), + Constraint::Length(1), + ]) + .split(area); + frame.render_widget( + Paragraph::new("⬢ Memory Bee · Claude original aberto por Ctrl+O") + .style(Style::default().fg(p.accent).add_modifier(Modifier::BOLD)), + rows[0], + ); + let block = Block::default() + .title(" Claude Code original · preparação/diagnóstico ") + .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); + 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)); + } + let footer = if view.ready { + "Claude pronto · Ctrl+O volta à Bee · Ctrl+Q encerra" + } else { + "Teclas vão ao Claude · Ctrl+O volta à Bee · Ctrl+Q encerra sem responder" + }; + frame.render_widget(Paragraph::new(footer), rows[2]); +} + /// First native Bee conversation surface. Claude's own terminal stays private. #[cfg(unix)] pub fn run_hidden( @@ -891,32 +1043,51 @@ pub fn run_hidden( if size.width < 40 || size.height < 10 { return Err("Terminal exige pelo menos 40×10".into()); } + // Claude draws its original screen inside the frame opened by Ctrl+O. + let (rows, cols) = viewport(size.into()); + let mut parser = vt100::Parser::new(rows, cols, 0); + let mut query_tail = Vec::new(); let mut pty = Pty::start( OsStr::new("claude"), project, id, resume, - pty_size(size.height, size.width), + pty_size(rows, cols), no_color, Some((&bridge.path, &settings)), )?; if !resume { store.start(&mut state, id)?; } - let mut view = HiddenView::new(); + let mut view = HiddenView::new(Instant::now()); let mut status = None; let mut quit_at = None; let mut dirty = true; loop { while let Ok(event) = bridge.events.try_recv() { - view.receive(event, id); + view.receive(event, id, Instant::now()); dirty = true; } - // Drain native terminal bytes without displaying or parsing its UI. - while pty.output.try_recv().is_ok() {} + if view.exit_after_turn && view.turn.is_none() { + view.exit_after_turn = false; + // Claude may already be gone; the exit status decides below. + let _ = pty.write(b"/exit\r"); + } + // Native bytes feed a private screen model; it is shown only on Ctrl+O. + while let Ok(Output::Bytes(bytes)) = pty.output.try_recv() { + feed_native(&mut parser, &mut query_tail, &bytes, &mut pty)?; + dirty |= view.native; + } + dirty |= view.check_stall(Instant::now(), || parser.screen().contents()); if dirty { terminal - .draw(|f| draw_hidden(f, &view, mode, no_color)) + .draw(|f| { + if view.native { + draw_native_setup(f, parser.screen(), &view, mode, no_color) + } else { + draw_hidden(f, &view, mode, no_color) + } + }) .map_err(|e| e.to_string())?; dirty = false; } @@ -941,26 +1112,48 @@ pub fn run_hidden( Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { - if key.modifiers.contains(KeyModifiers::CONTROL) - && key.code == KeyCode::Char('c') - { + let control = key.modifiers.contains(KeyModifiers::CONTROL); + if control && key.code == KeyCode::Char('q') { if let Some((_, reply)) = view.pending.take() { let _ = reply.send(false); - view.push("Permissão negada ao interromper."); + view.push("Permissão negada ao sair."); } - pty.write(&[3])?; - view.push("Interrupção enviada ao Claude."); - } else if key.modifiers.contains(KeyModifiers::CONTROL) - && key.code == KeyCode::Char('q') - { + if view.quitting { + // Already leaving; the 5 s fallback still applies. + } else if !view.ready { + // Nothing is typed into a native setup dialog. + pty.child.kill().map_err(|e| e.to_string())?; + let exit = pty.child.wait().map_err(|e| e.to_string())?; + pty.exited = true; + status = Some(exit.exit_code()); + } else if view.exit_by_command() { + pty.write(b"/exit\r")?; + quit_at = Some(Instant::now()); + view.push("Encerrando Claude…"); + } else { + // Enter could confirm a native dialog; interrupt + // and type /exit only after the turn stops. + pty.write(&[3])?; + // Without a turn, the 5 s fallback ends the process. + view.exit_after_turn = view.turn.is_some(); + quit_at = Some(Instant::now()); + view.push("Interrompendo Claude antes de sair…"); + } + view.quitting = true; + view.native = false; + } else if control && key.code == KeyCode::Char('o') && view.pending.is_none() { + view.native = !view.native; + } else if view.native { + if let Some(bytes) = key_bytes(key, parser.screen().application_cursor()) { + pty.write(&bytes)?; + } + } else if control && key.code == KeyCode::Char('c') { if let Some((_, reply)) = view.pending.take() { let _ = reply.send(false); - view.push("Permissão negada ao sair."); + view.push("Permissão negada ao interromper."); } - pty.write(b"/exit\r")?; - view.quitting = true; - quit_at = Some(Instant::now()); - view.push("Encerrando Claude…"); + pty.write(&[3])?; + view.push("Interrupção enviada ao Claude."); } else if view.pending.is_some() && matches!(key.code, KeyCode::Char('y' | 'n') | KeyCode::Esc) && !key @@ -979,9 +1172,7 @@ pub fn run_hidden( }); } else if view.pending.is_none() && view.ready && !view.quitting { match key.code { - KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => { - view.input.push(c) - } + KeyCode::Char(c) if !control => view.input.push(c), KeyCode::Backspace => { view.input.pop(); } @@ -989,6 +1180,7 @@ pub fn run_hidden( let prompt = std::mem::take(&mut view.input); pty.write(prompt.as_bytes())?; pty.write(b"\r")?; + view.submitted(Instant::now()); view.push(format!("Você: {prompt}")); } _ => {} @@ -996,12 +1188,23 @@ pub fn run_hidden( } dirty = true; } + Event::Paste(text) if view.native => { + 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::Paste(paste) if view.ready && view.pending.is_none() => { view.input.push_str(&paste.replace(['\r', '\n'], " ")); dirty = true; } Event::Resize(width, height) => { - pty.resize(height, width)?; + 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; } @@ -1056,6 +1259,53 @@ mod tests { } #[cfg(unix)] #[test] + fn native_screens_are_named_without_copying_their_text() { + assert_eq!( + native_hint("Do you trust the files in this folder?"), + "confiança do projeto" + ); + assert_eq!( + native_hint("Select login method: token abc123"), + "login ou autenticação" + ); + assert_eq!( + native_hint("Choose the text style ... /theme"), + "configuração inicial" + ); + assert_eq!(native_hint("???"), "tela nativa sem evento"); + let mut view = HiddenView::new(Instant::now()); + view.check_stall(Instant::now() + SETUP_STALL, || "login token abc123".into()); + assert!(view.lines.iter().all(|line| !line.contains("abc123"))); + } + #[cfg(unix)] + #[test] + fn missing_hooks_block_startup_and_hint_during_turns() { + let start = Instant::now(); + let mut view = HiddenView::new(start); + assert!(!view.check_stall(start + Duration::from_secs(1), || "trust".into())); + assert!(view.check_stall(start + SETUP_STALL, || "trust".into())); + assert_eq!(view.blocked, Some("confiança do projeto")); + assert!(!view.check_stall(start + SETUP_STALL, || "trust".into())); + assert!(!view.exit_by_command()); + let id = Uuid::new_v4(); + let hook = |name: &str| BridgeEvent { + value: serde_json::json!({"hook_event_name": name, "session_id": id.to_string()}), + reply: None, + }; + view.receive(hook("SessionStart"), id, start); + assert!(view.ready && view.blocked.is_none() && view.exit_by_command()); + view.submitted(start); + assert!(!view.exit_by_command()); + assert!(!view.check_stall(start + Duration::from_secs(19), String::new)); + view.receive(hook("PreToolUse"), id, start + Duration::from_secs(19)); + assert!(!view.check_stall(start + Duration::from_secs(30), String::new)); + assert!(view.check_stall(start + Duration::from_secs(40), String::new)); + assert!(!view.check_stall(start + Duration::from_secs(60), String::new)); + view.receive(hook("Stop"), id, start + Duration::from_secs(61)); + assert!(view.turn.is_none() && view.exit_by_command()); + } + #[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())); From 79a86c44be080e107b45aeec60f6c1f9f06133f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:52:27 +0000 Subject: [PATCH 2/7] feat: state who allowed each hidden Claude tool call (#30) Tool results in the Bee now say whether the user approved them once in the Bee panel or Claude ran them without a PermissionRequest under its own rules. The review panel closes at 85 s, before the hook's 90 s denial, so a late `y` can no longer be reported as approved; undelivered replies and concurrent requests are reported as denied. Refs #30 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MUyLMVv9GbqWRByEZ7LgEb --- docs/EXECUTION.md | 28 ++- docs/MANUAL_TESTS.md | 6 + docs/specs/workspace.md | 9 + scripts/check_claude_hidden_pty.py | 12 +- src/tui/claude_native.rs | 267 +++++++++++++++++++++++++---- 5 files changed, 282 insertions(+), 40 deletions(-) diff --git a/docs/EXECUTION.md b/docs/EXECUTION.md index 89121f2..edc45d9 100644 --- a/docs/EXECUTION.md +++ b/docs/EXECUTION.md @@ -75,8 +75,9 @@ rejeitou a exibição da tela nativa do Claude. `workspace --claude` agora hospe a CLI interativa em PTY oculto e usa hooks para mostrar texto, ações e permissões na Bee. Uma resposta real curta foi recebida na UI; negação/aprovação e retomada foram testadas com CLI falsa. Prompts nativos sem hook agora são detectados e -concluídos por `Ctrl+O` ([#29](https://github.com/lippdev/memory-bee/issues/29)). -Próximo: prova de ferramentas/permissões reais +concluídos por `Ctrl+O` ([#29](https://github.com/lippdev/memory-bee/issues/29)), +e a Bee distingue ações aprovadas por ela das liberadas pelo próprio Claude. +Próximo: ensaio humano de ferramentas/permissões reais ([#30](https://github.com/lippdev/memory-bee/issues/30)) e histórico/exportação revisável ([#31](https://github.com/lippdev/memory-bee/issues/31)). O modo ainda guarda só IDs; perfis reais e Codex integrado seguem pendentes. @@ -784,3 +785,26 @@ escopo da entrega atual nem a próxima tarefa aprovada. - Próximo: [#30](https://github.com/lippdev/memory-bee/issues/30) (ferramentas e permissões reais) e [#31](https://github.com/lippdev/memory-bee/issues/31) (histórico e exportação da sessão Claude). + +## Claude oculto — origem das ações e expiração (#30, parcial) + +- Data: 2026-09-28, mesmo branch e PR #51. Parte de código da issue #30. +- Não houve ensaio real de ferramentas: o Claude Code deste ambiente não está + autenticado e a Memory Bee não usa credenciais que não sejam do login próprio + do CLI. O ensaio real (leitura, edição, Bash, permitir/negar, interrupção e + retomada) continua no item 52 do roteiro, para o mantenedor. +- Lacunas corrigidas: (1) o resultado de cada ferramenta diz se foi aprovado na + Bee ou liberado pelas regras/modo do próprio Claude, sem afirmar aprovação que + o CLI não pediu; associação por `tool_use_id` ou nome+entrada, com número de + sequência estável. (2) Um pedido sem resposta ficava aberto após o hook negar + em 90 s, e um `y` tardio mostrava "concedida"; agora o painel fecha em 85 s + com registro de negação, e resposta não entregue é informada como não + aprovada. Pedido concorrente é negado com registro. +- Evidências: testes unitários de origem, pedido antigo concluindo durante a + revisão, expiração, pedido concorrente e aprovação tardia; teste PTY com CLI + falsa emitindo leitura sem pedido e Bash com pedido. Checks canônicos, quatro + scripts PTY e `check_bundle.py` passaram localmente (Linux). Autorrevisão. +- Limitação: a presença de `tool_use_id` no `PermissionRequest` real não foi + confirmada; sem ele a associação usa nome e entrada exatos. +- Próximo: ensaio humano do item 52 para fechar #29/#30 e, em código, #31 + (histórico e exportação da sessão Claude). diff --git a/docs/MANUAL_TESTS.md b/docs/MANUAL_TESTS.md index e0c17ac..0636186 100644 --- a/docs/MANUAL_TESTS.md +++ b/docs/MANUAL_TESTS.md @@ -889,6 +889,11 @@ solicitação, verificar que `n` nega e `y` permite somente a ação exibida. Testar `Ctrl+C`, colagem, resize, `Ctrl+Q`, retomada e restauração do terminal. Com permissão pendente, conferir que outra tecla não decide e que `Ctrl+C` ou `Ctrl+Q` negam antes de interromper ou sair. +Para cada ação, conferir a origem mostrada no resultado: ações que o Claude já +permite (por exemplo, leitura) devem aparecer "sem pedido de permissão"; somente +ações aprovadas com `y` aparecem como "aprovada por você na Bee". Deixar um +pedido sem resposta por 85 s e conferir "Pedido expirou sem resposta e foi +negado" e que a ação não ocorreu. Confirmar que a sessão continua acessível no Claude original. Registrar versão e resultados, sem copiar dados reais da conversa para o repositório. @@ -910,6 +915,7 @@ ensaio humano. Exportação integrada e perfis reais ainda pendentes. | Item novo | Estado | Evidência manual | |---|---|---| | 52: UI Bee sobre Claude real oculto, ferramentas, permissões e retomada | Pendente | — | +| 52: origem das ações (regra do Claude × aprovação Bee) e expiração negada | Pendente | — | | 52: prompts nativos sem hook — confiança/login via `Ctrl+O` e `Ctrl+Q` seguro | Pendente | Agente observou só a configuração inicial de um Claude sem login (2026-09-28); não é ensaio humano | ## 53. Revisão do Pullfrog em PR (pendente) diff --git a/docs/specs/workspace.md b/docs/specs/workspace.md index 7f6d4be..6ddb7ab 100644 --- a/docs/specs/workspace.md +++ b/docs/specs/workspace.md @@ -37,6 +37,15 @@ Esc não decidem a permissão. O painel mostra o `tool_input` completo; quando não cabe para revisão, `y` também nega. +Cada ação aparece com um resumo (`Ação: Bash · `). O resultado informa a +origem: "aprovada por você na Bee, uma vez" somente quando houve +`PermissionRequest` e o hook recebeu `allow`; ações sem pedido aparecem como +"sem pedido de permissão; liberada pelas regras ou modo do próprio Claude". A +associação usa `tool_use_id` e, na falta dele, nome e entrada da ferramenta. O +painel fecha em 85 s, antes do limite de 90 s do hook, e registra que o pedido +expirou negado; um segundo pedido enquanto outro aguarda é negado e registrado. +Se a resposta não chegar ao hook, a Bee informa que nada foi aprovado. + Telas nativas sem hook (login, confiança do projeto, configuração inicial ou outro diálogo) são detectadas pela ausência de progresso: sem `SessionStart` em 5 s, a Bee informa que o Claude aguarda uma ação no terminal original e nomeia o diff --git a/scripts/check_claude_hidden_pty.py b/scripts/check_claude_hidden_pty.py index 65033d8..0496637 100644 --- a/scripts/check_claude_hidden_pty.py +++ b/scripts/check_claude_hidden_pty.py @@ -32,9 +32,16 @@ def hook(name, **fields): prompt = sys.stdin.readline().strip() hook('UserPromptSubmit', prompt=prompt) hook('MessageDisplay', delta='resposta sintetica', final=True, index=0) +# Read allowed by Claude's own rules: no PermissionRequest. +hook('PreToolUse', tool_name='Read', tool_use_id='r1', tool_input={'file_path':'README.md'}) +hook('PostToolUse', tool_name='Read', tool_use_id='r1', tool_input={'file_path':'README.md'}) +hook('PreToolUse', tool_name='Bash', tool_use_id='b1', tool_input={'command':'echo synthetic'}) decision = json.loads(hook('PermissionRequest', tool_name='Bash', tool_input={'command':'echo synthetic'})) +behavior = decision['hookSpecificOutput']['decision']['behavior'] with open(os.environ['BEE_DECISIONS'], 'a') as f: - f.write(decision['hookSpecificOutput']['decision']['behavior'] + '\n') + f.write(behavior + '\n') +if behavior == 'allow': + hook('PostToolUse', tool_name='Bash', tool_use_id='b1', tool_input={'command':'echo synthetic'}) hook('Stop') ''' @@ -143,6 +150,9 @@ def until(token): until(b'Permitir esta') os.write(master, decision.encode()) until(b'sintetica') + until(b'regras') # Read ran without a request: Claude's rules, not the Bee. + if decision == 'y': + until(b'aprovada') try: proc.wait(timeout=20) except subprocess.TimeoutExpired as exc: diff --git a/src/tui/claude_native.rs b/src/tui/claude_native.rs index 831c84d..1103189 100644 --- a/src/tui/claude_native.rs +++ b/src/tui/claude_native.rs @@ -508,6 +508,14 @@ pub fn run( Ok((id, code)) } +/// The hook denies a request left unanswered this long. +#[cfg(unix)] +const PERMISSION_TIMEOUT: Duration = Duration::from_secs(90); +/// The Bee closes the review a little earlier, so a late `y` never reaches +/// a hook that has already denied. +#[cfg(unix)] +const PERMISSION_REVIEW: Duration = Duration::from_secs(85); + #[cfg(unix)] struct BridgeEvent { value: serde_json::Value, @@ -599,9 +607,7 @@ fn handle_hook_connection(mut stream: UnixStream, tx: mpsc::Sender) { return; } - let allowed = reply_rx - .recv_timeout(Duration::from_secs(90)) - .unwrap_or(false); + let allowed = reply_rx.recv_timeout(PERMISSION_TIMEOUT).unwrap_or(false); let decision = if allowed { serde_json::json!({"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}).to_string() } else { @@ -699,11 +705,63 @@ fn native_hint(screen: &str) -> &'static str { } } +#[cfg(unix)] +struct Pending { + request: String, + reply: mpsc::Sender, + /// `ToolCall::seq`, stable while older calls are dropped. + tool: Option, + since: Instant, +} + +#[cfg(unix)] +struct ToolCall { + seq: u64, + id: Option, + name: String, + input: serde_json::Value, + /// `None`: Claude never asked; `Some(false)`: asked and not approved. + approved: Option, +} + +/// One-line hint of what a tool touches; the review panel shows everything. +#[cfg(unix)] +fn tool_summary(input: &serde_json::Value) -> String { + let value = [ + "command", + "file_path", + "path", + "pattern", + "url", + "description", + ] + .iter() + .find_map(|key| input[*key].as_str()) + .unwrap_or(""); + let line: String = value + .split_whitespace() + .collect::>() + .join(" ") + .chars() + .take(72) + .collect(); + if line.is_empty() { + String::new() + } else if value.chars().count() > 72 { + format!(" · {line}…") + } else { + format!(" · {line}") + } +} + #[cfg(unix)] struct HiddenView { lines: Vec, input: String, - pending: Option<(String, mpsc::Sender)>, + pending: Option, + /// Recent tool calls, to tell Bee approvals from Claude's own rules. + tools: Vec, + next_tool: u64, ready: bool, quitting: bool, /// Explicitly opened original screen, only to finish setup or diagnose. @@ -724,6 +782,8 @@ impl HiddenView { lines: vec!["Iniciando Claude Code em segundo plano…".into()], input: String::new(), pending: None, + tools: Vec::new(), + next_tool: 0, ready: false, quitting: false, native: false, @@ -742,6 +802,14 @@ impl HiddenView { } /// Detects missing progress. Returns true when the view changed. fn check_stall(&mut self, now: Instant, screen: impl FnOnce() -> String) -> bool { + if self + .pending + .as_ref() + .is_some_and(|p| now.duration_since(p.since) >= PERMISSION_REVIEW) + { + self.decide(false, true); + return true; + } if !self.ready && !self.quitting && now.duration_since(self.started) >= SETUP_STALL { let hint = native_hint(&screen()); if self.blocked == Some(hint) { @@ -776,6 +844,37 @@ impl HiddenView { self.turn = Some(now); self.turn_hinted = false; } + /// Matches a hook to its `PreToolUse` by ID, else by name and input. + fn find_tool(&self, value: &serde_json::Value) -> Option { + if let Some(id) = value["tool_use_id"].as_str() + && let Some(i) = self.tools.iter().rposition(|t| t.id.as_deref() == Some(id)) + { + return Some(i); + } + let name = value["tool_name"].as_str()?; + self.tools + .iter() + .rposition(|t| t.name == name && t.input == value["tool_input"]) + } + /// Answers the pending request. The message states only what the hook + /// actually received: an expired request was denied, never approved. + fn decide(&mut self, allow: bool, expired: bool) { + let Some(pending) = self.pending.take() else { + return; + }; + let delivered = !expired && pending.reply.send(allow).is_ok(); + let approved = allow && delivered; + if let Some(call) = self.tools.iter_mut().find(|t| Some(t.seq) == pending.tool) { + call.approved = Some(approved); + } + self.push(if !delivered { + "Pedido expirou sem resposta e foi negado; nada foi aprovado." + } else if approved { + "Você permitiu esta ação uma vez." + } else { + "Você negou esta ação." + }); + } fn receive(&mut self, event: BridgeEvent, id: Uuid, now: Instant) { if event.value["session_id"].as_str() != Some(&id.to_string()) { if let Some(reply) = event.reply { @@ -802,31 +901,70 @@ impl HiddenView { } } } - "PreToolUse" => self.push(format!( - "Ação: {}", - event.value["tool_name"].as_str().unwrap_or("desconhecida") - )), - "PostToolUse" => self.push(format!( - "Concluído: {}", - event.value["tool_name"].as_str().unwrap_or("ação") - )), - "PostToolUseFailure" => self.push(format!( - "Falhou: {}", - event.value["tool_name"].as_str().unwrap_or("ação") - )), + "PreToolUse" => { + let name = event.value["tool_name"].as_str().unwrap_or("desconhecida"); + self.push(format!( + "Ação: {name}{}", + tool_summary(&event.value["tool_input"]) + )); + if self.tools.len() >= 64 { + self.tools.remove(0); + } + self.next_tool += 1; + self.tools.push(ToolCall { + seq: self.next_tool, + id: event.value["tool_use_id"].as_str().map(String::from), + name: name.into(), + input: event.value["tool_input"].clone(), + approved: None, + }); + } + name @ ("PostToolUse" | "PostToolUseFailure") => { + let tool = event.value["tool_name"].as_str().unwrap_or("ação"); + let origin = match self.find_tool(&event.value).map(|i| self.tools.remove(i)) { + Some(ToolCall { + approved: Some(true), + .. + }) => "aprovada por você na Bee, uma vez", + Some(ToolCall { + approved: Some(false), + .. + }) => "atenção: o pedido não foi aprovado na Bee", + _ => "sem pedido de permissão; liberada pelas regras ou modo do próprio Claude", + }; + let verb = if name == "PostToolUse" { + "Concluído" + } else { + "Falhou" + }; + self.push(format!("{verb}: {tool} ({origin})")); + } "PermissionRequest" => { let name = event.value["tool_name"].as_str().unwrap_or("ação"); let details = serde_json::to_string_pretty(&event.value["tool_input"]) .unwrap_or_else(|_| "".into()); let request = format!("Ferramenta: {name}\nEntrada completa:\n{details}"); - self.push(format!("Permissão solicitada: {name}")); + self.push(format!("Claude pediu permissão: {name}")); + let tool = self.find_tool(&event.value); + let seq = tool.map(|i| self.tools[i].seq); if let Some(reply) = event.reply { if self.pending.is_some() { let _ = reply.send(false); + self.push(format!( + "Negado: {name} (outro pedido já aguardava revisão)" + )); + if let Some(i) = tool { + self.tools[i].approved = Some(false); + } } else { // Decisions are always reviewed in the Bee panel. self.native = false; - self.pending = Some((request, reply)); + self.pending = Some(Pending { + request, + reply, + tool: seq, + since: now, + }); } } } @@ -929,7 +1067,7 @@ fn draw_hidden(frame: &mut Frame, view: &HiddenView, mode: Mode, no_color: bool) .style(Style::default().bg(bg)), rows[3], ); - if let Some((request, _)) = &view.pending { + if let Some(Pending { request, .. }) = &view.pending { let modal = Rect::new( 2, 2, @@ -1114,9 +1252,9 @@ pub fn run_hidden( { let control = key.modifiers.contains(KeyModifiers::CONTROL); if control && key.code == KeyCode::Char('q') { - if let Some((_, reply)) = view.pending.take() { - let _ = reply.send(false); - view.push("Permissão negada ao sair."); + if view.pending.is_some() { + view.decide(false, false); + view.push("Negada ao sair."); } if view.quitting { // Already leaving; the 5 s fallback still applies. @@ -1148,9 +1286,9 @@ pub fn run_hidden( pty.write(&bytes)?; } } else if control && key.code == KeyCode::Char('c') { - if let Some((_, reply)) = view.pending.take() { - let _ = reply.send(false); - view.push("Permissão negada ao interromper."); + if view.pending.is_some() { + view.decide(false, false); + view.push("Negada ao interromper."); } pty.write(&[3])?; view.push("Interrupção enviada ao Claude."); @@ -1160,16 +1298,12 @@ pub fn run_hidden( .modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) { - let (request, reply) = view.pending.take().expect("checked pending"); let size = terminal.size().map_err(|e| e.to_string())?; - let allow = key.code == KeyCode::Char('y') - && approval_fits(&request, size.width, size.height); - let _ = reply.send(allow); - view.push(if allow { - "Permissão concedida uma vez." - } else { - "Permissão negada." - }); + let fits = view + .pending + .as_ref() + .is_some_and(|p| approval_fits(&p.request, size.width, size.height)); + view.decide(key.code == KeyCode::Char('y') && fits, false); } else if view.pending.is_none() && view.ready && !view.quitting { match key.code { KeyCode::Char(c) if !control => view.input.push(c), @@ -1212,9 +1346,7 @@ pub fn run_hidden( } } } - if let Some((_, reply)) = view.pending.take() { - let _ = reply.send(false); - } + view.decide(false, false); let code = status.unwrap_or(1); store.finish(&mut state, id, code)?; Ok((id, code)) @@ -1306,6 +1438,67 @@ mod tests { } #[cfg(unix)] #[test] + fn tool_results_state_who_allowed_them() { + let start = Instant::now(); + let id = Uuid::new_v4(); + let mut view = HiddenView::new(start); + let event = |fields: serde_json::Value| { + let mut value = fields; + value["session_id"] = id.to_string().into(); + BridgeEvent { value, reply: None } + }; + let bash = serde_json::json!({"command": "touch ok"}); + view.receive(event(serde_json::json!({"hook_event_name": "PreToolUse", "tool_name": "Read", "tool_use_id": "r1", "tool_input": {"file_path": "a.txt"}})), id, start); + view.receive(event(serde_json::json!({"hook_event_name": "PreToolUse", "tool_name": "Bash", "tool_use_id": "b1", "tool_input": bash})), id, start); + let (tx, rx) = mpsc::channel(); + let mut request = event( + serde_json::json!({"hook_event_name": "PermissionRequest", "tool_name": "Bash", "tool_input": bash}), + ); + request.reply = Some(tx); + view.receive(request, id, start); + // An older call finishing must not disturb the pending review. + view.receive(event(serde_json::json!({"hook_event_name": "PostToolUse", "tool_name": "Read", "tool_use_id": "r1"})), id, start); + assert!( + view.lines + .last() + .unwrap() + .contains("sem pedido de permissão") + ); + view.decide(true, false); + assert!(rx.recv().unwrap()); + view.receive(event(serde_json::json!({"hook_event_name": "PostToolUse", "tool_name": "Bash", "tool_use_id": "b1"})), id, start); + assert!(view.lines.last().unwrap().contains("aprovada por você")); + assert!(view.lines.iter().any(|l| l == "Ação: Bash · touch ok")); + } + #[cfg(unix)] + #[test] + fn expired_or_orphaned_requests_are_reported_as_denied() { + let start = Instant::now(); + let id = Uuid::new_v4(); + let mut view = HiddenView::new(start); + let request = |reply| BridgeEvent { + value: serde_json::json!({"hook_event_name": "PermissionRequest", "session_id": id.to_string(), "tool_name": "Bash", "tool_input": {"command": "rm x"}}), + reply: Some(reply), + }; + let (tx, rx) = mpsc::channel(); + view.receive(request(tx), id, start); + let (second, denied) = mpsc::channel(); + view.receive(request(second), id, start); + assert!(!denied.recv().unwrap()); + assert!(view.check_stall(start + PERMISSION_REVIEW, String::new)); + assert!(view.pending.is_none()); + assert!(view.lines.last().unwrap().contains("expirou")); + drop(rx); + // The hook already gave up: a late approval is reported as denied. + let (tx, rx) = mpsc::channel(); + view.receive(request(tx), id, start); + drop(rx); + view.decide(true, false); + assert!(view.lines.last().unwrap().contains("nada foi aprovado")); + assert!(PERMISSION_REVIEW < PERMISSION_TIMEOUT); + } + #[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())); From 01cc2adefc83269b4b8d67f1129ab0f0afdb21e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:54:53 +0000 Subject: [PATCH 3/7] test: drain PTY output while waiting for hidden Claude exit On macOS the PTY buffer is small: after Ctrl+Q the script stopped reading and the Bee blocked on its final redraw, timing out the untrusted-project scenario. Keep reading until the process exits. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MUyLMVv9GbqWRByEZ7LgEb --- scripts/check_claude_hidden_pty.py | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/scripts/check_claude_hidden_pty.py b/scripts/check_claude_hidden_pty.py index 0496637..9592c86 100644 --- a/scripts/check_claude_hidden_pty.py +++ b/scripts/check_claude_hidden_pty.py @@ -64,6 +64,21 @@ def hook(name, **fields): ''' +def drain_until_exit(master, proc, output, seconds): + """Keep reading while waiting: a full PTY buffer (small on macOS) would + block the Bee's final redraw and look like a hang.""" + deadline = time.monotonic() + seconds + while proc.poll() is None: + if time.monotonic() > deadline: + raise subprocess.TimeoutExpired(proc.args, seconds) + if select.select([master], [], [], 0.05)[0]: + try: + output.extend(os.read(master, 65536)) + except OSError: + pass + return proc.returncode + + def spawn(binary, project, state, env): master, slave = pty.openpty() fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', 24, 80, 0, 0)) @@ -97,7 +112,7 @@ def untrusted(binary, project, state, env, quit_while_blocked): if quit_while_blocked: started = time.monotonic() os.write(master, b'\x11') - proc.wait(timeout=4) + drain_until_exit(master, proc, output, 4) assert time.monotonic() - started < 4 assert proc.returncode == 4, proc.returncode else: @@ -108,7 +123,7 @@ def untrusted(binary, project, state, env, quit_while_blocked): os.write(master, b'\x0f') until('concluída'.encode()) os.write(master, b'\x11') # Idle prompt: Ctrl+Q types /exit. - proc.wait(timeout=10) + drain_until_exit(master, proc, output, 10) assert proc.returncode == 0, proc.returncode assert termios.tcgetattr(slave) == before assert not (state / 'claude-native.lock').exists() @@ -154,7 +169,7 @@ def until(token): if decision == 'y': until(b'aprovada') try: - proc.wait(timeout=20) + drain_until_exit(master, proc, output, 20) except subprocess.TimeoutExpired as exc: raise AssertionError(f'Bee did not exit after {decision!r}; output={output[-800:]!r}') from exc assert proc.returncode == 0, proc.returncode From dcf7813791d1e871ad0681ed865cabb1564ec9c3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 12:57:45 +0000 Subject: [PATCH 4/7] fix: keep hook events emitted right before Claude exits Non-permission hooks returned before the Bee queued their event, so a tool result or Stop sent just before Claude exited could be lost (seen on macOS CI as a missing "aprovada" line). The hook now waits up to 5 s for a queue acknowledgement, and the Bee drains and redraws before leaving. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MUyLMVv9GbqWRByEZ7LgEb --- docs/EXECUTION.md | 6 ++++++ src/tui/claude_native.rs | 23 ++++++++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/docs/EXECUTION.md b/docs/EXECUTION.md index edc45d9..a68eb9c 100644 --- a/docs/EXECUTION.md +++ b/docs/EXECUTION.md @@ -806,5 +806,11 @@ escopo da entrega atual nem a próxima tarefa aprovada. scripts PTY e `check_bundle.py` passaram localmente (Linux). Autorrevisão. - Limitação: a presença de `tool_use_id` no `PermissionRequest` real não foi confirmada; sem ele a associação usa nome e entrada exatos. +- CI macOS do PR #51 expôs duas falhas, corrigidas: o script PTY parava de ler + a saída ao aguardar a saída da Bee (buffer pequeno do macOS bloqueava o + redesenho final); e hooks sem permissão não esperavam confirmação, então + eventos emitidos logo antes de o Claude sair podiam se perder. Agora o hook + aguarda até 5 s a confirmação de enfileiramento e a Bee drena os eventos e + redesenha antes de encerrar. - Próximo: ensaio humano do item 52 para fechar #29/#30 e, em código, #31 (histórico e exportação da sessão Claude). diff --git a/src/tui/claude_native.rs b/src/tui/claude_native.rs index 1103189..4bb45d1 100644 --- a/src/tui/claude_native.rs +++ b/src/tui/claude_native.rs @@ -615,8 +615,10 @@ fn handle_hook_connection(mut stream: UnixStream, tx: mpsc::Sender) }; let _ = stream.write_all(decision.as_bytes()); let _ = stream.write_all(b"\n"); - } else { - let _ = tx.send(BridgeEvent { value, reply: None }); + } else if tx.send(BridgeEvent { value, reply: None }).is_ok() { + // Acknowledge only once queued, so events from a hook that ran just + // before Claude exits are still shown. + let _ = stream.write_all(b"\n"); } } @@ -641,13 +643,17 @@ pub fn hook_main() -> u8 { .and_then(|path| UnixStream::connect(path).ok()) .and_then(|mut stream| { stream - .set_read_timeout(Some(Duration::from_secs(95))) + .set_read_timeout(Some(if permission { + PERMISSION_TIMEOUT + Duration::from_secs(5) + } else { + Duration::from_secs(5) + })) .ok()?; stream.write_all(&bytes).ok()?; stream.write_all(b"\n").ok()?; + let mut reply = String::new(); + io::BufReader::new(stream).read_line(&mut reply).ok()?; if permission { - let mut reply = String::new(); - io::BufReader::new(stream).read_line(&mut reply).ok()?; serde_json::from_str::(&reply).ok()?; Some(reply) } else { @@ -1243,6 +1249,13 @@ pub fn run_hidden( status = Some(exit.exit_code()); } if status.is_some() { + // Hooks are acknowledged once queued; show the last ones. + while let Ok(event) = bridge.events.try_recv() { + view.receive(event, id, Instant::now()); + } + terminal + .draw(|f| draw_hidden(f, &view, mode, no_color)) + .map_err(|e| e.to_string())?; break; } if event::poll(Duration::from_millis(40)).map_err(|e| e.to_string())? { From d3ddf4bed18e619fdd48d88f9f8ef10facdef1ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:00:27 +0000 Subject: [PATCH 5/7] fix: link Bee approvals when the request precedes PreToolUse Each hook uses its own connection, so a PermissionRequest could be queued before its PreToolUse and the approved call was then reported as running without a request (seen on Ubuntu CI). A request without a matching call now records one that the later PreToolUse adopts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MUyLMVv9GbqWRByEZ7LgEb --- src/tui/claude_native.rs | 67 +++++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 15 deletions(-) diff --git a/src/tui/claude_native.rs b/src/tui/claude_native.rs index 4bb45d1..6b0fec9 100644 --- a/src/tui/claude_native.rs +++ b/src/tui/claude_native.rs @@ -728,6 +728,8 @@ struct ToolCall { input: serde_json::Value, /// `None`: Claude never asked; `Some(false)`: asked and not approved. approved: Option, + /// Seen as `PreToolUse`; false when the request arrived first. + pre: bool, } /// One-line hint of what a tool touches; the review panel shows everything. @@ -850,6 +852,21 @@ impl HiddenView { self.turn = Some(now); self.turn_hinted = false; } + fn add_tool(&mut self, value: &serde_json::Value, pre: bool) -> u64 { + if self.tools.len() >= 64 { + self.tools.remove(0); + } + self.next_tool += 1; + self.tools.push(ToolCall { + seq: self.next_tool, + id: value["tool_use_id"].as_str().map(String::from), + name: value["tool_name"].as_str().unwrap_or("desconhecida").into(), + input: value["tool_input"].clone(), + approved: None, + pre, + }); + self.next_tool + } /// Matches a hook to its `PreToolUse` by ID, else by name and input. fn find_tool(&self, value: &serde_json::Value) -> Option { if let Some(id) = value["tool_use_id"].as_str() @@ -913,17 +930,18 @@ impl HiddenView { "Ação: {name}{}", tool_summary(&event.value["tool_input"]) )); - if self.tools.len() >= 64 { - self.tools.remove(0); + // Hooks use separate connections; a request may come first. + if let Some(call) = self + .tools + .iter_mut() + .rev() + .find(|t| !t.pre && t.name == name && t.input == event.value["tool_input"]) + { + call.pre = true; + call.id = event.value["tool_use_id"].as_str().map(String::from); + } else { + self.add_tool(&event.value, true); } - self.next_tool += 1; - self.tools.push(ToolCall { - seq: self.next_tool, - id: event.value["tool_use_id"].as_str().map(String::from), - name: name.into(), - input: event.value["tool_input"].clone(), - approved: None, - }); } name @ ("PostToolUse" | "PostToolUseFailure") => { let tool = event.value["tool_name"].as_str().unwrap_or("ação"); @@ -951,16 +969,18 @@ impl HiddenView { .unwrap_or_else(|_| "".into()); let request = format!("Ferramenta: {name}\nEntrada completa:\n{details}"); self.push(format!("Claude pediu permissão: {name}")); - let tool = self.find_tool(&event.value); - let seq = tool.map(|i| self.tools[i].seq); + let seq = match self.find_tool(&event.value) { + Some(i) => self.tools[i].seq, + None => self.add_tool(&event.value, false), + }; if let Some(reply) = event.reply { if self.pending.is_some() { let _ = reply.send(false); self.push(format!( "Negado: {name} (outro pedido já aguardava revisão)" )); - if let Some(i) = tool { - self.tools[i].approved = Some(false); + if let Some(call) = self.tools.iter_mut().find(|t| t.seq == seq) { + call.approved = Some(false); } } else { // Decisions are always reviewed in the Bee panel. @@ -968,7 +988,7 @@ impl HiddenView { self.pending = Some(Pending { request, reply, - tool: seq, + tool: Some(seq), since: now, }); } @@ -1485,6 +1505,23 @@ mod tests { } #[cfg(unix)] #[test] + fn approval_links_even_when_the_request_arrives_first() { + let start = Instant::now(); + let id = Uuid::new_v4(); + let mut view = HiddenView::new(start); + let input = serde_json::json!({"command": "touch ok"}); + let (tx, rx) = mpsc::channel(); + view.receive(BridgeEvent { value: serde_json::json!({"hook_event_name": "PermissionRequest", "session_id": id.to_string(), "tool_name": "Bash", "tool_input": input}), reply: Some(tx) }, id, start); + view.decide(true, false); + assert!(rx.recv().unwrap()); + for name in ["PreToolUse", "PostToolUse"] { + view.receive(BridgeEvent { value: serde_json::json!({"hook_event_name": name, "session_id": id.to_string(), "tool_name": "Bash", "tool_use_id": "b1", "tool_input": input}), reply: None }, id, start); + } + assert!(view.lines.last().unwrap().contains("aprovada por você")); + assert!(view.tools.is_empty()); + } + #[cfg(unix)] + #[test] fn expired_or_orphaned_requests_are_reported_as_denied() { let start = Instant::now(); let id = Uuid::new_v4(); From e7baf844f6c819925a71721c9b24d4e61cdcfeb6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:03:04 +0000 Subject: [PATCH 6/7] fix: mark approvals of identical parallel calls as uncertain The official PermissionRequest payload has no tool_use_id. When a request matched several identical unanswered calls, the approval was attributed to the latest one, possibly the wrong call. Such calls now show an uncertain origin instead of a specific Bee approval. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MUyLMVv9GbqWRByEZ7LgEb --- docs/EXECUTION.md | 6 ++- docs/specs/workspace.md | 5 ++- src/tui/claude_native.rs | 97 +++++++++++++++++++++++++++++++++++++--- 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/docs/EXECUTION.md b/docs/EXECUTION.md index a68eb9c..d28aba0 100644 --- a/docs/EXECUTION.md +++ b/docs/EXECUTION.md @@ -804,8 +804,10 @@ escopo da entrega atual nem a próxima tarefa aprovada. revisão, expiração, pedido concorrente e aprovação tardia; teste PTY com CLI falsa emitindo leitura sem pedido e Bash com pedido. Checks canônicos, quatro scripts PTY e `check_bundle.py` passaram localmente (Linux). Autorrevisão. -- Limitação: a presença de `tool_use_id` no `PermissionRequest` real não foi - confirmada; sem ele a associação usa nome e entrada exatos. +- Limitação: segundo a referência oficial de hooks, `PermissionRequest` não traz + `tool_use_id`; a associação usa nome e entrada exatos. Após revisão do + Pullfrog no PR #51, chamadas idênticas paralelas sem decisão recebem "origem + incerta" em vez de uma aprovação possivelmente trocada. - CI macOS do PR #51 expôs duas falhas, corrigidas: o script PTY parava de ler a saída ao aguardar a saída da Bee (buffer pequeno do macOS bloqueava o redesenho final); e hooks sem permissão não esperavam confirmação, então diff --git a/docs/specs/workspace.md b/docs/specs/workspace.md index 6ddb7ab..4c70b40 100644 --- a/docs/specs/workspace.md +++ b/docs/specs/workspace.md @@ -41,7 +41,10 @@ Cada ação aparece com um resumo (`Ação: Bash · `). O resultado inf origem: "aprovada por você na Bee, uma vez" somente quando houve `PermissionRequest` e o hook recebeu `allow`; ações sem pedido aparecem como "sem pedido de permissão; liberada pelas regras ou modo do próprio Claude". A -associação usa `tool_use_id` e, na falta dele, nome e entrada da ferramenta. O +associação usa `tool_use_id` quando existe; o `PermissionRequest` oficial não o +traz, então vale nome e entrada da ferramenta. Se o pedido corresponder a mais de +uma chamada idêntica ainda sem decisão, os resultados dessas chamadas aparecem +com "origem incerta", sem atribuir a aprovação a nenhuma delas. O painel fecha em 85 s, antes do limite de 90 s do hook, e registra que o pedido expirou negado; um segundo pedido enquanto outro aguarda é negado e registrado. Se a resposta não chegar ao hook, a Bee informa que nada foi aprovado. diff --git a/src/tui/claude_native.rs b/src/tui/claude_native.rs index 6b0fec9..d66e85e 100644 --- a/src/tui/claude_native.rs +++ b/src/tui/claude_native.rs @@ -730,6 +730,9 @@ struct ToolCall { approved: Option, /// Seen as `PreToolUse`; false when the request arrived first. pre: bool, + /// A request without `tool_use_id` matched several identical calls, so + /// the Bee cannot tell which one it answered. + ambiguous: bool, } /// One-line hint of what a tool touches; the review panel shows everything. @@ -864,9 +867,40 @@ impl HiddenView { input: value["tool_input"].clone(), approved: None, pre, + ambiguous: false, }); self.next_tool } + /// Call answered by a `PermissionRequest`. The official payload carries + /// no `tool_use_id`, so identical unanswered calls make it ambiguous; + /// those are marked instead of guessing. Returns `None` in that case. + fn request_target(&mut self, value: &serde_json::Value) -> Option { + if value["tool_use_id"].is_string() + && let Some(i) = self.find_tool(value) + { + return Some(self.tools[i].seq); + } + let name = value["tool_name"].as_str().unwrap_or("desconhecida"); + let candidates: Vec = (0..self.tools.len()) + .filter(|&i| { + let t = &self.tools[i]; + t.approved.is_none() + && !t.ambiguous + && t.name == name + && t.input == value["tool_input"] + }) + .collect(); + match candidates[..] { + [] => Some(self.add_tool(value, false)), + [i] => Some(self.tools[i].seq), + _ => { + for i in candidates { + self.tools[i].ambiguous = true; + } + None + } + } + } /// Matches a hook to its `PreToolUse` by ID, else by name and input. fn find_tool(&self, value: &serde_json::Value) -> Option { if let Some(id) = value["tool_use_id"].as_str() @@ -946,6 +980,11 @@ impl HiddenView { name @ ("PostToolUse" | "PostToolUseFailure") => { let tool = event.value["tool_name"].as_str().unwrap_or("ação"); let origin = match self.find_tool(&event.value).map(|i| self.tools.remove(i)) { + Some(ToolCall { + ambiguous: true, .. + }) => { + "origem incerta: houve um pedido para uma chamada idêntica e não é possível dizer qual foi aprovada" + } Some(ToolCall { approved: Some(true), .. @@ -969,17 +1008,14 @@ impl HiddenView { .unwrap_or_else(|_| "".into()); let request = format!("Ferramenta: {name}\nEntrada completa:\n{details}"); self.push(format!("Claude pediu permissão: {name}")); - let seq = match self.find_tool(&event.value) { - Some(i) => self.tools[i].seq, - None => self.add_tool(&event.value, false), - }; + let seq = self.request_target(&event.value); if let Some(reply) = event.reply { if self.pending.is_some() { let _ = reply.send(false); self.push(format!( "Negado: {name} (outro pedido já aguardava revisão)" )); - if let Some(call) = self.tools.iter_mut().find(|t| t.seq == seq) { + if let Some(call) = self.tools.iter_mut().find(|t| Some(t.seq) == seq) { call.approved = Some(false); } } else { @@ -988,7 +1024,7 @@ impl HiddenView { self.pending = Some(Pending { request, reply, - tool: Some(seq), + tool: seq, since: now, }); } @@ -1522,6 +1558,55 @@ mod tests { } #[cfg(unix)] #[test] + fn identical_parallel_calls_never_claim_a_specific_approval() { + let start = Instant::now(); + let id = Uuid::new_v4(); + let mut view = HiddenView::new(start); + let input = serde_json::json!({"command": "make"}); + let hook = |name: &str, tool_id: Option<&str>| { + let mut value = serde_json::json!({"hook_event_name": name, "session_id": id.to_string(), "tool_name": "Bash", "tool_input": input}); + if let Some(tool_id) = tool_id { + value["tool_use_id"] = tool_id.into(); + } + value + }; + for tool_id in ["b1", "b2"] { + view.receive( + BridgeEvent { + value: hook("PreToolUse", Some(tool_id)), + reply: None, + }, + id, + start, + ); + } + let (tx, rx) = mpsc::channel(); + view.receive( + BridgeEvent { + value: hook("PermissionRequest", None), + reply: Some(tx), + }, + id, + start, + ); + view.decide(true, false); + assert!(rx.recv().unwrap()); + for tool_id in ["b1", "b2"] { + view.receive( + BridgeEvent { + value: hook("PostToolUse", Some(tool_id)), + reply: None, + }, + id, + start, + ); + let line = view.lines.last().unwrap(); + assert!(line.contains("origem incerta"), "{line}"); + assert!(!line.contains("aprovada por você")); + } + } + #[cfg(unix)] + #[test] fn expired_or_orphaned_requests_are_reported_as_denied() { let start = Instant::now(); let id = Uuid::new_v4(); From fca3fc054fe67011b5c47a7a46a3a2da86e579ae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 28 Sep 2026 13:09:15 +0000 Subject: [PATCH 7/7] fix: keep origin uncertain when a request precedes its tool call A PermissionRequest carries no tool_use_id. If it arrived before any matching PreToolUse, the first identical call adopted the approval and a later identical call showed "no request", possibly inverting provenance. Such requests and any identical call seen while they are open now show an uncertain origin; tool records are dropped at the end of each turn. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MUyLMVv9GbqWRByEZ7LgEb --- docs/specs/workspace.md | 4 +++- src/tui/claude_native.rs | 50 ++++++++++++++++++++++++++++++++++------ 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/specs/workspace.md b/docs/specs/workspace.md index 4c70b40..b13d0eb 100644 --- a/docs/specs/workspace.md +++ b/docs/specs/workspace.md @@ -44,7 +44,9 @@ origem: "aprovada por você na Bee, uma vez" somente quando houve associação usa `tool_use_id` quando existe; o `PermissionRequest` oficial não o traz, então vale nome e entrada da ferramenta. Se o pedido corresponder a mais de uma chamada idêntica ainda sem decisão, os resultados dessas chamadas aparecem -com "origem incerta", sem atribuir a aprovação a nenhuma delas. O +com "origem incerta", sem atribuir a aprovação a nenhuma delas; o mesmo vale +quando o pedido chega antes do `PreToolUse` correspondente. Os registros de +ferramentas são descartados ao fim de cada turno. O painel fecha em 85 s, antes do limite de 90 s do hook, e registra que o pedido expirou negado; um segundo pedido enquanto outro aguarda é negado e registrado. Se a resposta não chegar ao hook, a Bee informa que nada foi aprovado. diff --git a/src/tui/claude_native.rs b/src/tui/claude_native.rs index d66e85e..fb7df44 100644 --- a/src/tui/claude_native.rs +++ b/src/tui/claude_native.rs @@ -730,6 +730,8 @@ struct ToolCall { approved: Option, /// Seen as `PreToolUse`; false when the request arrived first. pre: bool, + /// Created by a request that arrived before any matching call. + early: bool, /// A request without `tool_use_id` matched several identical calls, so /// the Bee cannot tell which one it answered. ambiguous: bool, @@ -867,6 +869,7 @@ impl HiddenView { input: value["tool_input"].clone(), approved: None, pre, + early: !pre, ambiguous: false, }); self.next_tool @@ -891,7 +894,15 @@ impl HiddenView { }) .collect(); match candidates[..] { - [] => Some(self.add_tool(value, false)), + [] => { + // A request before any matching PreToolUse cannot tell which + // identical call will adopt it, so its origin stays uncertain. + let seq = self.add_tool(value, false); + if let Some(call) = self.tools.last_mut() { + call.ambiguous = true; + } + Some(seq) + } [i] => Some(self.tools[i].seq), _ => { for i in candidates { @@ -965,16 +976,25 @@ impl HiddenView { tool_summary(&event.value["tool_input"]) )); // Hooks use separate connections; a request may come first. + // Any identical call seen after such a request may be the one + // that asked, so its origin is uncertain too. + let input = &event.value["tool_input"]; + let uncertain = self + .tools + .iter() + .any(|t| t.early && t.name == name && t.input == *input); if let Some(call) = self .tools .iter_mut() - .rev() - .find(|t| !t.pre && t.name == name && t.input == event.value["tool_input"]) + .find(|t| !t.pre && t.name == name && t.input == *input) { call.pre = true; call.id = event.value["tool_use_id"].as_str().map(String::from); } else { self.add_tool(&event.value, true); + if let Some(call) = self.tools.last_mut() { + call.ambiguous |= uncertain; + } } } name @ ("PostToolUse" | "PostToolUseFailure") => { @@ -1032,10 +1052,17 @@ impl HiddenView { } "Stop" => { self.turn = None; + // Calls of a finished turn get no more hooks. + if self.pending.is_none() { + self.tools.clear(); + } self.push("Turno concluído."); } "StopFailure" => { self.turn = None; + if self.pending.is_none() { + self.tools.clear(); + } self.push("Claude encerrou o turno com erro."); } _ => {} @@ -1541,7 +1568,7 @@ mod tests { } #[cfg(unix)] #[test] - fn approval_links_even_when_the_request_arrives_first() { + fn a_request_before_its_call_is_reported_as_uncertain() { let start = Instant::now(); let id = Uuid::new_v4(); let mut view = HiddenView::new(start); @@ -1550,10 +1577,19 @@ mod tests { view.receive(BridgeEvent { value: serde_json::json!({"hook_event_name": "PermissionRequest", "session_id": id.to_string(), "tool_name": "Bash", "tool_input": input}), reply: Some(tx) }, id, start); view.decide(true, false); assert!(rx.recv().unwrap()); - for name in ["PreToolUse", "PostToolUse"] { - view.receive(BridgeEvent { value: serde_json::json!({"hook_event_name": name, "session_id": id.to_string(), "tool_name": "Bash", "tool_use_id": "b1", "tool_input": input}), reply: None }, id, start); + // Two identical calls; either could be the one that asked. + for (name, tool_id) in [ + ("PreToolUse", "b2"), + ("PreToolUse", "b1"), + ("PostToolUse", "b2"), + ("PostToolUse", "b1"), + ] { + view.receive(BridgeEvent { value: serde_json::json!({"hook_event_name": name, "session_id": id.to_string(), "tool_name": "Bash", "tool_use_id": tool_id, "tool_input": input}), reply: None }, id, start); + if name == "PostToolUse" { + let line = view.lines.last().unwrap(); + assert!(line.contains("origem incerta"), "{line}"); + } } - assert!(view.lines.last().unwrap().contains("aprovada por você")); assert!(view.tools.is_empty()); } #[cfg(unix)]