Skip to content

fix: stream agent events live, don't buffer until completion - #10

Merged
yogthos merged 2 commits into
mainfrom
fix/stream-events-live
May 19, 2026
Merged

fix: stream agent events live, don't buffer until completion#10
yogthos merged 2 commits into
mainfrom
fix/stream-events-live

Conversation

@yogthos

@yogthos yogthos commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

The error-recovery work introduced a `StreamBuffer` that collected every `Token`/`Reasoning`/`ToolCall`/`ToolResult` event and only flushed them to the UI on success. Result: the user saw a completely silent agent for the entire turn. They'd type something or hit Ctrl+C out of frustration (printing "interrupted") while the background task kept running and asked for permissions.

What changed

  • `run_stream` now `event_tx.send().await`s each event as it arrives — same path as before the error-recovery PR.
  • Retry-safety preserved via a plain `StreamOutcome { had_tool_calls, error }` struct: if any tool calls already dispatched, surface the error rather than retry (same correctness as before — side effects don't double-fire).
  • For pure-streaming errors before any tool call, we still retry with jittered backoff. The user may see a couple of duplicated tokens, but that's a much friendlier failure mode than total silence.

Repro user reported

```

let's examine the persistence layer
interrupted ← Ctrl+C, but nothing had been visible
are you there?
interrupted ← same again
[permission] find_files: .*\.edn ← bg task still alive
```

Test plan

  • `cargo test --bin dirge --features plugin` — 158 passing
  • Manual: ask any non-trivial question, confirm tokens stream live as the model produces them
  • Manual: kill network mid-stream before any tool call, confirm retry message appears and stream resumes
  • Manual: kill network after a tool call, confirm "(tool side effects already applied, not retrying)" error

Yogthos added 2 commits May 19, 2026 00:38
The error-recovery work introduced a StreamBuffer that collected every
Token/Reasoning/ToolCall/ToolResult event and only flushed them to the
UI on success. That meant the user saw a completely silent agent for
the entire turn — tokens never appeared until the model finished, by
which point the user had typed something (or hit Ctrl+C out of
frustration, printing 'interrupted' while the background task kept
running and asked for permissions).

Send events to the UI as they arrive instead. The retry guard is
preserved via a plain StreamOutcome { had_tool_calls, error } struct:
if any tool calls already dispatched, we surface the error rather than
retry (the same correctness property as before — side effects don't
double-fire). For pure-streaming errors before any tool call, we still
retry with exponential backoff; the user may see a couple of duplicated
tokens, which is a much friendlier failure mode than total silence.
Just dropping the event_rx left the spawned tokio task running to
completion — it'd keep streaming the LLM response, executing tool
calls, and sending permission prompts after the user pressed Ctrl+C.

Hold a JoinHandle alongside event_rx in AgentRunner. On every site that
clears agent_rx (Ctrl+C, Ctrl+D, Esc, /clear, etc.), take the handle
and call abort() — the in-flight LLM request gets cancelled and any
mid-flight tool future is dropped.
@yogthos
yogthos merged commit dfc794f into main May 19, 2026
1 check passed
yogthos added a commit that referenced this pull request May 21, 2026
…#84)

Track F-HIGH #10 from ROADMAP.md. Also documents F9 as a
verified false positive.

## Problem

`check_bash_segments` (`agent/tools/bash.rs:194-201`) had two
code paths:

- With `semantic-bash` feature (default-on): uses tree-sitter
  via `semantic::adapters::bash::parse_bash_segments_full`.
  Quoting handled correctly.
- Without `semantic-bash` (`--no-default-features` builds): used
  naive `command.split(";")` etc., which split INSIDE quoted
  strings.

The naive splitter on `echo "; rm -rf /"` produced segments:

  1. `echo "`
  2. `rm -rf /"`

Segment 2 then matched the default bash deny rule for `rm`,
triggering a permission ask the user might confirm — for a
command they thought was safe because the rm was inside a string.

## Fix

New `quote_aware_split(command) -> Vec<&str>` walks the string
byte-by-byte tracking three state flags:

- `in_single`: inside `'…'`. `\` is literal here per shell rules.
- `in_double`: inside `"…"`. `\` escapes the next byte.
- `prev_backslash`: outside quotes, `\` escapes the next byte.

Boundary checks for `&&`, `||`, `;` only fire when ALL three flags
are clear. Empty / whitespace-only segments dropped.

Replaces the `command.split(';').flat_map(...)` chain in the
no-semantic-bash branch. Production builds (semantic-bash on by
default) are unchanged.

## F9 note

The audit listed F9 as "mid-stream decode retry blocked by
had_tool_calls flag" — claim was that we should retry when
"tool dispatched, result pending, stream died." Verified false
positive: rig dispatches tools synchronously inside its stream
loop (the tool's `call` method runs to completion before rig
emits ToolCall). By the time we observe ToolCall, side effects
are applied. `had_tool_calls=true → no retry` is exactly the
safe behavior. The comment at `runner.rs:414-417` already
documents this. Marking F9 as F-SKIP in next ROADMAP update.

## Tests

Six new tests in `agent::tools::bash::tests` (Unix-only because
the test module's `#[cfg(unix)]` gate, but the function itself
is platform-independent):

- `quote_aware_split_keeps_semi_in_double_quotes`: the original
  bug fixture — one segment, not two.
- `quote_aware_split_keeps_compound_in_single_quotes`: single-
  quoted `&&` stays one segment.
- `quote_aware_split_respects_backslash_escape`: `\;` outside
  quotes is also literal.
- `quote_aware_split_splits_unquoted_compounds`: real compounds
  still split into N segments.
- `quote_aware_split_drops_empty_segments`: leading / trailing
  / repeated separators don't yield empty entries.
- `quote_aware_split_mixed_quoted_and_unquoted`: combined case.

670 pass (was 664). All build profiles clean.

Co-authored-by: Yogthos <yogthos@gmail.com>
yogthos added a commit that referenced this pull request May 21, 2026
Follow-up to PR #111. Tier-2 items from the 23-bug audit batch:
docs corrections and two small correctness/UX fixes.

## Docs

- **#12 temperature** — CONFIG.md claimed "parsed but not
  currently applied". Actually applied since PR #105 with a
  clamp warning. Rewrote the cell.
- **#13 --api-key** — flag existed but neither README nor
  CONFIG.md mentioned it. Added a Quick-start example noting
  the process-list visibility caveat.
- **#14 acp_host/acp_port** — CONFIG.md documented both keys
  but the CLI flags were intentionally removed (stdio-only
  transport). Removed both from the keys table + ACP section.
- **#6 tools** — `Config::tools` (per-tool enable map) was
  fully wired in code but undocumented. Added a row to the
  keys table covering `tools.websearch` and `tools.webfetch`.
- **#21 find_callers** — README claimed "word-boundary regex"
  but the impl uses the tree-sitter symbol index. Updated to
  reflect actual behavior; the user-visible word-boundary
  semantics are preserved.

## Code

- **#16 semantic index skip_dir** — `SymbolIndex::find_callers`
  filter had its own hardcoded `matches!(name, "node_modules"
  | "target" | ".git" | "__pycache__")` while the rest of
  the codebase uses `agent::tools::is_skip_dir`. Switched to
  the shared helper so future additions stay in lockstep.

- **#18 context::load_file** — silently swallowed
  `read_to_string` errors via `.ok()`. A permission-denied
  AGENTS.md looked identical to a missing file. Now emits a
  stderr warning naming the path + reason; still returns None
  so callers' behavior is unchanged.

725 plugin / 599 default pass. All build profiles clean.

## Remaining audit items (deferred to feature work)

- **#8 LSP no crash restart**: needs broken-pipe IO error
  handling + exponential backoff. Touches manager state
  machine.
- **#10 task tool fire-and-forget**: needs timeout + cleanup
  coordination via JoinHandle tracking.
- **#25 MCP no reconnection**: similar architectural concern
  to #8.
- **#27 LSP didClose**: client lifecycle hook missing.
- **#29 token estimation len/4**: needs per-provider usage
  extraction (Phase 6 work).
- **#5 MCP shutdown**: rmcp Drop semantics need verification.
- **#38/39/40 semantic test gaps**: get_symbol_body untested,
  list_symbols kind_filter untested, find_definition test
  vacuous. Sat down to add but each requires a fixture build.

Together with PR #111 (10 code fixes), 17 of the 23 verified
items are now shipped. Remaining 6 are architectural or
test-infrastructure work better tackled as discrete PRs.

Co-authored-by: Yogthos <yogthos@gmail.com>
yogthos pushed a commit that referenced this pull request May 22, 2026
…view

Adversarial review of the per-prompt deny-list architecture flagged
two real bypasses + several defense-in-depth gaps. Addressing.

- **#1 CRITICAL — MCP tools bypassed the deny-list entirely**:
  `McpTool::call` passes the umbrella name `"mcp_tool"` to
  `check_perm`. The deny-list match is literal `==` (now case-
  insensitive), so a prompt declaring `deny_tools: [edit]` would
  NOT match an MCP server's `edit` tool — the LLM could route
  filesystem writes through any MCP server unscathed. Added
  `PermissionChecker::any_prompt_denied(&[name1, name2, ...])`
  public probe; `McpTool::call` now checks (concrete tool name,
  `mcp_tool:<server>:<name>` qualified form, umbrella `mcp_tool`)
  before invoking `check_perm`. Any hit returns a hard denial.

- **#2 CRITICAL — ACP never installed the prompt deny-list**:
  the ACP bridge built a fresh `PermissionChecker` per session
  and never wired in `context.current_prompt_deny_tools`. Plan
  mode was a no-op for editor clients. Mirror the
  `apply_prompt_deny` call from `main.rs::build_channels` into
  the ACP `run_prompt` path right after `build_acp_permission`.

- **#5 MEDIUM — `glob` / `repo_overview` added to PermissionConfig**:
  both were filesystem walkers reachable via the perm checker but
  not declared as user-configurable in `PermissionConfig`. User-
  level `permission.glob = "deny"` would silently fall through to
  the `*` default. Added the fields + the per-tool rule loop entry.

- **#6 MEDIUM — `plan_enter` / `plan_exit` now consult deny-list**:
  both intentionally skip `check_perm` (the confirmation dialog
  IS the user-prompt). But the prompt deny-list should still apply
  — a strict-mode prompt that says `deny_tools: [plan_exit]` should
  refuse the call WITHOUT opening the dialog. Added a thin
  `check_prompt_deny` helper that queries `any_prompt_denied`
  before opening the channel.

- **#7 MEDIUM — case-insensitive tool-name matching**:
  `deny_tools: [Edit]` (typo capitalization) used to silently no-op.
  `is_prompt_denied` now uses `eq_ignore_ascii_case`; the frontmatter
  parser also lowercases at load so the stored list is canonical
  in every consumer (status line, UI, etc.).

- **#9 LOW — warn on unknown tool names in `deny_tools`**: at prompt
  load time, cross-check every `deny_tools` entry against a
  `KNOWN_TOOLS` list. Warns once per unknown entry, with the full
  known set printed for guidance. MCP-server-exported tool names
  will trigger this benignly; documented inline.

- **#3 HIGH — pin order with tests**: three new checker tests pin
  the contract:
  - `prompt_deny_any_matches_concrete_and_qualified_mcp_names`
    (locks the MCP bypass fix)
  - `prompt_deny_is_case_insensitive`
  - existing tests continue passing

- **#4 plugin trust boundary documented**: per the review's
  recommendation, added a "Plugin trust boundary" section to
  CONFIG.md acknowledging that plugins are inside the trust
  boundary and not sandboxed.

Not addressed (intentional):
- #8 doom-loop UI nudge on repeated deny-list hits — UX polish.
- #10 `/prompt default` clear confirmation — user-typed; would
  have to confirm every clear, including the legitimate ones.

634 tests pass; fmt clean.
yogthos pushed a commit that referenced this pull request May 22, 2026
…rf cache

Self-review of the last 4 commits flagged 11 findings. Addressing all
in one batch.

- **#1 HIGH — leading whitespace dropped on first row of soft_wrap**:
  `current.is_empty()` at row start unconditionally dropped
  `token.leading_ws`, including on the first row of a logical line.
  Option lines `"  ▶ label …"` lost their `  ` margin; the green
  `  allowed …` confirmation lost its indent too. First-row branch
  now preserves leading_ws (with a ws-overflow fallback to keep the
  token).

- **#2 HIGH — drain_events early-broke before reader quiesced**: the
  Ok(false) shortcut fired on the first quiet poll, which was often
  "the background reader currently holds crossterm's internal mutex"
  rather than "terminal is quiet". A delayed OSC 11 / DA1 response
  could still escape past our drain. Now requires at least one
  observed event before the Ok(false) shortcut; honors the full
  budget otherwise.

- **#3 HIGH — MODIFIED section clipped its own bottom border at
  `available == 4`**: row_budget=1, footer + 1 file = 2 items, +3
  frame rows = 5 total in a 4-row budget. draw_panel clipped the
  `╰────╯`. Bumped MIN_MOD_SECTION_ROWS from 4 → 5 so the bottom
  border is always painted.

- **#4 MEDIUM — `\r` not stripped from CRLF input**: Windows /
  some-MCP tool output left `\r` in tokens, producing terminal
  redraw artifacts. `soft_wrap` now strips a trailing `\r` per
  logical line.

- **#5 MEDIUM — Show cursor on alt screen was a no-op**: `Show` was
  issued while still on the alt screen; `LeaveAlternateScreen`
  restores the main screen's saved DECTCEM state, discarding the
  Show. Moved Show to AFTER LeaveAlternateScreen + disable_raw_mode.

- **#6 MEDIUM — recent(256) clones + locks on every redraw**: panel
  redraws on every streamed token. Added `modified::version()`
  monotonic counter (bumped on mark / clear); panel-side cache in
  `panel_modified_cached` keyed by (version, cwd) skips the lock +
  256-PathBuf clone + path-strip when nothing changed.

- **#7 MEDIUM — break_long_token could emit wide-glyph row > budget
  at max_width<2**: floored max_width at 2 inside soft_wrap. A
  1-cell terminal is unusable anyway; this just removes a sharp
  edge.

- **#8 MEDIUM — all-whitespace first row collapsed to empty**:
  same root cause as #1, fixed by the same change. Indented blank
  separators now preserve their indentation.

- **#9 LOW — `allowed …` confirmation flush against alert `╰─╯`**:
  added a blank-line breathing row before the green confirmation
  so the alert's bottom border and the confirmation don't read as
  one block.

- **#10 LOW — head_w used chars().count() not display width**:
  switched to `UnicodeWidthStr::width(head)` so future wide-glyph
  markers won't under-pad the continuation indent.

- **#11 LOW — single-select marker width inconsistent**: cursor
  marker was `▶` (w=1), non-cursor was `  ` (w=2), so wrapped tails
  of adjacent options drifted by one column. Cursor marker padded
  to `▶ ` so all markers in a question share display width.

5 new tests:
- preserves_leading_whitespace_on_first_row
- strips_carriage_returns_from_crlf_input
- wide_glyph_respects_max_width_at_floor
- preserves_leading_whitespace_only_line
- version_bumps_on_mark_and_clear

651 tests pass; fmt clean.
yogthos pushed a commit that referenced this pull request May 22, 2026
…nds, error masking

Adversarial review of `1c341e9`/`8e60553`/`69318b7` flagged 14
findings. Addressing all.

- **#1 HIGH — C1 controls bypassed MCP stderr sanitizer**: the
  original filter was `b == 0x09 || (0x20..0x7f).contains(&b) ||
  b >= 0x80`. That second clause let through every non-ASCII byte
  including the C1 control range (U+0080..=U+009F). In particular
  U+009B is single-byte CSI, behaves identically to `\x1b[` on
  iTerm2/xterm in 8-bit mode. A misbehaving MCP child could write
  `\u{9b}2J` and repaint the screen — exactly the smuggling vector
  the commit message claimed to close. Filter now blocks C0
  controls (except `\t`), DEL, and the full C1 range.

- **#2 HIGH — MCP stderr was silently dropped at default verbosity**:
  emit was `tracing::info!`, but dirge's default EnvFilter is
  `warn,rig=off`. Users diagnosing MCP server panics or init
  errors saw nothing — real regression vs the old
  `Stdio::inherit()`. Raised to `tracing::warn!` so it surfaces
  on the default config.

- **#3 HIGH — `parse_ddg_html` panicked on truncated input**:
  `&html[tag_start..abs_start + 32]` blew up when `abs_start +
  32 > html.len()` or landed mid-codepoint. Rewrote the scanner
  to anchor on `<a ` tags and walk only within bounded slices
  via `tag_end.min(html.len())`. Added regression test for the
  truncated case.

- **#5 MEDIUM — MCP stderr forwarder had no per-line cap**:
  `BufReader::lines()` buffers until `\n`. A buggy child writing
  a GB without newline would OOM dirge. Replaced with a manual
  read loop, 16 KiB per-line cap, emit `…[truncated]` past the
  cap and skip until next `\n`.

- **#6 MEDIUM — provider rotation race on first call**: two
  concurrent first-callers both saw `AtomicU8 = 0`, both rolled
  a fresh entropy pick, both stored. Last writer won —
  inconsistent contract. Switched to `compare_exchange` from 0
  to candidate; loser re-reads the winner's value.

- **#7 MEDIUM — both-providers-fail error masked secondary +
  DDG errors**: only `primary_err` was returned. Now
  concatenates all three failures so the user can diagnose
  without chasing the wrong cause.

- **#8 MEDIUM — `parse_ddg_html` false-positives on substring
  match**: previously matched `class="result__a"` anywhere in
  the HTML, including inside `<script>` blocks or quoted text.
  Walked backward via `rfind("<a ")` could grab an unrelated
  anchor. Now anchors on `<a ` first and inspects the tag's
  attributes — proper containment check.

- **#9 MEDIUM — DDG snippet control bytes flowed into LLM
  prompt**: `strip_tags_and_decode` decoded entities but didn't
  filter ESC / C1 controls. A malicious or mojibake search
  result could ship ANSI styling into the agent's context.
  Added control-byte filter to the decoder's output pass.

- **#10 LOW — `PARALLEL_API_KEY` read per-call**: was
  `std::env::var` inside `call`, inconsistent with how
  `EXA_API_KEY` was captured at construction. Moved to
  `WebSearchTool::new`.

- **#11 LOW — whitespace-only key passed empty-filter**:
  `EXA_API_KEY="  "` produced a malformed
  `?exaApiKey=%20%20` URL. Now trims keys at construction.

- **#12 LOW — tool description out of date**: still mentioned
  Exa-only + DDG fallback, missed Parallel.ai rotation and the
  keyless default. Rewritten to reflect the actual contract.

- **#13 LOW — `urlencode_query` renamed to `percent_encode`**:
  the function is a generic percent-encoder (RFC 3986
  unreserved set), not a form-encoder. Misleading name.

- **#14 DESIGN — no tests for new code paths**: added 11
  regression tests covering ddg parser bounds + happy path +
  anti-script-block, control-byte filter, key trim, MCP
  response parser (plain JSON + SSE + malformed), DDG redirect
  unwrap, percent encoding, provider env override.

- **#15 LOW — bot-identifying DDG User-Agent**: swapped
  `compatible; dirge-agent/1.0` for a real Firefox 133 UA. DDG
  aggressively rate-limits identifiable scrapers.

695 tests pass (684 + 11 new); fmt clean.
yogthos pushed a commit that referenced this pull request May 22, 2026
…h_bg width, sanitization

Correctness + security review of `dc21de7`/`cd701a8` flagged 15
findings. Addressing all 15.

- **#1 HIGH — startup race: MCP forwarders fired before
  `install()`**. `connect_all` spawns stderr forwarders in `main`
  BEFORE `run_interactive` reached the old `install()` call. Lines
  emitted during MCP-server handshake hit `sender() == None` and
  were silently dropped. Moved `install()` to the very top of
  `main()` so the channel is live by the time any forwarder
  starts. Split the API: `install()` (creates channel) +
  `take_receiver()` (UI loop claims the rx).

- **#2 HIGH — orphaned-sender footgun on UI restart**. `OnceLock`
  meant a re-entry could never replace the sender; producers
  holding clones would send into a dead channel forever. Switched
  to `RwLock<Option<Sender>>`. Producers also self-heal: when
  `try_send` returns Err because the receiver was dropped,
  `notify_send` clears the slot so subsequent producers see `None`
  and skip.

- **#3 HIGH — `row_with_bg` was still char-count-based**. The
  refactor claim was "unifies display-width vs char-count" but
  the bg-tinted variant still used `chars().count()`. A diff row
  with CJK / emoji drifted the right border. Now uses the same
  display-width budget as the plain `row`. Regression test
  `row_with_bg_width_invariant` pins it.

- **#4 HIGH — unbounded channel + no backpressure → OOM**. A
  buggy / hostile MCP child spamming stderr would grow the queue
  unboundedly. Switched to `mpsc::channel(1024)` (bounded) with
  `try_send` so the producer drops on overflow rather than
  unboundedly queuing. Test `bounded_channel_drops_on_full` pins
  the contract.

- **#5 MEDIUM — multi-colon MCP tool names**. `splitn(3, ':')` on
  `mcp_tool:server:do:thing` parsed correctly but the comment
  explanation was off. Clarified; behavior unchanged (the
  wildcarded server pattern is the desired semantics).

- **#6 MEDIUM — mcp_tool umbrella check case-sensitive**.
  `umbrella == "mcp_tool"` would miss `MCP_TOOL:…` if a future
  caller surfaces uppercase. Switched to `eq_ignore_ascii_case`.

- **#7 MEDIUM — receiver-side sanitization for ALL Notification
  variants**. MCP variant was pre-sanitized at the producer,
  but Info/Warn/Error had no producer-side contract. Adding
  receiver-side `ansi::strip_controls(KEEP_NEWLINE)` makes the
  rule un-bypassable: nothing reaches `write_line` carrying
  escape bytes regardless of how careful a future producer is.

- **#8 MEDIUM — websearch `KEEP_BOTH` + `\n` broke chamber
  border**. Tabs survived into chamber rows where they
  interacted poorly with the wrap math. Switched to
  `KEEP_NEWLINE` and replace `\t` with single space.

- **#9 MEDIUM — whitespace-only MCP lines dropped**. The
  blank-line collapse used `trim().is_empty()` which also ate
  legitimate indented continuation lines. Now uses `is_empty()`
  post-sanitize.

- **#10 LOW — `top()` with empty title rendered `╭─  ─…─╮`**
  (two spaces with no glyph between). Empty title now matches
  the bottom-border shape `╭{horizontals}╮`. Test pins it.

- **#11 LOW — `expand_tabs` precondition undocumented**. Added
  comment that input should be control-byte free; callers must
  sanitize first.

- **#12 LOW — `BoxBuilder::row("a\nb")` produced one row
  containing a literal `\n`**. Now splits on `\n` and emits one
  row per logical line. Test `builder_splits_embedded_newlines`
  pins it.

- **#13 LOW — `BoxBuilder` had no labelled-row variant**. Added
  `row_labelled(label, sep, value)` that indents wrapped tails
  under the value column. Mirrors the alert chamber's
  `labelled_rows` shape so a future alert migration to
  BoxBuilder is unblocked. Test pins continuation indent.

- **#14 LOW — `strip_controls` allocated on no-op path**. Fast
  path returns the input unchanged when no chars would be
  filtered.

- **#15 DESIGN — sender caching deferred**. Per-call `sender()`
  is the right semantics for the orphan-detection case (#2);
  caching would skip the slot-clear behavior. Kept as is.

8 new tests; 715 total. Two-test serialisation via TEST_GATE for
the notification tests since they mutate global TX/RX_HOLDER state.

fmt clean.
yogthos pushed a commit that referenced this pull request May 22, 2026
…ser_rx writes

Code review of `b508658`/`8b48bfd`/`f9285ad` flagged 15 findings,
including one **active regression** I shipped: `write_outside_chamber`
reused `close_tool_chamber_if_open` which always painted
"⚠ tool denied · aborted · no result". So every notification
arriving while a tool was in-flight would falsely brand that tool
as denied. Fixed.

Headline: of 9 tokio::select! arms, only 2 were using the new
chokepoint. 3 others (question_rx, dialog_rx, plan_rx) carried
the SAME X-inside-chamber bug the helper was built to eliminate.
Migrated them.

- **#4 HIGH (regression I shipped)**: split chamber-close into
  two variants:
  - `close_tool_chamber_abort` — paints the "⚠ tool denied" row
    + bottom border. Used by permission-deny / agent error /
    interjection / context-overflow paths (the tool is being
    actively rejected).
  - `close_tool_chamber_passive` — emits ONLY the bottom border.
    Used by `write_outside_chamber` (the tool isn't being
    denied; we just need to terminate the visual frame so
    notification text doesn't land inside).
  - `close_tool_chamber_if_open` kept as back-compat alias for
    the abort variant — existing call sites (4 of them, all in
    abort-shaped contexts) keep their previous behavior.

- **#1 / #2 / #3 CRITICAL — three arms migrated**:
  - `question_rx` (3537): a `question` tool's chamber was open
    when the prompt header was painted; header + stem + option
    grid landed inside.
  - `dialog_rx` (3811): plugin `harness/confirm` /
    `harness/select` fires from inside on-tool-start hooks while
    a tool chamber is open; the dialog rendered inside.
  - `plan_rx` (3955): plan-switch prompt could be delivered
    while a tool chamber was open; prompt landed inside.

- **#5 HIGH — user_rx interactive writes migrated**:
  - Ctrl+C interrupt msg (1135)
  - "copied selection" (1150)
  - Ctrl+X dropped-interjection trailer (1168)
  - "agent is busy" × 2 (1533, 1598)

- **#7 MEDIUM — defense-in-depth sanitization**:
  `write_outside_chamber` now runs `strip_controls(KEEP_NEWLINE)`
  on `text` before writing. A future caller that forgets
  producer-side sanitization can't smuggle ANSI escapes.

- **#12 LOW — notification amplification cap**: the bounded
  channel limits NOTIFICATIONS but not ROWS per notification. A
  single `Notification::McpLog` carrying 10k `\n`s would expand
  to 10k chamber rows. After 200 lines we truncate and emit a
  `[N more lines suppressed]` marker.

- **#6 audit** revealed the 4 remaining manual sites
  (1984/2602/2713/2884) all ARE abort-shaped and correctly use
  the abort variant via the back-compat alias. No migration
  needed.

- **#8 / #9 / #10 / #13 / #14 / #15** noted as design
  trade-offs or already verified clean.

3 new regression tests:
  - `close_passive_does_not_paint_abort_row` pins the new
    no-abort-label contract
  - `close_abort_paints_warning_and_bottom` pins the abort
    variant still emits 2 rows
  - existing `write_outside_chamber_closes_chamber_first` still
    passes; helper now uses passive close

718 tests pass (716 + 2 new); fmt clean.
yogthos pushed a commit that referenced this pull request May 22, 2026
Three issues from the post-cutover code review against pi:

**Bug #1**: stream.rs:186-194 — defensive fallback (stream
closed without Done/Error) skipped emitting message_start /
message_end. Pi at agent-loop.ts:359-366 emits both. Fix:
route the fallback through `finalize()` so it follows the
same emit path as Done/Error. Updated the existing test that
documented the wrong behavior as "intentional Rust deviation"
— it's now pi-faithful.

**Bug #4**: integration.rs:411 — orphaned inner loop task.
`spawn_loop_runner` spawned `run_agent_loop` as a NESTED
`tokio::spawn`. A `task.abort()` on the outer task would
kill it but leave the nested task running silently — tools
could keep executing after the user thought they'd cancelled.
Fix: collapse to `tokio::join!(loop_future, pump_future)` in
the same outer task. Shared fate; outer abort drops both
futures at their next .await. Tools that poll the AbortSignal
still observe cancellation cooperatively.

**Gap #3**: run.rs prepareNextTurn — pi at agent-loop.ts:229-238
rebuilds config with the new model / reasoning. We accepted
the fields but silently ignored them. Surfacing a tracing
warning per ignored swap so users wiring the hook know their
change didn't take effect. Full fix requires the StreamFn to
be a factory `Fn(Context) -> StreamFn` (so the loop can
rebuild it on swap) — flagged for follow-up when a real
consumer demands it.

Items NOT addressed (documented in review):
  - #2 get_api_key receives empty string (no production caller)
  - #5/#6 timing / ordering changes (observable but not bugs)
  - #7-9 efficiency micro-optimizations
  - #10/#11 UI-side wiring + Agent.preamble defensiveness

Gates:
  - cargo build (default)         clean
  - cargo build --all-features    clean
  - cargo test (default)          841 green (unchanged)
  - cargo fmt                     clean
yogthos pushed a commit that referenced this pull request May 27, 2026
…r dedup

Two review findings from a clean-context audit of ca3bb42 / ba253b1.

**Bug (MEDIUM): tool-call gap timeout penalized any chunk while a
tool call was open**

The prior implementation narrowed `effective_timeout` to 30s
whenever `open_tool_calls` was non-empty. A provider that emits
one ToolCallDelta then takes 25s emitting reasoning/text deltas
(legitimate forward progress) would be killed at 30s — even
though the model was making progress, just not on the tool call.

Fix: track `last_chunk_at: Instant`. The gap budget for the next
wait = `TOOL_CALL_GAP_TIMEOUT.saturating_sub(last_chunk_at.elapsed())`.
Every chunk arrival (text, reasoning, tool-call delta, final
ToolCall) refreshes `last_chunk_at`, so the gap timer only counts
true silence — not gaps filled by other chunks.

Regression test `gap_timeout_resets_on_interleaved_text_delta`:
ToolCallDelta → 20s sleep → TextDelta → 20s sleep → TextDelta →
done. Total elapsed 40s, but no single chunk gap exceeds 30s, so
the gap timeout MUST NOT fire. Passes.

**Cosmetic (INFO): counter inflation on multi-null-strip calls**

`strip_null_optionals` pushes `RepairKind::NullStripped` once per
removed key. A single tool call with 3 null fields was registering
`null_stripped += 3`. The UI summary then read "repaired 3
input(s): 3 null-strip" for what was one call with three strips.

Fix: dedupe `rr.kinds` per-call inside the counter-record loop in
`tools.rs`. The full kinds vec still flows to the tracing event
for per-call detail; the aggregate counter now measures "tool
calls touched" which is the user-meaningful metric. Added `Hash`
derive on `RepairKind` to support the dedupe HashSet.

Review findings not addressed (deferred / not bugs):
- #2 apply_patch hint phrasing (low; the shared hint is
  defensible since each operations[].path IS absolute)
- #4-6 open_tool_calls lifecycle on stream-end / final-without-
  delta paths (all confirmed correct in original impl)
- #9 additional test coverage (would catch nothing new; the
  new test exercises the previously-buggy interleave path)
- #10 missing hints for task/skill/memory/etc. (defensible —
  those tools don't take path args)
- #11 Debug-formatted validation_errors (minor; structured-log
  consumers can normalize)

Full test suite: 1720 pass / 0 fail / 0 ignored (was 1719).
yogthos added a commit that referenced this pull request Jun 2, 2026
…pt [dirge-ftmo] (#354)

#10 short_id: the id.chars().take(8) prefix idiom was hand-rolled at 9 sites
(2 of them already-duplicated local fns in ui/tree.rs + ui/plugin_tree.rs).
Hoisted to crate::text::short_id; the 2 local wrappers now delegate, the 7
inline sites call it directly.

#9 spawn.rs: the main (spawn_runner) and fork (spawn_filtered_runner_with_cache)
builders assembled tool_defs + model_name identically. Extracted private
AnyAgent::tool_defs_for(tools) and ::model_name_opt(model_name) (takes the
field by ref so it survives the partial move of self.loop_tools).

#5 ext_of was DROPPED — the audit counted 8 sites but only 2 (read_minified/
edit_minified) actually match the &str→&str shape; the rest take &Path and/or
add .to_lowercase()/format!, so a single helper doesn't fit. Not worth it for 2.

2455 tests pass; clean under -D warnings --all-features.

Co-authored-by: Yogthos <yogthos@gmail.com>
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…ode#10)

* fix: stream agent events live, don't buffer until completion

The error-recovery work introduced a StreamBuffer that collected every
Token/Reasoning/ToolCall/ToolResult event and only flushed them to the
UI on success. That meant the user saw a completely silent agent for
the entire turn — tokens never appeared until the model finished, by
which point the user had typed something (or hit Ctrl+C out of
frustration, printing 'interrupted' while the background task kept
running and asked for permissions).

Send events to the UI as they arrive instead. The retry guard is
preserved via a plain StreamOutcome { had_tool_calls, error } struct:
if any tool calls already dispatched, we surface the error rather than
retry (the same correctness property as before — side effects don't
double-fire). For pure-streaming errors before any tool call, we still
retry with exponential backoff; the user may see a couple of duplicated
tokens, which is a much friendlier failure mode than total silence.

* fix: abort background agent task on UI interrupt

Just dropping the event_rx left the spawned tokio task running to
completion — it'd keep streaming the LLM response, executing tool
calls, and sending permission prompts after the user pressed Ctrl+C.

Hold a JoinHandle alongside event_rx in AgentRunner. On every site that
clears agent_rx (Ctrl+C, Ctrl+D, Esc, /clear, etc.), take the handle
and call abort() — the in-flight LLM request gets cancelled and any
mid-flight tool future is dropped.

---------

Co-authored-by: Yogthos <yogthos@gmail.com>
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…dirge-code#84)

Track F-HIGH dirge-code#10 from ROADMAP.md. Also documents F9 as a
verified false positive.

## Problem

`check_bash_segments` (`agent/tools/bash.rs:194-201`) had two
code paths:

- With `semantic-bash` feature (default-on): uses tree-sitter
  via `semantic::adapters::bash::parse_bash_segments_full`.
  Quoting handled correctly.
- Without `semantic-bash` (`--no-default-features` builds): used
  naive `command.split(";")` etc., which split INSIDE quoted
  strings.

The naive splitter on `echo "; rm -rf /"` produced segments:

  1. `echo "`
  2. `rm -rf /"`

Segment 2 then matched the default bash deny rule for `rm`,
triggering a permission ask the user might confirm — for a
command they thought was safe because the rm was inside a string.

## Fix

New `quote_aware_split(command) -> Vec<&str>` walks the string
byte-by-byte tracking three state flags:

- `in_single`: inside `'…'`. `\` is literal here per shell rules.
- `in_double`: inside `"…"`. `\` escapes the next byte.
- `prev_backslash`: outside quotes, `\` escapes the next byte.

Boundary checks for `&&`, `||`, `;` only fire when ALL three flags
are clear. Empty / whitespace-only segments dropped.

Replaces the `command.split(';').flat_map(...)` chain in the
no-semantic-bash branch. Production builds (semantic-bash on by
default) are unchanged.

## F9 note

The audit listed F9 as "mid-stream decode retry blocked by
had_tool_calls flag" — claim was that we should retry when
"tool dispatched, result pending, stream died." Verified false
positive: rig dispatches tools synchronously inside its stream
loop (the tool's `call` method runs to completion before rig
emits ToolCall). By the time we observe ToolCall, side effects
are applied. `had_tool_calls=true → no retry` is exactly the
safe behavior. The comment at `runner.rs:414-417` already
documents this. Marking F9 as F-SKIP in next ROADMAP update.

## Tests

Six new tests in `agent::tools::bash::tests` (Unix-only because
the test module's `#[cfg(unix)]` gate, but the function itself
is platform-independent):

- `quote_aware_split_keeps_semi_in_double_quotes`: the original
  bug fixture — one segment, not two.
- `quote_aware_split_keeps_compound_in_single_quotes`: single-
  quoted `&&` stays one segment.
- `quote_aware_split_respects_backslash_escape`: `\;` outside
  quotes is also literal.
- `quote_aware_split_splits_unquoted_compounds`: real compounds
  still split into N segments.
- `quote_aware_split_drops_empty_segments`: leading / trailing
  / repeated separators don't yield empty entries.
- `quote_aware_split_mixed_quoted_and_unquoted`: combined case.

670 pass (was 664). All build profiles clean.

Co-authored-by: Yogthos <yogthos@gmail.com>
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…de#112)

Follow-up to PR dirge-code#111. Tier-2 items from the 23-bug audit batch:
docs corrections and two small correctness/UX fixes.

## Docs

- **dirge-code#12 temperature** — CONFIG.md claimed "parsed but not
  currently applied". Actually applied since PR dirge-code#105 with a
  clamp warning. Rewrote the cell.
- **dirge-code#13 --api-key** — flag existed but neither README nor
  CONFIG.md mentioned it. Added a Quick-start example noting
  the process-list visibility caveat.
- **dirge-code#14 acp_host/acp_port** — CONFIG.md documented both keys
  but the CLI flags were intentionally removed (stdio-only
  transport). Removed both from the keys table + ACP section.
- **dirge-code#6 tools** — `Config::tools` (per-tool enable map) was
  fully wired in code but undocumented. Added a row to the
  keys table covering `tools.websearch` and `tools.webfetch`.
- **dirge-code#21 find_callers** — README claimed "word-boundary regex"
  but the impl uses the tree-sitter symbol index. Updated to
  reflect actual behavior; the user-visible word-boundary
  semantics are preserved.

## Code

- **dirge-code#16 semantic index skip_dir** — `SymbolIndex::find_callers`
  filter had its own hardcoded `matches!(name, "node_modules"
  | "target" | ".git" | "__pycache__")` while the rest of
  the codebase uses `agent::tools::is_skip_dir`. Switched to
  the shared helper so future additions stay in lockstep.

- **dirge-code#18 context::load_file** — silently swallowed
  `read_to_string` errors via `.ok()`. A permission-denied
  AGENTS.md looked identical to a missing file. Now emits a
  stderr warning naming the path + reason; still returns None
  so callers' behavior is unchanged.

725 plugin / 599 default pass. All build profiles clean.

## Remaining audit items (deferred to feature work)

- **dirge-code#8 LSP no crash restart**: needs broken-pipe IO error
  handling + exponential backoff. Touches manager state
  machine.
- **dirge-code#10 task tool fire-and-forget**: needs timeout + cleanup
  coordination via JoinHandle tracking.
- **dirge-code#25 MCP no reconnection**: similar architectural concern
  to dirge-code#8.
- **dirge-code#27 LSP didClose**: client lifecycle hook missing.
- **dirge-code#29 token estimation len/4**: needs per-provider usage
  extraction (Phase 6 work).
- **dirge-code#5 MCP shutdown**: rmcp Drop semantics need verification.
- **dirge-code#38/39/40 semantic test gaps**: get_symbol_body untested,
  list_symbols kind_filter untested, find_definition test
  vacuous. Sat down to add but each requires a fixture build.

Together with PR dirge-code#111 (10 code fixes), 17 of the 23 verified
items are now shipped. Remaining 6 are architectural or
test-infrastructure work better tackled as discrete PRs.

Co-authored-by: Yogthos <yogthos@gmail.com>
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…view

Adversarial review of the per-prompt deny-list architecture flagged
two real bypasses + several defense-in-depth gaps. Addressing.

- **#1 CRITICAL — MCP tools bypassed the deny-list entirely**:
  `McpTool::call` passes the umbrella name `"mcp_tool"` to
  `check_perm`. The deny-list match is literal `==` (now case-
  insensitive), so a prompt declaring `deny_tools: [edit]` would
  NOT match an MCP server's `edit` tool — the LLM could route
  filesystem writes through any MCP server unscathed. Added
  `PermissionChecker::any_prompt_denied(&[name1, name2, ...])`
  public probe; `McpTool::call` now checks (concrete tool name,
  `mcp_tool:<server>:<name>` qualified form, umbrella `mcp_tool`)
  before invoking `check_perm`. Any hit returns a hard denial.

- **#2 CRITICAL — ACP never installed the prompt deny-list**:
  the ACP bridge built a fresh `PermissionChecker` per session
  and never wired in `context.current_prompt_deny_tools`. Plan
  mode was a no-op for editor clients. Mirror the
  `apply_prompt_deny` call from `main.rs::build_channels` into
  the ACP `run_prompt` path right after `build_acp_permission`.

- **dirge-code#5 MEDIUM — `glob` / `repo_overview` added to PermissionConfig**:
  both were filesystem walkers reachable via the perm checker but
  not declared as user-configurable in `PermissionConfig`. User-
  level `permission.glob = "deny"` would silently fall through to
  the `*` default. Added the fields + the per-tool rule loop entry.

- **dirge-code#6 MEDIUM — `plan_enter` / `plan_exit` now consult deny-list**:
  both intentionally skip `check_perm` (the confirmation dialog
  IS the user-prompt). But the prompt deny-list should still apply
  — a strict-mode prompt that says `deny_tools: [plan_exit]` should
  refuse the call WITHOUT opening the dialog. Added a thin
  `check_prompt_deny` helper that queries `any_prompt_denied`
  before opening the channel.

- **dirge-code#7 MEDIUM — case-insensitive tool-name matching**:
  `deny_tools: [Edit]` (typo capitalization) used to silently no-op.
  `is_prompt_denied` now uses `eq_ignore_ascii_case`; the frontmatter
  parser also lowercases at load so the stored list is canonical
  in every consumer (status line, UI, etc.).

- **dirge-code#9 LOW — warn on unknown tool names in `deny_tools`**: at prompt
  load time, cross-check every `deny_tools` entry against a
  `KNOWN_TOOLS` list. Warns once per unknown entry, with the full
  known set printed for guidance. MCP-server-exported tool names
  will trigger this benignly; documented inline.

- **#3 HIGH — pin order with tests**: three new checker tests pin
  the contract:
  - `prompt_deny_any_matches_concrete_and_qualified_mcp_names`
    (locks the MCP bypass fix)
  - `prompt_deny_is_case_insensitive`
  - existing tests continue passing

- **#4 plugin trust boundary documented**: per the review's
  recommendation, added a "Plugin trust boundary" section to
  CONFIG.md acknowledging that plugins are inside the trust
  boundary and not sandboxed.

Not addressed (intentional):
- dirge-code#8 doom-loop UI nudge on repeated deny-list hits — UX polish.
- dirge-code#10 `/prompt default` clear confirmation — user-typed; would
  have to confirm every clear, including the legitimate ones.

634 tests pass; fmt clean.
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…rf cache

Self-review of the last 4 commits flagged 11 findings. Addressing all
in one batch.

- **#1 HIGH — leading whitespace dropped on first row of soft_wrap**:
  `current.is_empty()` at row start unconditionally dropped
  `token.leading_ws`, including on the first row of a logical line.
  Option lines `"  ▶ label …"` lost their `  ` margin; the green
  `  allowed …` confirmation lost its indent too. First-row branch
  now preserves leading_ws (with a ws-overflow fallback to keep the
  token).

- **#2 HIGH — drain_events early-broke before reader quiesced**: the
  Ok(false) shortcut fired on the first quiet poll, which was often
  "the background reader currently holds crossterm's internal mutex"
  rather than "terminal is quiet". A delayed OSC 11 / DA1 response
  could still escape past our drain. Now requires at least one
  observed event before the Ok(false) shortcut; honors the full
  budget otherwise.

- **#3 HIGH — MODIFIED section clipped its own bottom border at
  `available == 4`**: row_budget=1, footer + 1 file = 2 items, +3
  frame rows = 5 total in a 4-row budget. draw_panel clipped the
  `╰────╯`. Bumped MIN_MOD_SECTION_ROWS from 4 → 5 so the bottom
  border is always painted.

- **#4 MEDIUM — `\r` not stripped from CRLF input**: Windows /
  some-MCP tool output left `\r` in tokens, producing terminal
  redraw artifacts. `soft_wrap` now strips a trailing `\r` per
  logical line.

- **dirge-code#5 MEDIUM — Show cursor on alt screen was a no-op**: `Show` was
  issued while still on the alt screen; `LeaveAlternateScreen`
  restores the main screen's saved DECTCEM state, discarding the
  Show. Moved Show to AFTER LeaveAlternateScreen + disable_raw_mode.

- **dirge-code#6 MEDIUM — recent(256) clones + locks on every redraw**: panel
  redraws on every streamed token. Added `modified::version()`
  monotonic counter (bumped on mark / clear); panel-side cache in
  `panel_modified_cached` keyed by (version, cwd) skips the lock +
  256-PathBuf clone + path-strip when nothing changed.

- **dirge-code#7 MEDIUM — break_long_token could emit wide-glyph row > budget
  at max_width<2**: floored max_width at 2 inside soft_wrap. A
  1-cell terminal is unusable anyway; this just removes a sharp
  edge.

- **dirge-code#8 MEDIUM — all-whitespace first row collapsed to empty**:
  same root cause as #1, fixed by the same change. Indented blank
  separators now preserve their indentation.

- **dirge-code#9 LOW — `allowed …` confirmation flush against alert `╰─╯`**:
  added a blank-line breathing row before the green confirmation
  so the alert's bottom border and the confirmation don't read as
  one block.

- **dirge-code#10 LOW — head_w used chars().count() not display width**:
  switched to `UnicodeWidthStr::width(head)` so future wide-glyph
  markers won't under-pad the continuation indent.

- **dirge-code#11 LOW — single-select marker width inconsistent**: cursor
  marker was `▶` (w=1), non-cursor was `  ` (w=2), so wrapped tails
  of adjacent options drifted by one column. Cursor marker padded
  to `▶ ` so all markers in a question share display width.

5 new tests:
- preserves_leading_whitespace_on_first_row
- strips_carriage_returns_from_crlf_input
- wide_glyph_respects_max_width_at_floor
- preserves_leading_whitespace_only_line
- version_bumps_on_mark_and_clear

651 tests pass; fmt clean.
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…nds, error masking

Adversarial review of `349afb6`/`b9de3d5`/`2fac609` flagged 14
findings. Addressing all.

- **#1 HIGH — C1 controls bypassed MCP stderr sanitizer**: the
  original filter was `b == 0x09 || (0x20..0x7f).contains(&b) ||
  b >= 0x80`. That second clause let through every non-ASCII byte
  including the C1 control range (U+0080..=U+009F). In particular
  U+009B is single-byte CSI, behaves identically to `\x1b[` on
  iTerm2/xterm in 8-bit mode. A misbehaving MCP child could write
  `\u{9b}2J` and repaint the screen — exactly the smuggling vector
  the commit message claimed to close. Filter now blocks C0
  controls (except `\t`), DEL, and the full C1 range.

- **#2 HIGH — MCP stderr was silently dropped at default verbosity**:
  emit was `tracing::info!`, but dirge's default EnvFilter is
  `warn,rig=off`. Users diagnosing MCP server panics or init
  errors saw nothing — real regression vs the old
  `Stdio::inherit()`. Raised to `tracing::warn!` so it surfaces
  on the default config.

- **#3 HIGH — `parse_ddg_html` panicked on truncated input**:
  `&html[tag_start..abs_start + 32]` blew up when `abs_start +
  32 > html.len()` or landed mid-codepoint. Rewrote the scanner
  to anchor on `<a ` tags and walk only within bounded slices
  via `tag_end.min(html.len())`. Added regression test for the
  truncated case.

- **dirge-code#5 MEDIUM — MCP stderr forwarder had no per-line cap**:
  `BufReader::lines()` buffers until `\n`. A buggy child writing
  a GB without newline would OOM dirge. Replaced with a manual
  read loop, 16 KiB per-line cap, emit `…[truncated]` past the
  cap and skip until next `\n`.

- **dirge-code#6 MEDIUM — provider rotation race on first call**: two
  concurrent first-callers both saw `AtomicU8 = 0`, both rolled
  a fresh entropy pick, both stored. Last writer won —
  inconsistent contract. Switched to `compare_exchange` from 0
  to candidate; loser re-reads the winner's value.

- **dirge-code#7 MEDIUM — both-providers-fail error masked secondary +
  DDG errors**: only `primary_err` was returned. Now
  concatenates all three failures so the user can diagnose
  without chasing the wrong cause.

- **dirge-code#8 MEDIUM — `parse_ddg_html` false-positives on substring
  match**: previously matched `class="result__a"` anywhere in
  the HTML, including inside `<script>` blocks or quoted text.
  Walked backward via `rfind("<a ")` could grab an unrelated
  anchor. Now anchors on `<a ` first and inspects the tag's
  attributes — proper containment check.

- **dirge-code#9 MEDIUM — DDG snippet control bytes flowed into LLM
  prompt**: `strip_tags_and_decode` decoded entities but didn't
  filter ESC / C1 controls. A malicious or mojibake search
  result could ship ANSI styling into the agent's context.
  Added control-byte filter to the decoder's output pass.

- **dirge-code#10 LOW — `PARALLEL_API_KEY` read per-call**: was
  `std::env::var` inside `call`, inconsistent with how
  `EXA_API_KEY` was captured at construction. Moved to
  `WebSearchTool::new`.

- **dirge-code#11 LOW — whitespace-only key passed empty-filter**:
  `EXA_API_KEY="  "` produced a malformed
  `?exaApiKey=%20%20` URL. Now trims keys at construction.

- **dirge-code#12 LOW — tool description out of date**: still mentioned
  Exa-only + DDG fallback, missed Parallel.ai rotation and the
  keyless default. Rewritten to reflect the actual contract.

- **dirge-code#13 LOW — `urlencode_query` renamed to `percent_encode`**:
  the function is a generic percent-encoder (RFC 3986
  unreserved set), not a form-encoder. Misleading name.

- **dirge-code#14 DESIGN — no tests for new code paths**: added 11
  regression tests covering ddg parser bounds + happy path +
  anti-script-block, control-byte filter, key trim, MCP
  response parser (plain JSON + SSE + malformed), DDG redirect
  unwrap, percent encoding, provider env override.

- **dirge-code#15 LOW — bot-identifying DDG User-Agent**: swapped
  `compatible; dirge-agent/1.0` for a real Firefox 133 UA. DDG
  aggressively rate-limits identifiable scrapers.

695 tests pass (684 + 11 new); fmt clean.
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…h_bg width, sanitization

Correctness + security review of `ea042b1`/`524c90c` flagged 15
findings. Addressing all 15.

- **#1 HIGH — startup race: MCP forwarders fired before
  `install()`**. `connect_all` spawns stderr forwarders in `main`
  BEFORE `run_interactive` reached the old `install()` call. Lines
  emitted during MCP-server handshake hit `sender() == None` and
  were silently dropped. Moved `install()` to the very top of
  `main()` so the channel is live by the time any forwarder
  starts. Split the API: `install()` (creates channel) +
  `take_receiver()` (UI loop claims the rx).

- **#2 HIGH — orphaned-sender footgun on UI restart**. `OnceLock`
  meant a re-entry could never replace the sender; producers
  holding clones would send into a dead channel forever. Switched
  to `RwLock<Option<Sender>>`. Producers also self-heal: when
  `try_send` returns Err because the receiver was dropped,
  `notify_send` clears the slot so subsequent producers see `None`
  and skip.

- **#3 HIGH — `row_with_bg` was still char-count-based**. The
  refactor claim was "unifies display-width vs char-count" but
  the bg-tinted variant still used `chars().count()`. A diff row
  with CJK / emoji drifted the right border. Now uses the same
  display-width budget as the plain `row`. Regression test
  `row_with_bg_width_invariant` pins it.

- **#4 HIGH — unbounded channel + no backpressure → OOM**. A
  buggy / hostile MCP child spamming stderr would grow the queue
  unboundedly. Switched to `mpsc::channel(1024)` (bounded) with
  `try_send` so the producer drops on overflow rather than
  unboundedly queuing. Test `bounded_channel_drops_on_full` pins
  the contract.

- **dirge-code#5 MEDIUM — multi-colon MCP tool names**. `splitn(3, ':')` on
  `mcp_tool:server:do:thing` parsed correctly but the comment
  explanation was off. Clarified; behavior unchanged (the
  wildcarded server pattern is the desired semantics).

- **dirge-code#6 MEDIUM — mcp_tool umbrella check case-sensitive**.
  `umbrella == "mcp_tool"` would miss `MCP_TOOL:…` if a future
  caller surfaces uppercase. Switched to `eq_ignore_ascii_case`.

- **dirge-code#7 MEDIUM — receiver-side sanitization for ALL Notification
  variants**. MCP variant was pre-sanitized at the producer,
  but Info/Warn/Error had no producer-side contract. Adding
  receiver-side `ansi::strip_controls(KEEP_NEWLINE)` makes the
  rule un-bypassable: nothing reaches `write_line` carrying
  escape bytes regardless of how careful a future producer is.

- **dirge-code#8 MEDIUM — websearch `KEEP_BOTH` + `\n` broke chamber
  border**. Tabs survived into chamber rows where they
  interacted poorly with the wrap math. Switched to
  `KEEP_NEWLINE` and replace `\t` with single space.

- **dirge-code#9 MEDIUM — whitespace-only MCP lines dropped**. The
  blank-line collapse used `trim().is_empty()` which also ate
  legitimate indented continuation lines. Now uses `is_empty()`
  post-sanitize.

- **dirge-code#10 LOW — `top()` with empty title rendered `╭─  ─…─╮`**
  (two spaces with no glyph between). Empty title now matches
  the bottom-border shape `╭{horizontals}╮`. Test pins it.

- **dirge-code#11 LOW — `expand_tabs` precondition undocumented**. Added
  comment that input should be control-byte free; callers must
  sanitize first.

- **dirge-code#12 LOW — `BoxBuilder::row("a\nb")` produced one row
  containing a literal `\n`**. Now splits on `\n` and emits one
  row per logical line. Test `builder_splits_embedded_newlines`
  pins it.

- **dirge-code#13 LOW — `BoxBuilder` had no labelled-row variant**. Added
  `row_labelled(label, sep, value)` that indents wrapped tails
  under the value column. Mirrors the alert chamber's
  `labelled_rows` shape so a future alert migration to
  BoxBuilder is unblocked. Test pins continuation indent.

- **dirge-code#14 LOW — `strip_controls` allocated on no-op path**. Fast
  path returns the input unchanged when no chars would be
  filtered.

- **dirge-code#15 DESIGN — sender caching deferred**. Per-call `sender()`
  is the right semantics for the orphan-detection case (#2);
  caching would skip the slot-clear behavior. Kept as is.

8 new tests; 715 total. Two-test serialisation via TEST_GATE for
the notification tests since they mutate global TX/RX_HOLDER state.

fmt clean.
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…ser_rx writes

Code review of `a70fb03`/`8b0688c`/`afc76eb` flagged 15 findings,
including one **active regression** I shipped: `write_outside_chamber`
reused `close_tool_chamber_if_open` which always painted
"⚠ tool denied · aborted · no result". So every notification
arriving while a tool was in-flight would falsely brand that tool
as denied. Fixed.

Headline: of 9 tokio::select! arms, only 2 were using the new
chokepoint. 3 others (question_rx, dialog_rx, plan_rx) carried
the SAME X-inside-chamber bug the helper was built to eliminate.
Migrated them.

- **#4 HIGH (regression I shipped)**: split chamber-close into
  two variants:
  - `close_tool_chamber_abort` — paints the "⚠ tool denied" row
    + bottom border. Used by permission-deny / agent error /
    interjection / context-overflow paths (the tool is being
    actively rejected).
  - `close_tool_chamber_passive` — emits ONLY the bottom border.
    Used by `write_outside_chamber` (the tool isn't being
    denied; we just need to terminate the visual frame so
    notification text doesn't land inside).
  - `close_tool_chamber_if_open` kept as back-compat alias for
    the abort variant — existing call sites (4 of them, all in
    abort-shaped contexts) keep their previous behavior.

- **#1 / #2 / #3 CRITICAL — three arms migrated**:
  - `question_rx` (3537): a `question` tool's chamber was open
    when the prompt header was painted; header + stem + option
    grid landed inside.
  - `dialog_rx` (3811): plugin `harness/confirm` /
    `harness/select` fires from inside on-tool-start hooks while
    a tool chamber is open; the dialog rendered inside.
  - `plan_rx` (3955): plan-switch prompt could be delivered
    while a tool chamber was open; prompt landed inside.

- **dirge-code#5 HIGH — user_rx interactive writes migrated**:
  - Ctrl+C interrupt msg (1135)
  - "copied selection" (1150)
  - Ctrl+X dropped-interjection trailer (1168)
  - "agent is busy" × 2 (1533, 1598)

- **dirge-code#7 MEDIUM — defense-in-depth sanitization**:
  `write_outside_chamber` now runs `strip_controls(KEEP_NEWLINE)`
  on `text` before writing. A future caller that forgets
  producer-side sanitization can't smuggle ANSI escapes.

- **dirge-code#12 LOW — notification amplification cap**: the bounded
  channel limits NOTIFICATIONS but not ROWS per notification. A
  single `Notification::McpLog` carrying 10k `\n`s would expand
  to 10k chamber rows. After 200 lines we truncate and emit a
  `[N more lines suppressed]` marker.

- **dirge-code#6 audit** revealed the 4 remaining manual sites
  (1984/2602/2713/2884) all ARE abort-shaped and correctly use
  the abort variant via the back-compat alias. No migration
  needed.

- **dirge-code#8 / dirge-code#9 / dirge-code#10 / dirge-code#13 / dirge-code#14 / dirge-code#15** noted as design
  trade-offs or already verified clean.

3 new regression tests:
  - `close_passive_does_not_paint_abort_row` pins the new
    no-abort-label contract
  - `close_abort_paints_warning_and_bottom` pins the abort
    variant still emits 2 rows
  - existing `write_outside_chamber_closes_chamber_first` still
    passes; helper now uses passive close

718 tests pass (716 + 2 new); fmt clean.
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
Three issues from the post-cutover code review against pi:

**Bug #1**: stream.rs:186-194 — defensive fallback (stream
closed without Done/Error) skipped emitting message_start /
message_end. Pi at agent-loop.ts:359-366 emits both. Fix:
route the fallback through `finalize()` so it follows the
same emit path as Done/Error. Updated the existing test that
documented the wrong behavior as "intentional Rust deviation"
— it's now pi-faithful.

**Bug #4**: integration.rs:411 — orphaned inner loop task.
`spawn_loop_runner` spawned `run_agent_loop` as a NESTED
`tokio::spawn`. A `task.abort()` on the outer task would
kill it but leave the nested task running silently — tools
could keep executing after the user thought they'd cancelled.
Fix: collapse to `tokio::join!(loop_future, pump_future)` in
the same outer task. Shared fate; outer abort drops both
futures at their next .await. Tools that poll the AbortSignal
still observe cancellation cooperatively.

**Gap #3**: run.rs prepareNextTurn — pi at agent-loop.ts:229-238
rebuilds config with the new model / reasoning. We accepted
the fields but silently ignored them. Surfacing a tracing
warning per ignored swap so users wiring the hook know their
change didn't take effect. Full fix requires the StreamFn to
be a factory `Fn(Context) -> StreamFn` (so the loop can
rebuild it on swap) — flagged for follow-up when a real
consumer demands it.

Items NOT addressed (documented in review):
  - #2 get_api_key receives empty string (no production caller)
  - dirge-code#5/dirge-code#6 timing / ordering changes (observable but not bugs)
  - dirge-code#7-9 efficiency micro-optimizations
  - dirge-code#10/dirge-code#11 UI-side wiring + Agent.preamble defensiveness

Gates:
  - cargo build (default)         clean
  - cargo build --all-features    clean
  - cargo test (default)          841 green (unchanged)
  - cargo fmt                     clean
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…r dedup

Two review findings from a clean-context audit of 7dc35f9 / 32b439e.

**Bug (MEDIUM): tool-call gap timeout penalized any chunk while a
tool call was open**

The prior implementation narrowed `effective_timeout` to 30s
whenever `open_tool_calls` was non-empty. A provider that emits
one ToolCallDelta then takes 25s emitting reasoning/text deltas
(legitimate forward progress) would be killed at 30s — even
though the model was making progress, just not on the tool call.

Fix: track `last_chunk_at: Instant`. The gap budget for the next
wait = `TOOL_CALL_GAP_TIMEOUT.saturating_sub(last_chunk_at.elapsed())`.
Every chunk arrival (text, reasoning, tool-call delta, final
ToolCall) refreshes `last_chunk_at`, so the gap timer only counts
true silence — not gaps filled by other chunks.

Regression test `gap_timeout_resets_on_interleaved_text_delta`:
ToolCallDelta → 20s sleep → TextDelta → 20s sleep → TextDelta →
done. Total elapsed 40s, but no single chunk gap exceeds 30s, so
the gap timeout MUST NOT fire. Passes.

**Cosmetic (INFO): counter inflation on multi-null-strip calls**

`strip_null_optionals` pushes `RepairKind::NullStripped` once per
removed key. A single tool call with 3 null fields was registering
`null_stripped += 3`. The UI summary then read "repaired 3
input(s): 3 null-strip" for what was one call with three strips.

Fix: dedupe `rr.kinds` per-call inside the counter-record loop in
`tools.rs`. The full kinds vec still flows to the tracing event
for per-call detail; the aggregate counter now measures "tool
calls touched" which is the user-meaningful metric. Added `Hash`
derive on `RepairKind` to support the dedupe HashSet.

Review findings not addressed (deferred / not bugs):
- #2 apply_patch hint phrasing (low; the shared hint is
  defensible since each operations[].path IS absolute)
- #4-6 open_tool_calls lifecycle on stream-end / final-without-
  delta paths (all confirmed correct in original impl)
- dirge-code#9 additional test coverage (would catch nothing new; the
  new test exercises the previously-buggy interleave path)
- dirge-code#10 missing hints for task/skill/memory/etc. (defensible —
  those tools don't take path args)
- dirge-code#11 Debug-formatted validation_errors (minor; structured-log
  consumers can normalize)

Full test suite: 1720 pass / 0 fail / 0 ignored (was 1719).
allen-munsch pushed a commit to allen-munsch/dirge that referenced this pull request Jun 3, 2026
…pt [dirge-ftmo] (dirge-code#354)

dirge-code#10 short_id: the id.chars().take(8) prefix idiom was hand-rolled at 9 sites
(2 of them already-duplicated local fns in ui/tree.rs + ui/plugin_tree.rs).
Hoisted to crate::text::short_id; the 2 local wrappers now delegate, the 7
inline sites call it directly.

dirge-code#9 spawn.rs: the main (spawn_runner) and fork (spawn_filtered_runner_with_cache)
builders assembled tool_defs + model_name identically. Extracted private
AnyAgent::tool_defs_for(tools) and ::model_name_opt(model_name) (takes the
field by ref so it survives the partial move of self.loop_tools).

dirge-code#5 ext_of was DROPPED — the audit counted 8 sites but only 2 (read_minified/
edit_minified) actually match the &str→&str shape; the rest take &Path and/or
add .to_lowercase()/format!, so a single helper doesn't fit. Not worth it for 2.

2455 tests pass; clean under -D warnings --all-features.

Co-authored-by: Yogthos <yogthos@gmail.com>
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