Skip to content

Feat/cli raw attach - #17

Merged
R0MADEV merged 88 commits into
mainfrom
feat/cli-raw-attach
Aug 26, 2026
Merged

Feat/cli raw attach#17
R0MADEV merged 88 commits into
mainfrom
feat/cli-raw-attach

Conversation

@R0MADEV

@R0MADEV R0MADEV commented Aug 25, 2026

Copy link
Copy Markdown
Owner

No description provided.

R0MADEV and others added 30 commits August 23, 2026 09:38
A Finder/Launchpad-launched macOS app gets launchd's bare PATH
(/usr/bin:/bin:/usr/sbin:/sbin), so Command::new("tailscale") never
found the CLI even when it was installed and working from a terminal.
tailscale_ip()/tailscale_detect() silently fell back to the LAN IP, so
toggling "Usar Tailscale" never changed the shown URL. Check the known
install locations (/usr/local/bin, /opt/homebrew/bin) before falling
back to a bare PATH lookup. Verified live against the daemon: remote
auto-start and the stop/start toggle cycle both now resolve to the
Tailscale IP.
Replaced bento-cli's line-buffered attach() with a real interactive
terminal session on unix: raw termios (no local echo/canonical mode, so
Ctrl-C/Ctrl-D pass through to the remote process instead of the local
shell), live window-resize forwarding via SIGWINCH, and UTF-8-safe byte
streaming instead of newline-delimited lines. Falls back to the old
line-based path when stdin isn't a real tty or on non-unix.

Verified against a real pty harness: single echo (no local double-echo),
SIGWINCH correctly reaches the remote PTY, Ctrl-C reaches the remote
process without killing the local CLI, and termios is restored both on
normal remote exit and on SIGTERM. Also fixed a real hang found during
that verification: tokio's stdin reader runs on a blocking OS thread that
the runtime waits for on shutdown, so returning normally left the process
stuck until the next keystroke — now exits explicitly once the terminal
is restored, matching the daemon's own daemon.shutdown pattern.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extracted the HTTP /api/review/branches handler's core logic into a
shared list_branches() fn so it's reusable without axum, then exposed it
on the daemon's lightweight IPC socket as review.branches (no token/HTTP
server needed, same trust level as the existing terminal.* commands) and
added `bento review branches [--cwd <dir>]` as its CLI entry point.

First iteration of Fase 2 (CLI parity for the review/PR workflow) — one
command at a time, starting with the simplest. Verified output matches
raw `git branch --sort=-committerdate` exactly, and confirmed the
existing HTTP handler still works unchanged after the extraction.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same pattern as review.branches: extracted the /api/review/prs handler's
gh_cmd call into a shared list_prs() fn and exposed it as review.prs on
the IPC socket, with `bento review prs [--cwd <dir>]` as its CLI entry
point. No unit test needed — list_prs is a one-line wrapper around
gh_cmd with no branching logic of its own, matching this file's existing
precedent (only pure validation fns like is_safe_branch are tested).

Verified output matches `gh pr list --json ...` byte-for-byte.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extracted the name-status/numstat merge from /api/review/files into a
pure, TDD-covered build_file_list() (4 tests: path matching, renames
with old_path, missing-numstat defaults, untracked-file appends), wrapped
by list_files() which also carries the is_safe_branch() flag-injection
guard so IPC callers get the same protection the HTTP handler always
had, not just axum ones. Wired up as review.files on the IPC socket and
`bento review files [--cwd <dir>] [--base <ref>]` on the CLI.

Verified byte-for-byte against `git diff --name-status`/`--numstat` on
this branch's real diff vs main, and confirmed the HTTP handler is
unchanged. Also added workers/.gitignore for .wrangler/ — it was leaking
into every diff as untracked cruft from earlier local TURN testing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Same extraction pattern as prs/branches (thin gh_cmd wrappers, no unit
test needed — no branching logic of their own). Added `bento review pr
diff <n>` and `bento review pr comments <n>`.

Also fixed a real bug found while verifying pr diff for real: it was
printing the diff as a JSON-escaped string (literal \n, wrapped in
quotes) instead of raw text, and panicked with "Broken pipe" when piped
through `head`/`less` (extremely likely for a diff-sized command). Added
print_text(), which writes raw bytes and exits quietly on a closed pipe
instead of panicking — matches how well-behaved Unix text tools behave.

Verified both commands byte-for-byte against `gh pr diff`/`gh pr view
--json` directly, and confirmed the HTTP handlers are unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extracted add_comment/update_comment/delete_comment/submit_review from
their HTTP handlers, preserving the comment_belongs_to_pr ownership
check (now shared via ensure_comment_belongs_to_pr) so IPC callers can't
edit/delete a comment across PRs any more than the HTTP handlers could.
Added `bento review pr comment/comment-update/comment-delete/submit`.

Verified live against the real PR #10: posted a clearly-labeled test
comment, edited it, deleted it, and confirmed via `gh api` at each step
(including the final 404 after delete). `submit` (approve/request
changes) was deliberately NOT tested live — unlike a comment, a
submitted review isn't cleanly reversible, so that needs separate
explicit sign-off before touching a real PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Extracted ask_handler's checkpoint-lookup + resume/fallback decision into
a shared ask() fn (previously inline in the axum handler), reused by both
the HTTP SSE endpoint and a new review.ask IPC command. The IPC command
follows terminal.subscribe's established pattern: an immediate ack, then
push events (review.output per chunk, review.done at the end) on the same
connection — no new streaming mechanism needed.

Added `bento review ask <question> [--cwd] [--base] [--agent]` with a new
stream_review() helper in bento-cli that prints chunks live as they
arrive instead of buffering the whole response.

Verified the cheap path (no saved checkpoint for the given base) end to
end on both CLI and HTTP — identical error, no agent spawned. The path
that resumes/runs a real claude/codex/opencode process needs the user's
go-ahead before testing live (real cost, real time), so it's implemented
and compiles clean but not yet exercised for real.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found by actually running the live agent path for review.ask (a real
claude invocation against a saved checkpoint): the response text came
back correct, but the literal string "[DONE]" was appended right after
it with no separator, since ask()'s own end-of-stream sentinel chunk was
being forwarded as a normal review.output event before the IPC layer's
own review.done event fired. The IPC completion signal already exists
independently, so the sentinel text itself is now filtered out instead
of leaking into what the user sees.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Running `bento` with no arguments now opens a navigable panel (ratatui +
crossterm) listing terminals/agents, instead of printing help. Selecting
one and pressing Enter attaches inline; when that session ends, control
returns to the list — the panel stays running, unlike the standalone
`bento attach` (which intentionally hard-exits, see its own comment).

This works because crossterm's EventStream (used for all panel input,
list navigation and attached-session keystrokes alike) has none of the
blocking-OS-thread problem tokio::io::stdin() has: its reader thread
blocks on an interruptible mio poll, not an uninterruptible read(), so
Drop can always unwind it promptly — confirmed by reading crossterm's
own source before relying on it. Since crossterm delivers structured
KeyEvents instead of raw bytes, added key_event_to_bytes() (TDD-covered:
plain chars, Enter, Backspace, arrows, Ctrl+letter) to translate back to
what a raw tty would have produced.

Alternate-screen handling required care: a remote program's own alt-
screen use (vim, htop) shares one non-ref-counted flag with the panel's,
so the panel leaves its own alt-screen before attaching and reasserts it
after, instead of assuming nested enter/exit is harmless (it isn't —
confirmed against real terminal-emulator semantics before shipping this).

Verified live end-to-end against a real vim session inside a pty
harness, which caught two real bugs the type checker couldn't:
1. `Terminal::clear()` (called to force a repaint on returning to the
   list) queries the cursor position via a synchronous DSR read on
   stdin — racing the EventStream's own concurrent reader on the same
   fd and hanging until crossterm's read timeout, observed live as the
   panel dying with "cursor position could not be read within a normal
   duration" right after a real vim session ended. Fixed by using
   `Terminal::resize()` to the current size instead — same "next draw
   repaints everything" effect via a pure ANSI clear write, with no read
   involved (confirmed by reading ratatui's own source: for a Fullscreen
   viewport, resize() never touches cursor position at all).
2. (caught and fixed earlier in the same pass) confirmed raw mode is
   restored and the process exits cleanly on quit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Exposes the existing multi-agent/batched review pipeline (already used by
the HTTP SSE endpoint) as `bento review run`, and moves base/branch
validation into run_review itself so the IPC caller gets the same
protection the HTTP handler already had. Also extracts the duplicated
review-stream forwarding between review.ask and review.run into a shared
spawn_review_stream helper, and filters the protocol's batch/synthesis/
session sentinels out of the CLI's printed report (into stderr) instead of
leaking them as literal text — both issues were caught by running the new
command against its own diff as a live test.
Tab from the terminals list into a Review view: browse files changed vs
main, run a full AI review inline with live streaming output (scrollable),
and ask follow-up questions — all through the existing review.* IPC
commands, no new backend surface beyond one addition (see below).

Split tui.rs into tui/{mod,terminals,review}.rs (mod.rs owns the Mode enum
and main loop; terminals.rs is the pre-existing list/attach code, moved
unchanged) — matches this branch's established per-concern-submodule
pattern. Extracted the review.run/review.ask streaming plumbing shared
between the one-shot CLI and the new tab into review_stream.rs (DRY: one
connect/ack/classify implementation instead of two).

Live pty testing against a real agent surfaced a real gap: review.run
never persisted a checkpoint, so a follow-up review.ask always failed with
"no hay análisis guardado" for any review started from the TUI (checkpoints
were previously only ever written by the web panel's own JS). Fixed by
extracting the existing PUT /api/review/checkpoint save logic into
save_checkpoint() and exposing it as a new review.checkpoint_save IPC
command, called once when a run finishes — verified end-to-end (run, then
a real follow-up ask resuming that session, no error).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… tab

Review tab was scoped down too far for a first pass (base fixed to main,
no PR browsing, no way to stop a running review) — the goal is parity with
Bento.app, not a reduced CLI subset, so filling these in now rather than
treating them as a deferred v2. All reuse existing review.* IPC commands,
no new backend surface for browsing:

- b: pick any local branch as the review base (review.branches)
- p: browse open PRs, Enter shows diff + reviews/comments (review.prs,
  review.pr_diff, review.pr_comments)
- g: cycle the agent (claude/codex/opencode) used for run/ask
- c: cancel an in-flight run/ask

Cancel surfaced a real gap: aborting the TUI's own streaming task only
stops the client from listening — the daemon-side review.run/review.ask
task (and the real agent subprocess it spawns) kept running unseen and
unbilled-for-nothing. Fixed at both ends: the daemon now tracks each
connection's review task and aborts it when the connection closes (a
cancel just drops the TUI's socket), and every agent Command::new(...) in
review/{mod,ask}.rs now sets kill_on_drop(true) so aborting the task
actually kills the process (tokio doesn't do this by default). Verified
live: started a real review, confirmed the claude subprocess was running
via ps, cancelled, confirmed it was gone.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…w-quality fix

Review tab: Enter on a file now shows its diff (r runs the full review,
moved off Enter); PR detail gained a (comment), y/n/m (approve/request
changes/comment-only review). All reuse existing review.* IPC commands
except review.file, a new thin wrapper around the same file_diff logic
the web panel's /api/review/file already used.

While comparing against the desktop app's real "AI Review" to check for
parity gaps, found that the daemon's review pipeline (used by bento
review run/ask and now the TUI) was never actually equivalent in quality,
not just interaction surface:

- build_review_prompt was a simpler, unstructured prompt with no severity
  levels or verdict — only build_synthesis_prompt had been ported from
  techReview.ts, and it already referenced a "Veredicto, Resumen,
  ## Hallazgos" format the per-batch prompt never produced. Ported
  buildReviewPrompt properly: same 8 categories, severity scale
  (critical/high/medium/low), verdict (pass/needs_review/fail), and the
  "read the final state, not just the diff" rule. Lexis context isn't
  portable (it's an interactive-agent MCP tool), so the prompt instead
  leans on the agent's own Read/Grep access to the worktree.
- That only works if the agent actually runs in the project's directory —
  run_claude_collecting/resume_claude (the default "claude" agent path)
  never received `cwd` at all, so every claude-agent review ran with
  whatever directory the daemon process happened to launch from, not the
  project being reviewed. Added .current_dir(cwd) to both. codex/opencode
  were already correct.

Verified live: real review against the new prompt shows the format
change directly (**Veredicto:** fail, ### [high] file:line — title,
**Arreglo:** ...) where the old prompt produced generic unlabeled
sections.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…resume

Closes the remaining gaps against the desktop Review panel:

- x: toggle "compare agents" in the Files view — runs claude+codex+opencode
  together and synthesizes their reports (review.run already supported a
  comma-separated agents list; the TUI just never exposed more than one).
- c: edit the author context injected into the review prompt, mirroring
  desktop's "Contexto para la review" textarea (previously always sent "").
- h: browse saved checkpoints (one per base branch reviewed) with relative
  save time, open one to view without re-running, d to delete. New
  review.checkpoints/review.checkpoint_get/review.checkpoint_delete IPC
  commands wrap the same list/get/delete logic the HTTP checkpoint
  endpoints already used (extracted into list_checkpoint_metas/
  get_checkpoint/delete_checkpoint so both transports share one
  implementation).
- Fixed review.ask never actually resuming a codex session: resume_agent
  special-cased only "opencode" and silently fell back to resume_claude
  for codex, which can't resume a codex session id. Added resume_codex,
  args order matching the desktop's own confirmed codex resume invocation
  (src-tauri/src/agent/mod.rs's build_agent_invocation, verified against
  its existing test codex_resume_puts_session_and_prompt_last).

Verified live: compare toggle and context both reflect in the Files title,
checkpoint history lists/opens/deletes correctly against a real checkpoint
file. Did not live-test the codex resume fix specifically — round-tripping
it end-to-end needs two more real paid agent calls (a codex review, then a
follow-up ask) for a narrow, low-risk fix whose only change is call
routing plus an arg list already verified byte-for-byte against an
existing tested implementation elsewhere in the repo.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Replaced the previous "one full-screen view at a time" navigation
(separate Files/Branches/Prs screens) with a persistent two-pane layout
matching the desktop Tech Review panel: a sidebar (Base/Agent/Comparar
status + Ramas/PRs/Historial tabs) on the left, changed files with
per-file "revisado" checkboxes and a status filter (All/Added/Modified/
Deleted) on the right. Left/Right arrows move focus between panes; b/p/h
switch the sidebar tab in place instead of navigating to a new screen.
File diff, PR detail, and running/loaded review output stay as full-screen
drill-downs (Esc returns to the split view).

Fixed a real bug found live: the sidebar defaulted to the Ramas tab but
never fetched branches on entry (only files), so the tab looked selected
but stayed empty until `b` was pressed. Extracted the fetch into
fetch_branches() and added ReviewState::enter(), called once when
Tab-ing in from the terminals list, that populates both files and the
default sidebar tab together.

Verified live: branches/files/PRs all populate correctly on entry and tab
switch, checkbox/filter/counter mechanics work, clean exit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The Review tab was stuck on the cwd `bento` was launched from. It now keeps
its own `cwd` in `ReviewState` and a "Proyectos" sidebar tab (`o`) lists the
directories of every open terminal/agent — the same source `/api/projects`
already used for the phone remote, now shared through a new `projects.list`
IPC command instead of a second copy.

Also fixes two things the picker surfaced: daemon errors were swallowed into
empty lists (an old daemon that doesn't know a `review.*` command looked like
"this project has no branches"), and the sidebar header had a fixed height
that clipped the agent/compare lines once "Proyecto" was added.
The review prompt existed twice — `src/core/ai/techReview.ts` for the desktop
app and `remote/review/prompt.rs` for the daemon (phone remote + CLI) — and
the two had already drifted: the desktop sent no project/base line, escaped
the diff through JSON.stringify and lacked the trailing verdict kick-off that
stops the agent from writing a preamble.

Both now call one implementation in `daemon/bento-review`, a crate with no
tauri/axum dependency so it stays usable from every transport.
`ReviewPromptInput` covers both shapes: project/base/diff always, plus the
optional blocks each caller can gather (author context from the daemon, Lexis
context and inlined files from the desktop). The desktop reaches it through
two new commands, `review_build_prompt` and `review_build_synthesis_prompt`.

Prompt-text assertions moved to the crate's tests; the vitest ones now only
check the frontend passes what it gathered.
`is_safe_branch` existed twice — once in `src-tauri/src/git/mod.rs` for the
desktop's whole git panel, once in the daemon's review module — so hardening
it in one place never reached the other. It now lives in `bento-review::vcs`
together with `is_safe_relative_path`, the git/gh command runners and the
changed-file/branch/diff functions built on them (`parse.rs` moved along as
`bento_review::diff`, since `build_file_list` needs it).

The shared runners resolve `git`/`gh` through a login shell when they are not
on PATH, cached per process. That was a desktop-only workaround for macOS GUI
apps not inheriting the shell PATH, and it only covered `git` — a `gh`
installed via Homebrew was invisible to the packaged app.
The desktop app and the daemon each had their own `gh` layer, and the overlap
was not as clean as it looked: both had "update comment" and "delete comment"
but on different endpoints — the desktop edits inline *review* comments
(`pulls/comments/{id}`), the daemon edits *issue* comments (the PR
conversation). `bento-review::pr` now holds both families, named for what
they are, plus list/diff/discussion/submit and the inline create/reply.

Two behaviour changes fall out of it:

- The desktop's edit/delete/reply now check the comment belongs to the PR
  being reviewed, which only the daemon did. A comment id from another PR in
  the same repo was previously edited just as happily, so the commands (and
  `ReviewCommentActions`) take the PR number now.
- `review.pr_comments` returns the REST payload (`user.login`,
  `created_at`/`submitted_at`) instead of `gh pr view --json`'s. Only the REST
  one carries the numeric comment ids that editing and deleting need — the
  daemon exposed both operations while handing clients a payload with no ids
  to call them with. The phone UI and the TUI formatter follow the new shape.

`git/pr.rs` drops from 445 to 200 lines, with one `blocking` helper instead of
the same spawn_blocking + double map_err in every command.
The sidebar is ~40 columns, so clipping the project line from the right left
"/Users/romangomez/Desktop/roma" on screen — the half that says nothing about
which project is loaded, and identical for every project under the same tree.
It now drops leading segments instead: "…/roma/bento/daemon".
Each codebase drove claude/codex/opencode its own way and each knew things
the other didn't. `bento-review::agents` is the union: one argv builder, one
stateful parser per agent, one run loop.

What each side gains:

- The daemon (phone remote + CLI) now runs reviews read-only. It launched
  `claude -p` with default tools, so a review from the TUI or the phone could
  edit the code it was reviewing, while the desktop passed
  `--allowedTools Read,Glob,Grep`. Same for opencode's `--agent plan`.
- The daemon resolves the agent binary outside PATH and caps line/total output
  size, both of which only the desktop did.
- The desktop reads Claude's streaming deltas and Codex's `session_meta`,
  which only the daemon did, and resolves built-in agents instead of running
  them by bare name.

Claude can report the same text twice — as deltas and again in the final
assistant message — so the parser is stateful and drops the final message once
deltas have been seen.

Six near-identical spawn+parse loops in the daemon (three for reviews, three
for follow-up questions) collapse into one call: review/mod.rs goes from 747
to 526 lines and ask.rs from 257 to 131. The desktop's five adapter files are
gone; its tests moved to the crate, where they now cover both dialects.
review.rs had grown to 927 lines of implementation doing four unrelated jobs
at once, and I had just made it worse by adding the project picker to it. The
desktop panel it mirrors was already split this way (ReviewPanel /
ReviewDiffView / reviewDataLoader / reviewFormat), so this follows the
convention the repo already had.

  mod.rs     358  state and the review.* IPC calls
  input.rs   277  one key handler per view
  draw.rs    235  rendering
  format.rs  164  pure helpers, with their tests next to them

No behaviour change: same tests, plus a smoke run of the real TUI against a
live daemon (Tab → p still lists PR #10).
run_review lived in the daemon and spoke its transport's wire format
directly, emitting "[BATCH:1/2]", "[SYNTHESIS]" and "[ERROR] …" strings from
inside the orchestration. The engine now emits a typed ReviewEvent and the
daemon translates those into the same markers, so the phone, the CLI and the
TUI see exactly the protocol they saw before.

Splitting it that way made the orchestration testable for the first time:

- `plan_stages` is pure — one stage per agent when comparing, one per batch
  of the diff otherwise — so what each agent gets asked is now covered by
  tests instead of being implied by a 120-line function.
- The agent call is behind an `AgentRunner` trait, so the batching order, the
  synthesis trigger and the failure paths are tested with a fake runner
  rather than by launching claude.

Gathering the diff moved to `vcs::review_diff` (it was raw tokio Commands
inline), and `parse_agents` came along with the engine. review/mod.rs is down
to 385 lines, from 1083 when this started.
The desktop kept finished reviews in the browser's localStorage while the
daemon kept them in ~/.bento/review-checkpoints, so a review run in the app
did not exist for the TUI or the phone — and vice versa. Both now write the
same store, which lives in `bento_review::checkpoints`; the daemon keeps only
its HTTP handlers.

The record gained two optional fields the desktop had and the daemon didn't
(`branch` and `commit`, so a stale review can be spotted), and a checkpoint
written before they existed still loads — there is a test for exactly that,
and the daemon read a desktop-shaped record end to end.

Reviews saved in the old localStorage are not lost: `loadReviewCheckpoint`
reads the shared store first and falls back to the browser copy.
…eir folders

Two things the file audit turned up.

The `invoke` calls I added to `src/core/ai/techReview.ts` this morning broke
the rule the rest of the codebase follows: `core/` holds logic, the panels and
adapters talk to Tauri. `buildReviewPrompt`/`buildReviewSynthesisPrompt` moved
to `panels/review/reviewPrompts.ts` and `loadReviewCheckpoint` to
`panels/review/reviewCheckpoints.ts`; techReview.ts keeps what is actually TS
logic (context provider, review document, checkpoint parsing) and no longer
imports Tauri at all.

And eleven test files sat outside the folder mirroring their source — tasks,
db, terminal and tv panel tests flat in tests/panels/, the command palette
test under tests/core/, the i18n catalog test under tests/core/. All 158 test
files now mirror their source; 1253 tests still pass.
src/ui was 15 flat files where nothing said which ones render and which are
utilities. Now the root holds only what draws something (agentStatusBar,
aiChat, askAi, collapsibleSidebar, commandPalette, contextMenu, panelLauncher,
resizablePane, windowControls), helpers/ the pure ones (icons, platform,
zoom), and state/ the persisted UI state (activeProject,
decorationsPreference). Their tests moved with them.

aiKeys.ts left ui/ entirely: it reads and writes AI keys in the Vault through
Tauri and has no UI in it, so it belongs with the other Tauri-backed access in
adapters/.

Also folded src-tauri/src/agent/tests.rs into its module as an inline
`#[cfg(test)] mod tests` — it was the only separate test file among 51.
A file never gets big in one commit — it grows a bit at a time and nobody
notices, which is exactly how tui/review.rs reached 927 lines with me adding
to it this morning. `npm run check:size` (now part of ci:local) fails when a
file crosses 400 lines of implementation.

The 17 files already over it are recorded in file-size-baseline.json with
their current size: they don't block, but they can't grow either. Splitting
one means lowering its number or deleting the entry.

Implementation lines exclude blanks, comments and — in Rust — the inline
`#[cfg(test)]` module, so writing tests never counts against you.
The mark lived in a HashSet that refresh_files() cleared, so it vanished on
every reload — useless for the one thing it exists for, working through a big
diff over time. It is now filed per project and ref under
~/.bento/review-viewed, next to the checkpoints, through two IPC commands
(review.viewed / review.viewed_set).

`bento_review::store` came out of it: checkpoints already had the FNV-1a
naming for a (cwd, ref) pair inline and the second store would have copied it.
A corrupt or unreadable file reads as "nothing reviewed" — the cost of being
wrong is re-reading a diff.

Adding the two commands pushed ipc.rs past the size check added a commit ago,
so the `review.*` arms moved to ipc/review.rs: 290 + 196 lines instead of 407
in one match. The check earned its keep on its first day.

Verified live: marked a file, restarted the TUI, still 1/119.
R0MADEV added 29 commits August 25, 2026 08:19
`/` opens a search box in whatever you are looking at: it filters the branch
list in the sidebar, and the lines of a file diff, a PR or a finished review.
In a thousand-line diff that is the difference between reviewing and giving
up.

Both are pure functions on the state (`visible_branches`, `filtered`) and both
have tests — including the case that matters, a search matching nothing
leaving an empty list rather than the whole one.
Reviewing a branch created a worktree, mirrored the uncommitted work into it
and made it read-only while the agent looked — but only in the desktop app. A
review from the TUI or the phone ran against the live working tree: the agent
could touch the code you are writing, and what it was reviewing moved under
its feet.

The worktree module moved from src-tauri into bento-review (the app now
re-exports it for its commands) and the engine uses it: when a branch is given
it prepares the isolated copy, runs there, and releases it afterwards
whatever happened.

`uuid` did not come along — a worktree's unique suffix is now nanoseconds plus
the pid, which is enough for "two reviews of the same branch at once" and
saves the crate a dependency.
…he agent used

Two things the desktop had and the shared engine did not, so a review from the
TUI or the phone was worse in ways that matter:

- **Retry.** A rate limit or a dropped connection killed the whole stage.
  It now retries once. A timeout is not retried — it means the work did not
  fit the window, so repeating it just burns another one. `agents::is_retryable`
  is the same rule as the desktop's `isRetryableReviewError`, with its tests.
- **Tool use.** The agents module already parsed tool events and the runner
  threw them away. They now reach the caller as `ReviewEvent::Tool`, which is
  the only thing you can see while an agent thinks — and the evidence of what
  it looked at before saying what it says. The TUI shows them as progress, the
  phone in its header, and neither lets them into the report text.

This is the other half of "one engine": rather than ripping out the desktop's
orchestration and losing its retries, evidence and parallelism, the engine
grows to match it. What is still missing before the desktop can switch:
parallel multi-agent runs and the Lexis context.
…ritten exposure policy

`tasks.list` over IPC and `GET /api/tasks` answer "which branch is each task
on, and on what commit" — the first thing you want to know from outside the
app. Read-only: creating, deleting or rebasing a worktree stays in the app,
where there are confirmations, history and backups.

The worktree parsing moved to `bento_review::worktrees` with its tests,
including the detached case that has no branch.

docs/remote-exposure.md writes down what is exposed and what is not — vault,
databases and task writes, each with the reason — plus the three questions to
answer before adding anything to that surface. The list existed only in my
head and in this conversation, which is not where it should live.

Also fixed the unique suffix for isolated worktrees: nanoseconds plus pid
collided when two ran in the same nanosecond (a test caught it), so it now
carries a per-process counter too.
`bento tasks` lists each task (worktree) with its branch, what it has
uncommitted and how far it has drifted from its upstream. `new`, `rm`,
`commit`, `sync` and `push` do the rest. All of it goes through
`bento_review::tasks`, so the logic is the crate's, not the CLI's.

  list    worktrees + `status --porcelain` + ahead/behind, per task
  create  a branch in its own worktree next to the repo
  remove  refuses to drop unsaved work unless you say --force
  commit  stages everything and commits, --amend rewrites the last one
  sync    fetch + rebase --autostash onto the upstream (rebase, not merge:
          a task branch is for a PR, and a merge back only adds noise)
  push    --force means --force-with-lease, which never overwrites what you
          have not seen

Reads are exposed over HTTP too (the phone already lists tasks); writes only
over the local IPC socket, as docs/remote-exposure.md says.

**`is_safe_branch` let a bare flag through.** `--upload-pack=…` was rejected
for its `=`, but `--force` or `-n` passed and git reads those as options, not
as a branch name. Writing the task-name check is what surfaced it. Fixed with
its own test, and it protects every caller of the shared validator — review
and diffs included.

Verified end to end against a live daemon: created a task, committed in it,
listed it, removed it and cleaned up the branch.
The last two pieces of the tasks panel that only the desktop had, and the two
that matter most when something goes wrong.

  bento_review::rebase   plan validation, start, continue, abort, split,
                         preserve-merges, and where it stopped
  bento_review::backup   the automatic ref before anything that rewrites
                         history, the bounded per-branch history, and restore

`src-tauri/src/git/{rebase,backup}.rs` are now thin commands over them — 34 and
30 lines instead of 381 and 205 — and the crate carries their tests, plus the
test_support that builds real repos.

The plan validation got its own tests because it is the sharp edge: every line
of a rebase todo ends up inside git's, so an unknown action (`exec rm -rf /`),
a commit outside the range, or a newline smuggled into one instruction are each
rejected with a reason. The sequence-editor quoting too — a path with a space
or a quote cannot break out of the shell command git builds.

In the CLI: `bento tasks rebase <base>|status|continue|abort`, `tasks backups`
and `tasks restore [<ref>]`. Without an explicit plan the rebase is all picks,
which is what you want nine times out of ten.

Verified against a real repo: a rebase that conflicts pauses and `rebase
status` says where and on which file, aborting leaves the original content
untouched, the backup ref is there afterwards and restore lands on it.

Writes go over the local IPC socket only — docs/remote-exposure.md updated to
say so rather than "not exposed".
Moving BackupEntry, BackupStatus, RebaseStatus and WorktreeInfo into the crate
quietly broke the binding: ts-rs only writes a type where the derive is, so
the four .ts files stopped tracking their structs. Nothing failed — the files
were still there, just frozen — which is the worst way for a guarantee to
break.

`ts-rs` is now an optional dependency of bento-review behind a `ts` feature
that only the desktop app turns on: the daemon and the CLI do not build it.
`bindings:generate` runs the crate's export tests too.

Two details the move had eaten: the export path is relative to the workspace
root, not the crate, and `created_at` needs `ts(type = "number")` or a u64
becomes a bigint in TypeScript and `new Date(...)` stops accepting it — which
is exactly what tsc then said.
What the tasks panel still had of its own was already in bento-review, so it
now calls it: worktree listing and parsing, the status counts, ahead/behind
and the sync state, removing a worktree, and pushing.

Two of them were better in the app than in the crate, so the crate grew rather
than the app losing anything:

- **Removing.** If the `.git` file inside a worktree broke, git refuses and the
  folder stays forever. The app repaired the link and retried; the crate now
  does too — and the trigger is wider than the app's was, because git says
  "validation failed", not only "not a working tree" (a test with a genuinely
  broken link is what showed it). What did not widen: unsaved work still blocks
  the removal, which has its own test so the repair path can never quietly
  force it.
- **Pushing.** The first publish uses the branch name rather than HEAD, so the
  upstream points at something named, and a detached HEAD is refused.

The two worktree parsing tests (bare entries, Windows CRLF with a space in the
path) moved to the crate with the parser.

src-tauri/src/git/worktree.rs is 60 lines, from 164.
A new crate, `bento-docker`, with what the Docker panel does and nothing of
how it looks: resolving the binary (through a login shell, because a
Homebrew docker is invisible to a GUI app on macOS), listing containers,
parsing `docker ps` into typed ones, the start/stop/restart lifecycle, logs,
and compose up/down.

  bento docker                        containers, running and stopped
  bento docker logs <name> [--tail n]
  bento docker start|stop|restart <name>
  GET /api/docker                     read-only, for the phone

Reads go over HTTP; the lifecycle only over the local IPC socket. Stopping the
production database from a phone should not be one token away —
docs/remote-exposure.md says so now.

**Same flag hole as `is_safe_branch`, found the same way.** `is_safe_container`
allowed a leading dash, so `--volumes` passed for a container name and docker
would read it as an option. A test asserting it should fail is what caught it.
Docker itself does not allow a name to start with `-`, so rejecting it is also
what the tool expects.

`Container` is now defined once in Rust and exported to TypeScript, so the
shape cannot drift; the panel keeps parsing the raw text it already receives.

Adding the route pushed remote/mod.rs over the size check again, so the
read-only handlers (projects, tasks, docker) moved to remote/inventory.rs —
which is a better name for them than "the file with the server in it".
…top as wrappers

branches, log, commit, edit, status, sync, recommend, pr status/view/create and
worktree add/remove ya no tienen lógica en src-tauri: viven en la crate, con sus
tests, y el escritorio solo envuelve. src-tauri/src/git pasa de ~1770 a 606
líneas. Se van paths.rs (a bento_review::edit) y test_support.rs (ya está en la
crate).
tasks.status, tasks.diff, tasks.log y tasks.upstream salen ahora por el socket
IPC (local), y el CLI trae `bento tasks status|diff|log`. Son lecturas: por HTTP
sigue yendo solo la lista — ver docs/remote-exposure.md.
…to-review

El markdown de la review, el resumen de la rama, la sesión de seguimiento y qué
fallo merece reintento vivían en TS (y la lista de reintentos ya estaba duplicada
en Rust). Ahora están en `bento_review::report` y `agents::is_retryable`, con sus
tests; el panel los llama por `review_build_document`, `review_build_overview`,
`review_follow_up_session` y `review_is_retryable`. De paso se borra
buildReviewFileBatches, que ya no usaba nadie.
…e from the CLI

Localizar la sesión de cada agente (Claude, Codex, OpenCode) y el scrollback que
Bento guarda salen de src-tauri y pasan a la crate `bento-sessions`, con sus
tests. El CLI trae `bento agent sessions` (lo que se puede retomar aquí) y
`bento agent resume <agente> <id> [--attach]`, que abre el PTY por IPC. El id
llega de fuera y acaba en una línea de comandos, así que se rechaza vacío, con
guion inicial o con cualquier cosa que no sea [A-Za-z0-9-_]. De paso, `bento
agent` se parte a commands/agent.rs.
Entrecomillar nombres y escapar literales estaba escrito dos veces, con reglas
distintas por motor en cada lenguaje. Ahora `bento_db::query` arma el SQL que el
panel enseña y ejecuta —consulta de ejemplo, relaciones, camino de JOINs, INSERT,
vaciar celda y el LIMIT de seguridad— con 14 tests, y valida los nombres antes de
concatenarlos (antes el lado TS no validaba nada). Se van sqlQuote.ts,
pgIdents.ts, rowLimit.ts y joinPath.ts, y la tercera copia de la detección de
LIMIT que vivía suelta en dbQueryExec.ts. Los tests del panel comprueban ahora
que pide la sentencia correcta y ejecuta la que recibe.
Importar desde Rust (`memory_source_import`) solo miraba el `external_id`,
mientras que el panel además detectaba duplicados semánticos y los fusionaba: la
misma operación dejaba resultados distintos según por dónde entrara, y por el
lado de Rust duplicados que el panel habría fundido.

La regla vive ahora en `bento_memory::dedup` (clave semántica, contención,
fusión y el plan skip/merge/create) con 9 tests, y la usan las dos. En TS se van
memoryImportPlan.ts y la mitad de duplicados de normalize.ts; el panel entra por
`memory_plan_import` y `memory_merge`. Los tests del runner y de la vista previa
dictan ahora la decisión en vez de recalcularla.
Había tres criterios distintos para lo mismo: classifyPrCheck (panel de tareas),
computeCiStatus (panel de review) y format_checks (TUI). Un action_required salía
como "pasa" en uno, "pendiente" en otro; un skipped, "pasa" en el panel y
"pendiente" en el TUI. Ahora lo decide `bento_review::pr::check_verdict`, con
tests, y lo usan los tres. Un action_required cuenta como fallo: GitHub no deja
mezclar con él, así que contarlo como que pasa era la lectura peligrosa.

El informe (veredicto por check y recuento) viaja ya dentro de PrStatus, así que
el panel de tareas no pregunta de más; el de review usa gh_pr_check_report. Se
van core/git/prChecks.ts, computeCiStatus y core/git/reviewFiles.ts, que no lo
usaba nadie.
El panel pedía el log, las dos recomendaciones y los ficheros de cada commit por
separado (N+3 llamadas) y luego combinaba las tres señales con sus propios pesos.
Ahora `bento_review::recommend::fixup_targets` lo hace de una vez, con tests, y
el panel hace una sola llamada.

Los códigos del porcelain que significan conflicto estaban en dos sitios: dentro
de `rebase::status` y en conflictWorkflow.ts. Ahora los dice
`bento_review::status::parse_conflicted`, que usan los dos.

Se van changedPaths, matchingPaths y rankFixupCandidates de commitWorkflow.ts, y
parseConflictFiles de conflictWorkflow.ts.
`tasks.fixup` sale por el socket local y el CLI trae `bento tasks fixup [--base
<ref>] [<fichero>…]`: a qué commit propio le pega lo que tienes sin commitear, y
por qué (ficheros en común, blame, historial). Sin parche explícito usa el diff
del worktree, que es el caso normal.

Probándolo salió un fallo del propio CLI: al recoger los ficheros sueltos se
colaba el valor de `--cwd` como si fuera uno, y entonces el historial marcaba
todos los commits. Ahora hay un `positional()` que sabe qué opciones se llevan
su valor, con tests.
…ies it

El panel armaba el parche por trozos en TypeScript y Rust lo aplicaba con
`--unidiff-zero` justo porque esos trozos van tal cual salen del diff original:
media regla a cada lado de una pareja que tiene que ir junta. Ahora lo hace
`bento_review::diff::{parse_file_patch, build_selected_patch}`, con 6 tests.

Partir el fichero en trozos también pasa por ahí, y no solo por limpieza: quien
pinta las casillas y quien arma el parche cuentan los trozos con el mismo código,
así que no se puede marcar uno y commitear otro. commitWorkflow.ts se queda en 7
líneas.
… were loose

El panel de Jira y el del móvil se quedaron fuera de la pasada de i18n: 65 de los
82 strings anotados eran suyos. Ahora salen del catálogo (`panels.jira` sube a 55
claves, `panels.remote` es nueva), en español y en inglés. De paso caen los
sueltos de aiChat, lazyPanel, AgentsPanel y TV.

Quedan 12 anotados y ahí se quedan: no son prosa. Son el nombre del producto, el
literal SQL NULL, los valores de un filtro (all, commented), una rama por defecto
y un árbol de ficheros en ASCII — traducir eso los rompe. Lo dice el comentario
de audit-i18n.mjs, para no volver a mirarlo.
"claude/codex/opencode" estaba escrito en seis sitios: el catálogo del panel, el
tipo y el validador de chatHistory, la constante del TUI, dos constantes del
bundle del móvil y tres <select> del HTML. Ahora la lista es
`bento_review::agents::AGENTS` y de ahí salen todos.

Lo que impide que vuelvan a separarse:
- El tipo `AgentType` de TypeScript lo genera ts-rs desde `AgentId`, así que
  añadir uno en Rust rompe la compilación del panel hasta que tenga etiqueta
  (comprobado añadiendo un `Gemini` de prueba: el typecheck falla).
- Un test ata la lista al `match` de `invocation`: no se puede ofrecer un agente
  que luego no se sepa lanzar.
- El daemon sirve /agents.js generado desde el catálogo, y un test comprueba que
  ni el HTML ni el bundle vuelven a escribir la lista.

Al hacerlo salieron dos copias que no había contado: el tipo y el validador de
sessionAgent en chatHistory.ts.
…behind

Los procesos node que se acumulaban durante días eran este hook. El resumidor se
mataba con SIGTERM y se esperaba a su evento `close`: un agente que ignora
SIGTERM no lo emite nunca, así que la promesa no resolvía y el proceso se
quedaba vivo hasta que saltara el temporizador de 300 s.

Y cuando saltaba, era peor: `process.exit(0)` mataba a node y dejaba al agente
suelto, con sus propios hijos, para siempre.

Ahora el resumidor va en su propio grupo de procesos, se escala a SIGKILL tras
una gracia y se resuelve pase lo que pase; y el temporizador del hook corta a los
hijos antes de salir. Tres tests con agentes que se portan mal a propósito: uno
que ignora SIGTERM y otro al que hay que cortar a media faena.
… not

Los tres tests del cajón de Jira buscaban botones por su texto en español
('Guardar', 'Comentar'). Mientras el panel estaba sin traducir daba igual el
idioma; desde que sale del catálogo, el panel se pinta en el que esté activo.

Y cuál estaba activo dependía del orden de los ficheros: varios hacen
stubGlobal del localStorage y eso se filtra entre ellos. Sin localStorage
utilizable, getAppLocale() devuelve español (en local pasaba); con uno vacío cae
a navigator.language, que en happy-dom es en-US (en CI fallaba).

Un tests/setup.ts fija el idioma antes de cada test, así que ya no depende del
orden. Las aserciones de Jira usan ahora el catálogo y pasan en los dos idiomas.
De paso salió otro test que heredaba el inglés de un test anterior sin pedirlo.

Comprobado con la suite entera en español, en inglés, y tres pasadas en orden
aleatorio.
…king target/

El test del resumidor lanzaba scripts .sh, que en Windows no arrancan (spawn
UNKNOWN, errno -4094). Y el código de producción tampoco valía allí:
`process.kill(-pid)` mata un grupo de procesos, que es de Unix. Ahora la
decisión es una función pura por plataforma —taskkill /t en Windows, grupo en
Unix— probada en las dos, como ya hacía crossPlatformProcess. Lo que lanza
procesos de verdad usa agentes en Node en vez de shell, y se salta en Windows,
donde no se puede ignorar un SIGTERM. Los busca por un nombre único: con el fijo,
un resto de una ejecución anterior contaba como vivo y el test iba y venía.

Y aparte: `npm run test:coverage` se quedaba sin memoria con 6 GB de heap. No era
de esta rama —pasa en cualquiera— sino de `coverage.all`, que recorre el proyecto
y se metía en target/, 80 GB de artefactos de cargo en cuanto compilas la parte
Rust. En CI no se ve porque allí target/ está vacío.
@R0MADEV
R0MADEV merged commit f280bbe into main Aug 26, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant