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 occurs →
onData 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
- Run a project with at least one Claude agent and one Codex agent in file-chat mode.
- Post a message mentioning both (or let the scheduled trigger fire).
- 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
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.jsis 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
head/re1on Codex,re2/devon Claude): Codex agents take turns normally; Claude agents show zero activity events and never respond to@mentionsor the scheduled queue-check trigger.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 lastIDLE_THRESHOLD_MS(5000 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 everyterm.onData: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.onDataresets 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
activity.jsonlshows events only for the Codex agent.Isolated confirmation: spawning
claudevia node-pty and injectingtext + "\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()thatonDatacannot postpone, so a continuously-repainting TUI can no longer starve the wake: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 interruptstate) rather than raw PTY byte flow, but the hard cap is a low-risk unblock.Environment
AC removed)