Skip to content

DING is never transported when Claude's composer holds a context-derived placeholder: is_claude_idle_placeholder only recognizes the Try "…" grammar #200

Description

@schickling-assistant

st2 ding classifies the target's live composer before it pastes. Claude Code renders a dim,
context-derived suggestion into an otherwise-unused composer — text like refactor the parser to use a lookup table, drawn from the pane's own recent content. That is a placeholder, not typed input,
but it does not match the Try "<example>" grammar is_claude_idle_placeholder recognizes, so
classify_claude_composer reads it as a human draft.

The notice is then never transported at all. classify_composer returns Changed,
observed_poke_with_window returns Deferred before the paste, and flush_pending breaks. Nothing
is staged, nothing is logged, and the same decision is retaken every DELIVERY_RETRY_BACKOFF for as
long as the placeholder is on screen — which, on an idle pane, is indefinitely.

The observable result is a seat holding unread mail while its sidecar, its pty session and its
provider are all healthy, and while its sidecar log contains only its two lifetime lines.

Reproduction

https://github.com/schickling-repros/2026-08-st2-claude-placeholder-blocks-ding/tree/ceaf1bb88ea3d5f332fe55696cc5714a842e1110

nix run github:schickling-repros/2026-08-st2-claude-placeholder-blocks-ding/ceaf1bb88ea3d5f332fe55696cc5714a842e1110

One synthetic Claude-shaped pane, one pinned st2 revision, one uniquely nonced message, and a pty
shim that records every send and peek. Only the composer row differs between cases.

composer row result
❯ <dim>refactor the parser to use a lookup table RED — 0 transports across 2 retry cycles
the same pane, composer emptied in place mid-run GREEN — transported once
❯ <dim>Try "add a test for the parser" GREEN — transported once
BASELINE_COMPOSER_OBSERVATIONS=3
BASELINE_NOTICE_TRANSPORTS=0
BASELINE_MESSAGE_STILL_UNREAD=yes
BASELINE_SIDECAR_LOG_LINES=2
BASELINE_SIDECAR_ALIVE=yes
BASELINE_TARGET_ALIVE=yes
BASELINE_RESULT=RED
CONTROL_EMPTY_COMPOSER_NOTICE_TRANSPORTS=1
CONTROL_EMPTY_COMPOSER_RESULT=GREEN

The in-run control is the isolating one: same sidecar, same pty session, same still-unread message,
same retry loop. Only the composer row changes, and the notice that had not moved for two retry
cycles is transported within one.

The Try "…" case separates two readings. Refusing to type into a composer holding a human draft is
deliberate and correct. That case shows the adapter already intends to treat a placeholder as an
empty composer — it just does not recognize this variant of one.

I also replayed a real stuck pane's captured screen bytes through the same pinned binary rather than
only the synthetic fixture: RED. Deleting exactly the placeholder substring from those same bytes,
changing nothing else, makes the identical message transport once. Those captures are from private
panes so they are not in the public repository, but the synthetic fixture reproduces the same
classification input, byte-for-byte in the parts the classifier reads: U+276F, U+00A0, ESC[2m,
then the suggestion.

Why the styling matters

strip_ansi runs before classification, so the dim attribute — the only thing on screen that
separates Claude's placeholder from typed input — is gone by the time Claude::classify sees the
row. Screen already carries raw alongside plain, so the information is available to the
adapter; the Claude adapter just does not use it. The comment at src/ding/harness/claude.rs:58
("Claude only shows the rotating placeholder on an unused pane") is accurate about when the
placeholder appears; what has changed is that its text is no longer always the Try "…" shape.

I am not proposing a fix. Two directions look plausible and they trade differently, which is why I
would rather ask than guess:

  • classify on raw and treat a fully-dimmed composer row as empty — narrow and positive, but it
    makes the adapter depend on a styling detail the renderer does not promise;
  • widen the recognized placeholder grammar — no new dependency, but every widening moves the line
    between "placeholder" and "human draft", which is exactly the guarantee the current narrow grammar
    is buying.

Field shape

One measurement on one host. Of 203 seat inboxes holding unread mail, a Claude-shaped composer was
locatable on 53. Of those 53:

All 22 held a dim placeholder rather than undimmed typed input; none was in an active turn; all had
the maintained idle footer. Between them the 22 held 59 undelivered messages, the oldest queued for
over an hour.

Scope of those numbers, since they are easy to over-read: 203 is not a fleet size — inbox
directories persist for retired seats — and the 150 panes with no locatable composer include
non-Claude harnesses and sessions I could not peek, so they are evidence in neither direction. It is
also a snapshot rather than a steady state; seats enter and leave this condition as their panes go
active.

At first inspection, before any diagnostic sweep of my own, the two seats I looked at had sidecar
logs containing exactly their two lifetime lines: no timeout, no error, no trace of any decision at
all, which is what this path produces. Timeout lines appeared on those same logs later, coinciding
with my own catalog-wide pty peek sweep; I do not count those.

How this differs from the neighbouring issues

What I could not determine

Adjacent, and explicitly not observed in the field

While reading flush_pending for this, a second dead end is visible at src/ding/mod.rs:937-943. A
staged head that a maintained adapter proves NotRetained is released only when it is archived,
which is what #152 fixed. Everything else stays staged.

Three kinds of head are never archived, so that release can never fire for them: an unread message;
the coalesced Recovery notice, while any of its startup filenames is still unread; and every
Adopted notice — Adopted::is_archived() returns false unconditionally, so no amount of
archiving by the agent frees one. An Adopted head is not exotic: it is what a sidecar restart
produces whenever the previous generation left a notice in the composer.

The escape that would otherwise cover this is not reachable in production. retry_staged_with_window
returns only Delivered, Staged or NotRetained — it has no Deferred path — so flush_pending's
Deferred if was_staged arm, the one that pops a head which is not in the inbox, is dead code for
PtyPoker. A staged non-archived head therefore has exactly two fates: the composer keeps the
payload until it is provable and idle, and it is submitted; or the composer loses it on a screen the
adapter can parse, and the head stays staged for the life of the sidecar, holding the FIFO behind it.

Put the other way round: positive proof that the payload is gone is the terminal state, while
ambiguity would have released it.
That is inverted with respect to the fail-closed handling
everywhere else in this file.

PR #152's decision list states that "unread and adopted notices [stay] staged after NotRetained,
preventing a later fresh re-paste", so this is a recorded choice rather than an oversight.

This is now reproduced separately in #206, with a runnable two-arm artifact: archiving releases
an unread Message head and does not release an Adopted one, and restarting the sidecar against
the same pane transports the same stuck message immediately. It is a different defect from the one
this issue reports — nothing is ever staged here — and it is filed on its own rather than folded in.
I still have not observed the transition on a live seat; every staged head I have looked at in the
field still holds its payload.

Versions

  • st2: 3e0129434ac214d46fc4cace94c7086ec486302f
  • pty: 504ac7332895fe1fa3767b530dcd99f091f56cda
  • platform: x86_64-linux
  • provider: Claude Code 2.1.220
Posted on behalf of @schickling
field value
agent_name unknown
agent_tool Claude Code
agent_tool_version 2.1.220
agent_runtime Claude Code 2.1.220
agent_session_lookup unavailable
tooling_profile dotfiles@unknown-dirty

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:dingDING delivery: inbox notice into a running agent · Set: manualharness:claudeClaude Code-specific behavior · Set: manualorigin:agentFiled or primarily produced by an AI agent · Set: manualtype:bugSomething broken or a regression · Set: manual

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions