Skip to content

pty delivery retries flood a busy agent with empty <hcom> prompts #100

Description

@lewishowles

Human overview

I've noticed the pattern below a few times, and it appears to me as something gets "stuck" in the session and constantly polls. Usually I end up re-spawining the team member to solve the issue. I thought it was something getting stuck, or degrading in some way, or some failure causing a runaway, so I asked Claude to investigate and see if it can find any information to pinpoint what's going on. Here's the investigation.

Summary

When the receiving agent (a Claude Code session bound via pty) is busy running tool calls for an extended period, hcom's native delivery mechanism repeatedly re-injects the literal 6-character placeholder <hcom> into the terminal and presses Enter, on a roughly 10-second retry loop, without ever attaching the actual message content. Each retry surfaces to the agent as a new, content-less "the user sent a message while you were working: <hcom>" interruption. The agent has no way to distinguish these from a real new message and must poll hcom events/hcom transcript manually to find out whether anything new actually arrived. In a long busy stretch this can fire a dozen or more times, burning turns and confusing the session ("destroys the session").

Environment

  • hcom version: 0.7.23
  • OS: Darwin 25.6.0 (macOS), arm64
  • Affected instance: dev-tools-orchestrator-kazu ("kazu"), tool = claude (Claude Code CLI), bindings = hooks, pty
  • Session ID: 95cd01a0-d374-4695-833f-f1a82e007c2d
  • Senders: two codex-tool peer instances (dev-tools-scout-vito, dev-tools-implementer-nevo) in the same hcom room, sending inform/ack messages while kazu was mid-turn running back-to-back tool calls (delegating work, polling for their reports).
  • Log source: attached (28 WARN entries in the last hour at time of writing, via hcom status --logs)

Symptom, from the receiving agent's side

The conversation transcript shows repeated turns like:

<hcom>

with no accompanying message body. Contrast this with a successful delivery, which arrives as a UserPromptSubmit hook injecting full context, e.g.:

<system-reminder>
UserPromptSubmit hook additional context: <hcom>[ack #12202] dev-tools-scout-vito → ...: Acknowledged. ...
</system-reminder>

The empty variant gives the agent nothing to act on. To find out what (if anything) actually arrived, the agent has to shell out to hcom events --sql "..." or hcom transcript <name> --full and cross-reference message IDs by hand.

Root cause, from the daemon's own log

Filtering hcom.log for "instance":"kazu" delivery events over the session shows two very different code paths:

Successful delivery: cursor position advances within the verification window, e.g.:

{
	"event": "delivery.success",
	"instance": "kazu",
	"level": "INFO",
	"msg": "Cursor advanced 12272 -> 12295, delivery successful",
	"subsystem": "native",
	"ts": "2026-07-22T10:25:46Z"
}

Failed delivery: cursor position does not advance (before == current), logged as a timeout; the daemon immediately restarts the whole gate-inject-Enter cycle:

{"event":"delivery.gate_pass","instance":"kazu","level":"INFO","msg":"Gate passed, injecting to port 51794","subsystem":"native","ts":"2026-07-22T10:28:23Z"}
{"event":"delivery.injected","instance":"kazu","level":"INFO","msg":"Injected '<hcom>' (len=6, inject_attempt=10)","subsystem":"native","ts":"2026-07-22T10:28:23Z"}
{"event":"delivery.phase1_poll","instance":"kazu","level":"INFO","msg":"t=26ms input=\"<hcom>\" want=<hcom> ready=false","subsystem":"native","ts":"2026-07-22T10:28:23Z"}
{"event":"delivery.text_rendered","instance":"kazu","level":"INFO","msg":"Injected text exclusively owns the input box","subsystem":"native","ts":"2026-07-22T10:28:23Z"}
{"event":"delivery.send_enter","instance":"kazu","level":"INFO","msg":"Sending Enter key","subsystem":"native","ts":"2026-07-22T10:28:23Z"}
{"event":"delivery.text_cleared","instance":"kazu","level":"INFO","msg":"Input box cleared, verifying cursor","subsystem":"native","ts":"2026-07-22T10:28:23Z"}
{"event":"delivery.verify_timeout","instance":"kazu","level":"WARN","msg":"Cursor verify timeout (before=12295, current=12295, inject_attempt=11)","subsystem":"native","ts":"2026-07-22T10:28:33Z"}
{"event":"delivery.failed","instance":"kazu","level":"WARN","msg":"Delivery failed after 11 attempts, resetting","subsystem":"native","ts":"2026-07-22T10:28:33Z"}

This exact 10-second cycle (gate_pass → injected → phase1_poll → text_rendered → send_enter → text_cleared → verify_timeout → failed → gate_pass again) repeats continuously; see the attached kazu-delivery-events.log extract (31 lines, delivery.failed/delivery.success events only) for the full sequence.

Session totals for the kazu instance (via grep '"instance":"kazu"' hcom.log | sed -n 's/.*"event":"\([^"]*\)".*/\1/p' | sort | uniq -c):

Event Count
delivery.failed 22
delivery.success 8
delivery.verify_timeout 26
delivery.gate_pass 35
delivery.injected 35
delivery.retry 4

So roughly 3 in 4 delivery attempts failed verification during this session's busy stretches.

Mechanism

  1. hcom's native delivery, for a pty-bound Claude Code session, delivers a pending message by literally typing the placeholder string <hcom> into the terminal's input box and sending Enter. It does not type the actual message body.
  2. It then "verifies" the delivery by comparing a cursor/line-count value (before vs current) after a ~10 second window, presumably expecting the submitted prompt to have been processed (the agent to have gone idle and picked it up), which would move the terminal's output cursor forward.
  3. When the target agent is busy running tool calls (status active, not listening), the terminal's cursor position does not advance within that 10-second window purely because the agent hasn't finished its current turn yet, not because the injection failed. hcom logs this as delivery.verify_timeout / delivery.failed and immediately restarts the entire gate-wait, inject, Enter sequence.
  4. Because the gate conditions (user_active, prompt_has_text, submit_settle) do eventually pass again almost immediately, the retry re-injects <hcom> and presses Enter again, while the agent is still mid-turn. Claude Code surfaces this as a new "user sent a message while you were working: <hcom>" interruption each time, with no message content, since the content-attachment path (via the UserPromptSubmit hook) only appears to populate real content when the delivery is a genuine idle-prompt submission, not a busy-agent interruption.
  5. This repeats roughly every 10 seconds for as long as the agent stays busy, producing a stream of empty <hcom> interruptions until the agent finally goes idle, at which point the next retry's cursor delta is nonzero, delivery.success fires, and the next real message shows up correctly (with content) via the hook path.

Why it looked like "persistent empty messages" rather than "delivery is slow"

From the agent's side, every failed-then-retried attempt is indistinguishable from a brand new message arriving, because Claude Code has no way to tell "this is hcom re-injecting the same pending message" apart from "this is a new message." The agent ends up polling hcom events/hcom list -v repeatedly trying to find content that isn't there yet, burning turns, until the backlog finally clears once the agent goes idle.

Reproduction recipe

  1. Start a claude (Claude Code) hcom instance with pty binding as part of a multi-agent room.
  2. Have a peer instance send it two or more hcom send --intent inform/ack messages while the receiving instance is busy running a sequence of tool calls that together take longer than ~10-20 seconds (e.g. delegate a task to a subagent and poll for its status repeatedly).
  3. Watch hcom status --logs and ~/.hcom/.tmp/logs/hcom.log for "instance":"<name>" entries: repeated delivery.gate_passdelivery.injecteddelivery.verify_timeoutdelivery.failed cycles, roughly one every 10 seconds, for as long as the receiving instance stays busy.
  4. Correlate against the receiving agent's own transcript: each failed-cycle timestamp corresponds to a bare <hcom> interruption with no message content.

Attachments

  • kazu-delivery-events.log: filtered extract of delivery.failed/delivery.success events for the kazu instance across this session (31 lines).

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