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 |
st2 dingclassifies 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>"grammaris_claude_idle_placeholderrecognizes, soclassify_claude_composerreads it as a human draft.The notice is then never transported at all.
classify_composerreturnsChanged,observed_poke_with_windowreturnsDeferredbefore the paste, andflush_pendingbreaks. Nothingis staged, nothing is logged, and the same decision is retaken every
DELIVERY_RETRY_BACKOFFfor aslong 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
One synthetic Claude-shaped pane, one pinned st2 revision, one uniquely nonced message, and a
ptyshim that records every
sendandpeek. Only the composer row differs between cases.❯ <dim>refactor the parser to use a lookup table❯ <dim>Try "add a test for the parser"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 isdeliberate 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_ansiruns before classification, so the dim attribute — the only thing on screen thatseparates Claude's placeholder from typed input — is gone by the time
Claude::classifysees therow.
Screenalready carriesrawalongsideplain, so the information is available to theadapter; 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:
rawand treat a fully-dimmed composer row as empty — narrow and positive, but itmakes the adapter depend on a styling detail the renderer does not promise;
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:
Tryplaceholder — this issue;[DING]notice still sitting in the composer — DING staged into a Claude composer is never submitted when the notice wraps: the soft-wrap guard models character wrap, Claude word-wraps #198's shape;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 peeksweep; I do not count those.How this differs from the neighbouring issues
composer. Here nothing is ever pasted and the composer holds no notice. These are visibly
different pane states and both were present on the same host at the same time.
Errarm) also produces "nothing staged,composer empty". It reaches
flush_pending'sErrarm and leaves a log line; this path reachesthe
Deferredarm and leaves none. DING sidecar: a pty command timeout is swallowed by flush_pending's catch-all Err arm, so nothing is ever staged and the log says only "timed out after 2.0s" #199 notes that 4 of its 7 "not-rendered" seats had notimeouts and no other trace and were explained by neither DING staged into a Claude composer is never submitted when the notice wraps: the soft-wrap guard models character wrap, Claude word-wraps #198 nor itself. This is a candidate
explanation for that population.
requires a paste to have happened. It did not here.
What I could not determine
placeholder undimmed. I observed one Claude Code version.
and the suggestion text was derived from their own recent content. I treat that as a correlation.
Err-arm timeouts in DING sidecar: a pty command timeout is swallowed by flush_pending's catch-all Err arm, so nothing is ever staged and the log says only "timed out after 2.0s" #199 and this path overlap on the same seat over time. Onecaution on reading timeout counts at all, since DING sidecar: a pty command timeout is swallowed by flush_pending's catch-all Err arm, so nothing is ever staged and the log says only "timed out after 2.0s" #199's population split rests on them: a
catalog-wide
pty peeksweep addstimed out after 2.0slines to sidecar logs. I watched thathappen while measuring, so contamination runs toward over-counting. It is specifically a sweep
effect: a second reader checked repeated targeted peeks of one seat and found its log's byte
length, mtime and timeout count all unchanged. Reading one seat is safe; measuring the fleet at
once is what moves the numbers.
Adjacent, and explicitly not observed in the field
While reading
flush_pendingfor this, a second dead end is visible atsrc/ding/mod.rs:937-943. Astaged head that a maintained adapter proves
NotRetainedis 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
Adoptednotice —Adopted::is_archived()returnsfalseunconditionally, so no amount ofarchiving by the agent frees one. An
Adoptedhead is not exotic: it is what a sidecar restartproduces 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_windowreturns only
Delivered,StagedorNotRetained— it has noDeferredpath — soflush_pending'sDeferred if was_stagedarm, the one that pops a head which is not in the inbox, is dead code forPtyPoker. A staged non-archived head therefore has exactly two fates: the composer keeps thepayload 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
Messagehead and does not release anAdoptedone, and restarting the sidecar againstthe 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
3e0129434ac214d46fc4cace94c7086ec486302f504ac7332895fe1fa3767b530dcd99f091f56cdax86_64-linuxPosted on behalf of @schickling
agent_nameagent_toolagent_tool_versionagent_runtimeagent_session_lookuptooling_profile