-
Notifications
You must be signed in to change notification settings - Fork 2
live_attach_app_vs_demo_debug
Status: ✅ RESOLVED, then REARCHITECTED IN-BAND (2026-07-02) — see below.
The PoC at docs/refactor/ccdemo-poc/ remains the reference oracle.
The d1ec56d6 fix (drain timer + out-of-band capture) still left residual races:
the 180 ms drain was a guess (attach repaint could land late → double paint), the
subscribe→capture-pane ordering replayed output that the snapshot already contained
(duplicate spinners on every (re)connect during streaming), and the frontend's manual
fit override fought FitAddon (double resize → repaint storms). Grey lines came from
-J (preserves trailing spaces → bg fills become full-width gray bars) plus the
unsanitized live path (Claude Code's neutral bg-237 canvas).
New design (terminal_live_attach.go + internal/liveattach): all transport
commands — resize-window, the capture-pane backfill, #{history_size}/cursor
queries — are written to the control client's OWN stdin and answered in-stream
between %begin/%end guards (liveattach.Protocol frames them; verified against
tmux 3.6a: replies are FIFO by command number, serialized with %output, ESC bytes
verbatim, %error keeps the queue in order). A viewer is spliced into the broadcast
inside the scanner goroutine at its seed's %end, so the seed and the live
stream can neither overlap nor gap — no drain timers, no replay window, no
out-of-band subprocesses on the hot path. A single writer-pump goroutine owns
control-stdin writes (never hold a lock across that write — it deadlocks against
deliverReply). #{history_size} is queried first because tmux clamps
capture-pane -S … -E -1 into the visible screen when scrollback is empty
(otherwise row 0 seeds twice). History capture drops -J. Slow viewers are dropped
whole (WS close → reconnect → fresh seed) instead of losing mid-stream chunks.
Frontend (LiveAttachXtermPane): fit.fit() is the single sizing authority
(manual DOM-ruler override deleted — it guaranteed a double resize per layout tick);
the first frame of every (re)connect (the seed) gets normalizeAnsiForEmbeddedXterm
(gray-canvas strip) + selection clear, live bytes are written verbatim as raw
Uint8Array and never clear the selection; reconnect reschedules itself when the
pane is transiently hidden (used to strand the stream permanently); per-connection
TextDecoder.
Regression net: TestLiveAttachRealTmuxEndToEnd (cmd/server) drives the real
tmux + real WS handler against a continuously-printing pane and asserts the stream is
both duplicate-free AND contiguous (no gap); TestLiveAttachSeedSpliceExcludesPreSeedOutput
pins the splice semantics; parser tests pin %begin/%end/%error framing.
Follow-up fix (commit e19452b1) — the seed SPLICE POINT. The first cut spliced the
viewer on the CURSOR query's %end, with the screen captured one command earlier. tmux
emits %output between consecutive commands, so %output produced in the
screen→cursor window was lost: not in the snapshot (captured before it), not streamed
(spliced after it). A single dropped byte permanently desyncs xterm's grid → cursor-
addressed redraws land on wrong cells → characters from two logical lines interleave on
one row (the exact corruption reported). It only bites when the pane streams heavily
during the connect — e.g. a scheduled/headless workflow the browser attaches to
mid-run — so a quick interactive test looked clean. Fix: the current-screen
capture-pane is now the LAST seed command and the viewer splices on ITS %end, so
every byte is either in the snapshot or streamed, never both, never neither. Cursor is
queried just before the screen (benign momentary staleness, corrected by the first live
redraw). Decisive diagnostic: tmux capture-pane -p -t <session> showed the pane
content perfectly clean while the browser was garbled+frozen ⇒ transport/render bug, not
the session (the handoff's §8 test).
Follow-up fix (commit 1fe27d1c) — seed 2J erased in-viewport history ("can't
scroll up"). The seed was RIS → history → \x1b[H\x1b[2J → screen, with a comment
claiming 2J "clears only the viewport, preserving scrollback". Wrong in the case that
matters: xterm.js ED(2) erases viewport rows in place — it only pushes to scrollback
if scrollOnEraseInDisplay is set, which we deliberately leave off (live TUI repaints
would stack stale frames into scrollback). Right after the RIS, the tail of the
just-written history is still inside the viewport (only lines scrolled past enter
scrollback), so the 2J destroyed up to one screenful of the most recent history on
every (re)connect. For in-place-repaint TUIs like Claude Code — whose tmux history
stays small because completed turns are redrawn, not scrolled — that was often all
of it: the pane could not scroll up past roughly the current screen even though
capture-pane showed tmux retaining the full render. Fix: drop the clear entirely.
capture-pane -p always returns the full pane height, so painting the screen directly
below the history scrolls the history up into xterm scrollback naturally and lands the
screen exactly on the viewport (cursor rows stay aligned); an SGR reset between the two
stops attribute bleed. Unit test now fails if any 2J reappears in the seed.
Original resolution: commit d1ec56d6 "Fix live attach initial repaint
duplication". Root cause + fix below; the rest of this doc is the original investigation
(kept for context).
Root cause: the app's control-mode attach starts lazily (on the first browser
subscribe), so tmux's initial full-screen repaint — emitted whenever a control client
attaches — reached the browser right after the capture-pane backfill → the same screen
drawn twice → duplicate lines even without resize. The PoC never hit this because its
attach runs at server startup, before any browser, so that repaint broadcasts to zero
subscribers and is dropped.
Fix: on first subscribe, warm the attach with no subscriber and drain tmux's initial
repaint (liveAttachInitialDrainDelay 180 ms / 750 ms cap; drained chan +
waitInitialDrain/markInitialDrainComplete) BEFORE adding the viewer's channel —
replicating the PoC's warm-up. Reordered handleTerminalStream to subscribe→backfill→
writer. Added setSize dedup (only resize-window when the grid actually changed). New
test TestLiveAttachManagerDrainsInitialAttachBeforeSubscriber. Builds + live-attach
tests pass. (This is exactly the "backfill + attach initial %output = double" lead flagged
in §6 below.)
(Original investigation, pre-fix:) The standalone PoC demo rendered tmux CLIs perfectly; the real app, using the same approach, showed duplicated lines/stacked frames even without resizing. This doc captured everything tried.
Branch: terminal-live-attach-phase1
A live terminal transport that mirrors a server-side tmux session (detached,
new-session -d) into a browser xterm.js pane via tmux control mode
(tmux -CC attach) over a WebSocket. The backend parses %output and streams the raw
pane bytes; the browser writes them to xterm. This replaces an older snapshot/replay
(capture-pane polling) transport.
Location: /private/tmp/claude-501/-Users-mipl-ai-work/a86ff7c1-184a-484a-90e1-e3bb2d10bdd7/scratchpad/ccdemo/
-
main.go— standalone Go server:tmux -CC attachunder a creack/pty PTY, abufio.Scannerreads control-mode lines, decodes%outputoctal escapes, broadcasts decoded pane bytes to WS viewers. Backfill =capture-pane -e -S -1000prefixed with\x1b[H\x1b[2Jand\n→\r\n. Resize =resize-window -x C -y R. -
static/index.html— xterm.js page.new Terminal({fontSize:13, scrollback:20000}), FitAddon, writes WS bytes to xterm, resize viaterm.onResize+ debouncedwindow 'resize'. - Runs as two instances:
-
:8742→ sessionccdemo-live,pi --model google/gemini-3.5-flash(exact app model) -
:8743→ sessionccdemo-claude,claude(Claude Code)
-
- Result: both CLIs stream smoothly, resize cleanly on the fly, render complex ASCII — flawless.
- Backend:
agent_go/cmd/server/terminal_live_attach.go-
liveAttachManager/liveAttachStream: onetmux -CC attachper session (subscribe/unsubscribe; designed for 1 viewer/session). -
handleTerminalStream(WS handlerGET /api/terminals/{id}/stream): backfill vialiveAttachBackfill(capture-pane, same shape as demo), then streams broadcast channel; reader loop: binary→send-keys, JSON→resize/input/key. -
setSize: nowresize-windowonly (pty.Setsize removed). - Parser:
agent_go/internal/liveattach/parser.go(DecodeOutput,ClassifyLine).
-
- Frontend:
frontend/src/components/TerminalCenter.tsx-
LiveAttachXtermPaneInner(~line 2359): xtermconvertEol:false,disableStdin:true, scrollback 20000, FitAddon, manualterm.write,ResizeObserver-based fit, reconnect-on-close.key={stableLiveAttachId}. - Render branch (~line 4944): synthetic→
StructuredTerminalView, elsestableLiveAttachId→LiveAttachXtermPane, else empty placeholder. -
stableLiveAttachId: a debounced mount id (~line 3886) so transient selection flicker doesn't unmount the pane.
-
- Result: same content block renders 2+ times; "Working…" spinners stack; content mis-wraps. Happens DURING NORMAL STREAMING (no resize), and worse on resize.
- Duplicate lines without resize: e.g. a whole "STEP 4 — BACK UP FINAL STATE…" block appears twice, with different wrapping. ← strongest clue: the live stream is being rendered more than once, OR backfill overlaps the stream.
- On resize: multiple stacked spinners, mis-wrapped/garbled tables.
- Demo never shows any of this.
| Hypothesis | Test | Result |
|---|---|---|
| Stale frontend build / cache | Deleted all node_modules, reinstalled, restarted --only-frontend
|
App still broken → not stale |
| xterm.js version | App is @xterm/xterm@6.0.0; switched demo to 6.0.0 too |
Demo still perfect → not the version |
| Model / thinking level | Ran demo on (google) gemini-3.5-flash • medium, 1.0M ctx (exact app match) |
Demo still perfect → not the model |
| Scrollback / content length | Generated 200-line output in demo, then resized | Demo still perfect → not content length |
| Write method (AttachAddon vs manual) | Switched demo to manual term.write
|
Demo still perfect → not the write method |
| Old capture-pane polling interfering | Gated then ripped out the legacy polls (selected-probe, rail-probe excl. selected, selected-detail, manual-refresh) | App still broken |
| Feature flag / dual-path | Ripped out RUNLOOP_TERMINAL_LIVE_ATTACH flag + XtermTerminalPane; app is always live-attach for tmux |
App still broken |
-
CLI config/extensions: the app launches CLIs with extra config the demo lacks —
e.g. the live gemini workflow runs
gemini --model auto --admin-policy …/restrict-tools.toml;mcpagent/agent/agent.go:2226,3354append a system prompt "running inside Pi CLI with built-in tools disabled. Use the MCP …" + MCP servers. The demo's CLIs are bare. Plan: run the app's exact CLI through the demo's transport, OR replicate the config in the demo, to rule this out. -
Backend transport code (
terminal_live_attach.go) differs subtly from the demo'smain.go. Look hard at: the shared-attach manager (subscribe/unsubscribe vs demo's simpler hub), the broadcast buffer drop-for-slow-viewer path (liveAttachSubBuffer, ~line 271 "Slow viewer: drop"), the scanner buffer sizing, and whether the backfill is being sent more than once or the WS reconnects (each reconnect re-backfills → duplicate blocks). -
Frontend (
LiveAttachXtermPane) differs fromindex.html: React re-renders, the debounced remount, the flex/[&_.xterm]:h-fullcontainer,disableStdin.
A duplicated block during steady streaming almost always means one of:
- the WS reconnects and the backend re-runs the backfill (capture-pane snapshot appended on top of the live stream), or
-
two deliveries of the same
%output(double subscribe / two viewers / scanner re-emitting), or - the backfill cursor desync: capture-pane backfill leaves the xterm cursor at the
bottom, and the subsequent cursor-relative
%outputredraws append instead of overwrite (but the demo does the same backfill and is clean, so this alone shouldn't explain it — unless the app reconnects/re-backfills repeatedly).
Recommended first check: instrument how many times handleTerminalStream runs the
backfill and how many WS connects happen per terminal in the app vs the demo; and
count term.write calls per %output chunk. The demo connects once and stays.
- Backend
terminal_live_attach.go:setSize→resize-windowonly (removedpty.Setsize);liveAttachEnabled()→ always true (flag removed);newLiveAttachManagerIfEnabledalways builds (keeps tmux ≥2.9 guard). - Frontend: capabilities retry-with-backoff + a
main.tsxentry-point fetch (initializeStores()was dead code; capabilities never loaded reliably); reverted a bad cache-buster header that tripped CORS (Cache-Controlnot inAccess-Control-Allow-Headers, 127.0.0.1 vs localhost); debouncedstableLiveAttachIdmount; ripped out legacy polling +XtermTerminalPaneusage + the flag + debug scaffolding; experimented with removing then restoringResizeObserverand[&_.xterm]:h-full(both restored — removing the observer regressed at-rest rendering because the app's React pane sizes after mount). -
tscandgo buildboth clean.
-
Isolate transport vs CLI-config: point the demo's
ccdemoserver at the app's real tmux session (./ccdemo -s <mlp-… session> -addr …) and view it — same CLI + config through the proven-clean transport. (Avoid two simultaneous-CCclients on one session; stop the app's attach first.) If it stacks → CLI/config; if clean → app transport code. -
Line-by-line diff
terminal_live_attach.govsccdemo/main.go(broadcast, backfill, subscribe/unsubscribe, scanner) andLiveAttachXtermPanevsindex.html. -
Count duplicate writes: log WS-connect count, backfill count, and per-chunk
term.writecount in the app; compare to the demo (which is 1 connect, 1 backfill). - Verify the app isn't opening two
/streamWS for one terminal (e.g. rail + main), or re-subscribing.
The PoC source is committed at docs/refactor/ccdemo-poc/ and has its own
README.md. Keep it — it is the proven-correct reference/oracle for this
transport. Key files: main.go + listen.go (the server, package main at root),
static/index.html (the xterm page), launch.sh/stop.sh (helpers).
cd docs/refactor/ccdemo-poc
go build -o ccdemo . # main.go + listen.go are package main at the root
Deps (go build fetches them): github.com/creack/pty, github.com/gorilla/websocket.
# pi on the app's exact model:
tmux new-session -d -s ccdemo-live -x 120 -y 36 "pi --model google/gemini-3.5-flash"
tmux set-option -t ccdemo-live status off
tmux set-option -t ccdemo-live remain-on-exit on
tmux set-window-option -t ccdemo-live window-size latest
./ccdemo -s ccdemo-live -addr 127.0.0.1:8742 & # open http://127.0.0.1:8742/
# claude-code:
tmux new-session -d -s ccdemo-claude -x 120 -y 36 "claude"
tmux set-window-option -t ccdemo-claude window-size latest
./ccdemo -s ccdemo-claude -addr 127.0.0.1:8743 & # open http://127.0.0.1:8743/
./launch.sh [session] is a helper that does the tmux setup for pi/codex automatically.
GOTCHA: ccdemo does NOT auto-reattach. If you kill/recreate the tmux session, restart the ccdemo server, or it only shows the backfill on reload (no live stream).
With the app backend OFF (so there's no competing -CC client on the session):
./ccdemo -s <mlp-… app session> -addr 127.0.0.1:8744 & # open http://127.0.0.1:8744/
Clean here and duplicated in the app ⇒ app transport bug. Duplicated here too ⇒
CLI/config. (capture-pane reflects tmux's grid, which is never duplicated, so a clean
:8744 means the app's render path is duplicating, not the session content.)
./run_server_with_logging.sh --only-frontend # frontend (vite :51733) + Electron
Backend is separate on :18743 and must be running for the live-attach WS. The
Electron renderer caches aggressively — for reliable comparison open a plain browser at
http://localhost:51733/ instead.
-
:8742— pigemini-3.5-flash(app's model) -
:8743— claude-code -
:8744— the app's realmlp-claude-code-…session through the demo transport
Auto-synced from docs/ on main. Edit there, not here.