Skip to content

file-chat: Claude agents never woken — PTY idle heuristic starved by continuous TUI repaint #1010

Description

@realproject7

Summary

In file-chat mode, Claude Code agents never receive PTY wake injections and sit idle forever, while Codex agents in the same project work fine. Root cause: the wake-delivery idle heuristic in server/pty-dispatcher.js is starved by the current Claude Code TUI, which repaints its status line continuously (~5×/sec) even when idle.

Observed on quadwork 2.7.0 with Claude Code v2.1.212 (agents launched claude --dangerously-skip-permissions --mcp-config …).

Symptom

  • Mixed fleet (head/re1 on Codex, re2/dev on Claude): Codex agents take turns normally; Claude agents show zero activity events and never respond to @mentions or the scheduled queue-check trigger.
  • The Claude agents are healthy — they boot to a normal, empty composer (bypass permissions on), no trust dialog, no crash, correct cwd/worktree. They're simply never woken.

Root cause

dispatchToAgentPTY() treats an agent as busy if it produced any PTY output in the last IDLE_THRESHOLD_MS (5000 ms):

function isAgentBusy(session) {
  return session.lastOutputAt && (Date.now() - session.lastOutputAt < IDLE_THRESHOLD_MS);
}

A mention for a "busy" agent is deferred via queuePendingWake(), whose drain only fires after a 5 s gap in PTY output — and the drain's idle timer is reset on every term.onData:

const disposable = session.term.onData(() => { resetIdleTimer(); });

The Claude Code v2.1.x TUI repaints its status line/spinner roughly every 200 ms while idle. Measured on a live idle agent: 39 output chunks in an 8 s window, max inter-chunk gap 212 ms. So:

  • isAgentBusy() is permanently true → every mention is deferred, never injected directly.
  • The pending-wake drain's 5 s idle gap never occursonData resets the timer every 200 ms → the wake is starved indefinitely.

Codex's TUI does not continuously repaint, so it clears the 5 s window and wakes normally — hence the Claude-specific symptom.

Reproduction

  1. Run a project with at least one Claude agent and one Codex agent in file-chat mode.
  2. Post a message mentioning both (or let the scheduled trigger fire).
  3. Codex agent responds; Claude agent never does. activity.jsonl shows events only for the Codex agent.

Isolated confirmation: spawning claude via node-pty and injecting text + "\r" submits correctly (the mechanism is fine); the failure is purely that the dispatcher never decides to inject, because the idle gap never arrives.

Proposed fix

The idle-gap heuristic assumes a quiescent TUI, which the current Claude Code TUI is not. Add an absolute deadline to queuePendingWake() that onData cannot postpone, so a continuously-repainting TUI can no longer starve the wake:

const MAX_DEFER_MS = 10000;
// in queuePendingWake, alongside the resettable idle timer:
state.hardCapHandle = setTimeout(drainFn, MAX_DEFER_MS);   // NOT reset by onData
// and clear it in cleanupDrainListener()

Claude queues input received mid-turn (it doesn't interrupt), so an occasional redundant wake from the cap is harmless. This restores delivery (verified locally: wake fires at ~10 s despite a simulated 200 ms repaint loop, vs. never without the cap).

A more thorough fix would base "busy" on genuine turn activity (e.g. presence of the esc to interrupt state) rather than raw PTY byte flow, but the hard cap is a low-risk unblock.

Environment

  • quadwork 2.7.0 (file-chat mode, AC removed)
  • Claude Code v2.1.212, Opus 4.8 (1M context)
  • node v24.15.0, macOS

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions