EPIC Context
- Parent: none — standalone
- Epic goal: Restore reliable file-chat wake delivery without regressing active-turn safety.
- This ticket's role: Repair Claude-specific PTY repaint starvation with a bounded, safe deferred-wake path.
- Depends on: none Enables: reliable Claude file-chat operation under continuous TUI repaint
- Contracts to respect/expose: Deferred wakes must never barge into an active Codex or Gemini turn; timer/listener state is per session key and must be fully disposed on every terminal path.
- Out of scope here (later tickets): A genuine turn-activity detector replaces raw PTY-byte heuristics later — do not implement it here.
Summary
In file-chat mode, Claude Code agents can never receive PTY wake injections when their idle TUI repaints continuously. The current wake-delivery heuristic treats any output in the last 5 seconds as busy, and the deferred-wake drain resets its only timer on every PTY chunk. A ~200 ms repaint loop therefore prevents the required 5-second quiet gap indefinitely.
The root cause is confirmed in server/pty-dispatcher.js: isAgentBusy() is based on raw output age, and both defer paths enter the same queuePendingWake() drain. A hard deadline is appropriate only for the backend with verified repaint starvation, and only with the safety contract below.
Scope
- Add a bounded deferred-wake deadline for Claude sessions only. Extend the session/spawn contract with explicit backend identity (or an equally explicit, tested dependency) so Codex and Gemini preserve their current genuine-quiescence-only behavior.
- The Claude cap must not preempt the deliberate active-send suppression window. If the cap can be armed from the
recentlySent path, either choose a cap at least ACTIVE_SUPPRESSION_MS or re-arm once for the remaining suppression duration; implement and test the selected rule.
- Keep idle-gap delivery as the preferred path. The cap is solely a starvation escape hatch for continuous Claude repaint output.
- Treat both timer handles and the onData listener as one per-key lifecycle. Clear the idle timer, hard-cap timer, and listener on idle drain, cap drain, replacement/cleanup, and session exit. A timer from one deferred-wake cycle must never consume or dispose a later cycle.
- At cap-fire time, re-resolve the session by key and require a running session with a live term before injection.
- Define repeated-mention behavior after a cap delivery: add a per-key cooldown that defers a pending wake rather than dropping it, preventing repeated cap injections during one long turn and avoiding loop-guard amplification.\n7. Give the delayed submit (
\\r) timer created by injection an explicit owner and cleanup behavior so stopping a session during its delay cannot write to a dead term.\n8. Correct the stale queuePendingWake() comment that describes an unimplemented since_id high-water mark.
Deterministic test contract
Make the cap duration injectable/overridable by the dispatcher test harness so regression tests run at millisecond scale. Extend the PTY stub tests to invoke captured onData callbacks; a test that does not drive those callbacks does not cover the bug.
Required cases:
- A sub-threshold repeating Claude repaint invokes
onData and still delivers one wake at the cap; this test fails before the fix.
- Codex and Gemini do not receive cap-driven injection during a continuously streaming active turn.
- A cap armed from the recent-send path obeys the selected active-suppression rule.
- Repeated deferred mentions yield exactly one injection per permitted cooldown window, with the next pending wake deferred rather than discarded.
- After idle drain, cap drain, and
cleanupSession, no pending wake, listener, hard-cap/idle handle, or delayed-submit handle remains; a stale callback cannot affect a subsequent cycle.
- A cap firing after the session is no longer running (or lacks a term) performs no write.
Acceptance Criteria
Non-Goals
- Do not replace raw-output busy detection with a full turn-state detector in this ticket.
- Do not change direct wake, non-file-chat behavior, or unrelated backend launch configuration.
EPIC Context
Summary
In file-chat mode, Claude Code agents can never receive PTY wake injections when their idle TUI repaints continuously. The current wake-delivery heuristic treats any output in the last 5 seconds as busy, and the deferred-wake drain resets its only timer on every PTY chunk. A ~200 ms repaint loop therefore prevents the required 5-second quiet gap indefinitely.
The root cause is confirmed in
server/pty-dispatcher.js:isAgentBusy()is based on raw output age, and both defer paths enter the samequeuePendingWake()drain. A hard deadline is appropriate only for the backend with verified repaint starvation, and only with the safety contract below.Scope
recentlySentpath, either choose a cap at leastACTIVE_SUPPRESSION_MSor re-arm once for the remaining suppression duration; implement and test the selected rule.\\r) timer created by injection an explicit owner and cleanup behavior so stopping a session during its delay cannot write to a dead term.\n8. Correct the stalequeuePendingWake()comment that describes an unimplemented since_id high-water mark.Deterministic test contract
Make the cap duration injectable/overridable by the dispatcher test harness so regression tests run at millisecond scale. Extend the PTY stub tests to invoke captured
onDatacallbacks; a test that does not drive those callbacks does not cover the bug.Required cases:
onDataand still delivers one wake at the cap; this test fails before the fix.cleanupSession, no pending wake, listener, hard-cap/idle handle, or delayed-submit handle remains; a stale callback cannot affect a subsequent cycle.Acceptance Criteria
Non-Goals