Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions context/knowledge/gotchas/pty-terminal.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
- **Single-reader-tee pattern is critical.** Two goroutines reading the same fd causes data loss.
- **`AddWriter` must replay before registering.** Register first → live bytes arrive before replay → duplicate data → rendering corruption.
- **x/vt `SafeEmulator` hangs on terminal query sequences.** Use `newDrainedEmulator()` which starts `go io.Copy(io.Discard, emu)` to drain the response pipe. Never use bare `xvt.NewSafeEmulator()` in tui.
- **Discarding query responses silently degrades color-aware agent CLIs — the pane's LIVE emulator now answers instead.** `NewDrainedEmulator`'s `io.Discard` drain (above) avoided a hang but also meant an agent process's `OSC 10`/`OSC 11` ("what's your fg/bg color?") queries got no reply. Codex sends this on every startup and, per a captured real session, never draws its composer/placeholder highlight background in any occurrence when the query goes unanswered — it only ever colors the separately-unconditional submitted-prompt history box (bare `\x1b[7m`). Fix (`NewLiveEmulator`, used only by `newTrackedEmulatorWithCallback`, the pane's live-emulator path): set `SetBackgroundColor`/`SetForegroundColor` to an assumed dark-terminal default (Argus itself never assumes a background — chrome uses `tcell.ColorDefault` throughout — so this is a best-effort guess, not a queried truth), and forward the drained response bytes into `TerminalAdapter.WriteInput` (looked up fresh under `tp.mu` per call, since the session can change across attach/detach) instead of `io.Discard`. Replay/preview emulators (`newTrackedReplayEmulatorWithCallback`, `previewvt.go`) deliberately keep the discard-only behavior — they reconstruct historical output for a process that may not be live, so forwarding would be meaningless or cross-wired.
- **x/vt can panic on replay from differently-sized terminals.** Use `safeEmuWrite()` which wraps with `recover()`.
- **Cursor rendering respects `CursorVisibility` callback.** Tracked via `cursorVisible` field, updated by x/vt callback. Defaults to `false` on emulator creation.
- **Plugin terminalpane cursor: force emulator to hidden state in `New()` or the first `\e[?25h` is a silent no-op.** `xvt.SafeEmulator` starts with cursor visible (`Hidden=false`). The `CursorVisibility` callback only fires on state CHANGES (`changed := s.cur.Hidden != hidden`). So if you initialize tracking to `false` (via `atomic.Bool` zero value) but leave the emulator in the visible state, the plugin's first `\e[?25h` hits the emulator already visible → no change → callback doesn't fire → cursor stays hidden in tracking. Fix: write `\x1b[?25l` directly to the emulator in `New()` BEFORE starting the consumer goroutine; this changes the emulator's state from visible to hidden, fires the callback (sets tracking to `false`), and ensures the first `\e[?25h` triggers the callback correctly. The task terminal uses a different fix (calls the callback manually with `false` and relies on agents always sending `\e[?25l` early) — both approaches are valid for their respective contexts.
Expand Down
2 changes: 1 addition & 1 deletion context/knowledge/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ Non-obvious invariants and gotchas, split by topic. Read the relevant file when
| File | Topic | Bullets |
| --- | --- | --- |
| [gotchas/daemon-rpc.md](gotchas/daemon-rpc.md) | Daemon lifecycle, RPC timeouts, reconciliation races, session resume, Claude /clear recapture, binary staleness (SHA-256 content hash, not mtime), self-update, launchd auto-start + PATH, stream Since offset, paste-boundary flush, *.test fork-bomb backstop, singleton flock, PR poller (eligibility, terminal-state skip, batched per-repo graphql w/ alias-safe ids + chunked keep-stale), evidence-based completion (ExitInfo.CleanExit predicate; reconcile→InReview never Complete), hera worker finish policy (BUG-050 RollHeraWorkerToReview), startup hera-binding reconciliation, session-supervisor P1–P4 (dark PTY-owner, daemon-as-client behind cfg.Supervisor.Enabled, re-attach on bounce, default ON + in-process rollback, #707 cache-vs-EOF relay race), callWithTimeout nil-rpc guard, TUI supervisor restart, go-install skew (doctor restart-vs-path-divergence, supervisor-checked-on-auto-start, ProtocolVersion 2→3 old-supervisor-unknown, double-confirm supervisor restart), revive-restores-in_progress (BUG-B ReviveHeraWorkerToInProgress, inverse of RollHeraWorkerToReview), host-suspend watchdog (ARGUS_HOST_SUSPENDED advisory note — wall-clock gap>3m between 30s ticks, unconditional not Hera-gated, sibling of sendBounceSignals, one-shot no-dedup baseline-before-loop, monotonic-strip required, advisory-only no state mutation), Claude Code's own background-session supervisor (orphaned-worker root cause: single-PID SIGTERM can never reach a session Claude Code itself detached to its per-user supervisor; `claude agents`/`claude stop` detection+fix SHIPPED via internal/claudeagents + Runner.Stop fire-and-forget reap, not a signal-scoping bug), doctor Stop-hook registration check (detect-missing-coord-hook: REGISTERED/NOT REGISTERED/UNKNOWN, advisory-only, never gates the binary-coherence exit code), resume-time session-ID recapture (agent.RefreshResumeSessionID mirrors the exit hook because hera workers idle/StreamLost never reach captureSessionIDPostExit; Claude-only; wired at reattachSupervised orphans + TUI startSession + REST resume/restart; idempotent, never blanks/fabricates) | 107 |
| [gotchas/pty-terminal.md](gotchas/pty-terminal.md) | PTY sizing, x/vt emulator, ring buffer, replay cache, paint cache, lazyScreen, test concurrency, ESC-boundary alignment, live rebuild from log tail, monotonic firstByteOffset, scrollOffset clamp, waitLoop close-after-drain order, rerender gates (unchanged-cols, cache invalidation, blocked-on-prompt), OSC 0x9C-in-UTF8 strip filter, persistent preview emulator (PreviewVT reuse-via-RIS), plugin terminalpane cursor sync, alt-screen keyboard-scroll + scroll-mode-entry suppression (BUG-031, not just the wheel) | 60 |
| [gotchas/pty-terminal.md](gotchas/pty-terminal.md) | PTY sizing, x/vt emulator, ring buffer, replay cache, paint cache, lazyScreen, test concurrency, ESC-boundary alignment, live rebuild from log tail, monotonic firstByteOffset, scrollOffset clamp, waitLoop close-after-drain order, rerender gates (unchanged-cols, cache invalidation, blocked-on-prompt), OSC 0x9C-in-UTF8 strip filter, persistent preview emulator (PreviewVT reuse-via-RIS), plugin terminalpane cursor sync, alt-screen keyboard-scroll + scroll-mode-entry suppression (BUG-031, not just the wheel), live-emulator OSC 10/11 query answering (NewLiveEmulator forwards to TerminalAdapter.WriteInput instead of discarding, replay/preview stay discard-only) | 61 |
| [gotchas/ui-threading.md](gotchas/ui-threading.md) | tview thread safety, tick-goroutine rules, lazyScreen fill invariant, paste/input batching, tmux UX-tearing post-mortem (no Sync; 3 legit repair callsites), OnBranchChange log-only contract, EventFocus drift recovery, stderr/stdout-after-Init fd 2 guards, status-bar notice auto-expire (15s TTL, lazy revert via 1s tick, no Sync/timer) | 27 |
| [gotchas/ci-gates.md](gotchas/ci-gates.md) | `make pre-pr` per-gate failure recipes (fmt-check, test-cover-gate floor, lint-pr new-from-rev, vuln stdlib continue-on-error) | 4 |
| [gotchas/sandbox.md](gotchas/sandbox.md) | macOS sandbox-exec SBPL profiles, symlink resolution, allowed paths, Chrome support dir for Playwright, AppleEvent allowlist, Messages.app legacy alias, picker modal, TCC re-prompt fix (stable-signed local binary via workflow-neutral `make install-signed`; first-sign Keychain "Always Allow") | 26 |
Expand Down
63 changes: 62 additions & 1 deletion internal/tui/terminal/terminalpane.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,53 @@ func NewDrainedEmulator(cols, rows int) *xvt.SafeEmulator {
return emu
}

// assumedTerminalBG/FG answer OSC 10/11 (foreground/background color)
// queries from a live emulator (see NewLiveEmulator). Argus itself never
// assumes a background — its own chrome uses tcell.ColorDefault everywhere
// — so these are a best-effort dark-terminal guess, not a queried truth:
// they exist only so an agent CLI that conditions its own styling on a color
// query (Codex skips a background-dependent composer highlight when it
// can't determine the terminal's background — see gotchas/pty-terminal.md)
// gets *an* answer instead of the query going unanswered.
var (
assumedTerminalBG = color.RGBA{A: 0xff}
assumedTerminalFG = color.RGBA{R: 0xe5, G: 0xe5, B: 0xe5, A: 0xff}
)

// NewLiveEmulator creates an x/vt SafeEmulator for a pane's live agent
// session. Like NewDrainedEmulator, it drains the emulator's internal
// response pipe so terminal query sequences (DA1, DA2, DSR, OSC 10/11, etc.)
// never hang Write(). Unlike NewDrainedEmulator, the emulator reports
// assumedTerminalFG/BG and its generated response bytes are passed to
// forward (writing them to the agent process's stdin) instead of discarded
// — so a color-aware agent CLI sees a normal, answering terminal. forward
// may be called after the pane's session has changed or gone away; it is
// responsible for no-op'ing safely in that case.
//
// Only use this for a pane's live emulator. Replay/preview emulators
// reconstruct historical output for a process that isn't listening (or
// isn't the same live process) — forwarding responses into them would be
// meaningless at best and cross-wired at worst, so they keep using
// NewDrainedEmulator.
func NewLiveEmulator(cols, rows int, forward func([]byte)) *xvt.SafeEmulator {
emu := xvt.NewSafeEmulator(cols, rows)
emu.Emulator.SetBackgroundColor(assumedTerminalBG)
emu.Emulator.SetForegroundColor(assumedTerminalFG)
go func() {
buf := make([]byte, 4096)
for {
n, err := emu.Read(buf)
if n > 0 && forward != nil {
forward(buf[:n])
}
if err != nil {
return
}
}
}()
return emu
}

// newDrainedReplayEmulator creates an emulator with a large scrollback buffer
// for scrollback browsing. This avoids frequent rebuilds when scrolling up
// through long session output.
Expand Down Expand Up @@ -1678,7 +1725,7 @@ func (tp *TerminalPane) newTrackedEmulator(cols, rows int) *xvt.SafeEmulator {
}

func (tp *TerminalPane) newTrackedEmulatorWithCallback(cols, rows int, onCursorVisible func(bool)) *xvt.SafeEmulator {
emu := NewDrainedEmulator(cols, rows)
emu := NewLiveEmulator(cols, rows, tp.forwardEmulatorResponse)
if onCursorVisible != nil {
emu.Emulator.SetCallbacks(xvt.Callbacks{
CursorVisibility: onCursorVisible,
Expand All @@ -1695,6 +1742,20 @@ func (tp *TerminalPane) newTrackedEmulatorWithCallback(cols, rows int, onCursorV
return emu
}

// forwardEmulatorResponse writes a live emulator's generated query-response
// bytes (OSC 10/11 answers, etc.) into the pane's current session, if any.
// The session is looked up fresh under tp.mu on every call rather than
// captured once at emulator-creation time, because the pane's session can
// change (attach/detach, reconnect) across the emulator's lifetime.
func (tp *TerminalPane) forwardEmulatorResponse(p []byte) {
tp.mu.Lock()
sess := tp.session
tp.mu.Unlock()
if sess != nil {
_, _ = sess.WriteInput(p)
}
}

// newTrackedReplayEmulatorWithCallback creates a replay emulator with a large
// scrollback buffer (50K lines) for scrollback browsing in long sessions.
func (tp *TerminalPane) newTrackedReplayEmulatorWithCallback(cols, rows int, onCursorVisible func(bool)) *xvt.SafeEmulator {
Expand Down
133 changes: 130 additions & 3 deletions internal/tui/terminal/terminalpane_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"

Expand Down Expand Up @@ -147,11 +148,28 @@ type mockAdapter struct {
alive bool
totalWritten uint64
output []byte

writeMu sync.Mutex
written []byte
}

func (m *mockAdapter) WriteInput(p []byte) (int, error) {
m.writeMu.Lock()
m.written = append(m.written, p...)
m.writeMu.Unlock()
return len(p), nil
}

func (m *mockAdapter) WriteInput(p []byte) (int, error) { return len(p), nil }
func (m *mockAdapter) Resize(rows, cols uint16) error { return nil }
func (m *mockAdapter) RecentOutput() []byte { return m.output }
// Written returns a snapshot of everything WriteInput has received so far.
// Safe to call concurrently with WriteInput (used to poll for bytes forwarded
// from a background emulator-drain goroutine).
func (m *mockAdapter) Written() []byte {
m.writeMu.Lock()
defer m.writeMu.Unlock()
return append([]byte(nil), m.written...)
}
func (m *mockAdapter) Resize(rows, cols uint16) error { return nil }
func (m *mockAdapter) RecentOutput() []byte { return m.output }
func (m *mockAdapter) RecentOutputTail(n int) []byte {
if n >= len(m.output) {
return m.output
Expand Down Expand Up @@ -662,6 +680,115 @@ func TestNewTrackedEmulator_DefaultCursorHidden(t *testing.T) {
}
}

func TestNewLiveEmulator_AnswersBackgroundColorQuery(t *testing.T) {
got := make(chan []byte, 1)
emu := NewLiveEmulator(80, 24, func(p []byte) {
got <- append([]byte(nil), p...)
})
if _, err := emu.Write([]byte(ansi.RequestBackgroundColor)); err != nil {
t.Fatalf("Write: %v", err)
}

want := ansi.SetBackgroundColor(ansi.XRGBColor{Color: assumedTerminalBG}.String())
select {
case p := <-got:
if string(p) != want {
t.Fatalf("forwarded response = %q, want %q", p, want)
}
case <-time.After(2 * time.Second):
t.Fatal("forward callback was never invoked for an OSC 11 query")
}
}

func TestNewLiveEmulator_AnswersForegroundColorQuery(t *testing.T) {
got := make(chan []byte, 1)
emu := NewLiveEmulator(80, 24, func(p []byte) {
got <- append([]byte(nil), p...)
})
if _, err := emu.Write([]byte(ansi.RequestForegroundColor)); err != nil {
t.Fatalf("Write: %v", err)
}

want := ansi.SetForegroundColor(ansi.XRGBColor{Color: assumedTerminalFG}.String())
select {
case p := <-got:
if string(p) != want {
t.Fatalf("forwarded response = %q, want %q", p, want)
}
case <-time.After(2 * time.Second):
t.Fatal("forward callback was never invoked for an OSC 10 query")
}
}

func TestNewLiveEmulator_NilForwardDoesNotPanic(t *testing.T) {
emu := NewLiveEmulator(80, 24, nil)
if _, err := emu.Write([]byte(ansi.RequestBackgroundColor)); err != nil {
t.Fatalf("Write: %v", err)
}
// Give the drain goroutine a chance to run; there is nothing to
// assert beyond "this didn't panic".
time.Sleep(10 * time.Millisecond)
}

func TestTerminalPane_ForwardEmulatorResponse_NoSession(t *testing.T) {
tp := NewTerminalPane()
// No session attached — must no-op rather than panic.
tp.forwardEmulatorResponse([]byte("anything"))
}

func TestTerminalPane_ForwardEmulatorResponse_ForwardsToSession(t *testing.T) {
tp := NewTerminalPane()
sess := &mockAdapter{alive: true}
tp.SetSession(sess)

tp.forwardEmulatorResponse([]byte("hello"))

if got := string(sess.Written()); got != "hello" {
t.Fatalf("session received %q, want %q", got, "hello")
}
}

func TestTerminalPane_LiveEmulatorForwardsQueryResponseToSession(t *testing.T) {
tp := NewTerminalPane()
sess := &mockAdapter{alive: true}
tp.SetSession(sess)

emu := tp.newTrackedEmulatorWithCallback(80, 24, func(bool) {})
if _, err := emu.Write([]byte(ansi.RequestBackgroundColor)); err != nil {
t.Fatalf("Write: %v", err)
}

deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(sess.Written()) > 0 {
break
}
time.Sleep(time.Millisecond)
}
if len(sess.Written()) == 0 {
t.Fatal("expected the live emulator's OSC 11 response to be forwarded to the attached session")
}
}

func TestNewTrackedReplayEmulator_DoesNotForwardResponses(t *testing.T) {
tp := NewTerminalPane()
sess := &mockAdapter{alive: true}
tp.SetSession(sess)

// The replay/preview emulator constructor must stay discard-only: it
// takes no forward callback and must never write into the attached
// session even though one is present.
emu := tp.newTrackedReplayEmulatorWithCallback(80, 24, func(bool) {})
if _, err := emu.Write([]byte(ansi.RequestBackgroundColor)); err != nil {
t.Fatalf("Write: %v", err)
}

time.Sleep(50 * time.Millisecond)
if got := sess.Written(); len(got) != 0 {
t.Fatalf("replay emulator forwarded a response to the live session: %q", got)
}
}

func TestPaintEmu_HiddenCursorNoContentExtension(t *testing.T) {
// When cursor is hidden and at (0, lastRow), paintEmu should NOT extend
// lastContentRow to include the cursor — otherwise a phantom cursor cell
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Answer live-emulator terminal capability queries instead of discarding them

## Why

Argus's live PTY emulator (`x/vt`) deliberately discards any response it
generates to terminal capability queries — `NewDrainedEmulator` drains the
emulator's response pipe to `io.Discard` specifically to avoid a hang bug
(the emulator's internal `io.Pipe` blocks forever if nobody reads it; see
`gotchas/pty-terminal.md`). That fix was correct for the hang, but it also
means an agent CLI that queries the terminal at startup — e.g. Codex sends
`OSC 11` ("what's your background color?") on every session start — never
gets an answer, because nothing forwards the emulator's generated reply back
into the child process's stdin.

Confirmed via a real Codex v0.144.5 session log captured from a live Argus
task: Codex sends `OSC 10`/`OSC 11` queries at startup, and across every
occurrence of its idle composer/placeholder text in that session, it draws
zero background/reverse-video styling — it only colors the *submitted
prompt* history box (a separate, unconditional `\x1b[7m`). This is
consistent with Codex conservatively skipping a background-dependent
highlight it can't safely draw without knowing the terminal's background —
and matches the reported symptom exactly: the same composer state shows a
highlighted background band when Codex runs in a real terminal (which
answers OSC 11), but not when nested in Argus (which doesn't).

## What Changes

- The pane's **live** `x/vt` emulator now reports an assumed terminal
background/foreground color (`SetBackgroundColor`/`SetForegroundColor`) and
forwards its auto-generated query responses (OSC 10/11, and anything else
`x/vt` answers) into the agent process's stdin via the existing
`TerminalAdapter.WriteInput`, instead of discarding them. The drain-to-avoid-hang
behavior is preserved — responses are still drained asynchronously, just
routed to the real PTY instead of `io.Discard`.
- Replay and preview emulators (scrollback browsing, task-list preview) are
**unchanged** — they reconstruct historical output for a process that may
not be running (or isn't the same live process), so forwarding responses
into them would be meaningless or cross-wired. They keep using the existing
discard-only `NewDrainedEmulator`.

## Capabilities

### Modified Capabilities

- `terminal-rendering`: the live PTY emulator additionally answers terminal
capability queries (OSC 10/11 today) by forwarding a real response into the
agent process, using an assumed background/foreground color rather than
silence.

## Impact

- **Modified code:** `internal/tui/terminal/terminalpane.go` — new
`NewLiveEmulator` constructor (used only by the pane's live emulator path,
`newTrackedEmulatorWithCallback`); a `forwardEmulatorResponse` method that
looks up the pane's current session under its existing mutex (session can
change across attach/detach during the emulator's lifetime) and writes to
it if non-nil.
- **No breaking changes.** Additive behavior on the live emulator only;
replay/preview paths and the existing hang-avoidance drain are untouched.
- **Not addressed here:** querying the *real* outer terminal (the one Argus
itself runs inside) for its actual background/foreground color and
forwarding that true value through. Argus's own chrome never assumes a
background (`tview.Styles.PrimitiveBackgroundColor = tcell.ColorDefault`
everywhere), so there's no existing "real" color to relay — doing this
properly would mean Argus querying its own controlling terminal over its
own stdout/stdin, which risks conflicting with tcell's ownership of that
fd's raw-mode state. Out of scope for this fix; the assumed dark-terminal
default is a deliberate, named simplification, not an oversight.
Loading
Loading