Bugfix spec: a read-only tmux observer blocks every comment the poller forwards, and the give-up is silent
Phase 1 of 3 for a bug (bugfix → design → tasks). This phase MUST be reviewed and approved before the design is derived from it.
Summary
the-loop sessions attach --read-only is the documented safe way to watch a session — the session announcement itself prints it. On tmux 3.7 and newer it breaks the session's only input path: every comment the poller forwards fails with client is read-only, the poller spends its three attempts in about three minutes, and then abandons the comment permanently. The only thing the human sees is a 😕 reaction on their own comment.
Two defects, one report. The first is the delivery: the loop's submit keystroke is refused while an observer is attached. The second is what happens after: a comment abandoned by the retry budget produces no comment on the ticket, so a human who told an agent to do something has no way to learn it was never told. Ticket: #240; the real occurrence is #238 (comment).
Steps to reproduce
- Run a work item so a tmux-mode session exists.
- Attach read-only from another terminal and leave it attached:
the-loop sessions attach --work-item <ref> --read-only(i.e.tmux attach-session -r). - Post a comment on the ticket and wait out three poll cycles.
Observed in .the-loop/logs/events.jsonl (from the reporter, tmux 3.7b / macOS 15.6):
{"event": "reaction.added", "state": "started", "content": "eyes", "target": "comment"}
{"event": "dispatch.failed", "harness": "claude", "via": "tmux",
"gh_event": "issue_comment", "delivery_id": "poll-comment-IC_kwDO…",
"error": "tmux send-keys exited 1: client is read-only", "will_retry": true}
{"event": "reaction.added", "state": "error", "content": "confused", "target": "comment"}
…
{"event": "poll.comment_failed", "comment_id": "IC_kwDO…", "attempts": 3, "will_retry": false}Expected vs actual
- Expected: an observer is an observer. A read-only client changes what that terminal can do and nothing else; the daemon's own delivery is a separate tmux client and is unaffected. If a comment is nevertheless abandoned, the ticket says so.
- Actual: on tmux ≥ 3.7 every delivery into the session fails while any read-only client is attached, and the abandonment is recorded only in the local event log and as a 😕 reaction.
Root cause (confirmed, and not the one the ticket proposed)
TmuxRunner.deliver (cli/the_loop/runner.py:637-641) submits the pasted prompt with three tmux commands, the last of which is send-keys -t <session> Enter. In tmux ≥ 3.7, send-keys gained an exec-time guard on the target client:
/* cmd-send-keys.c (master, rev 1.81 — 2026-06-11) */
struct client *tc = cmdq_get_target_client(item);
…
if (tc != NULL && tc->flags & CLIENT_READONLY && !args_has(args, 'X')) {
cmdq_error(item, "client is read-only");
return (CMD_RETURN_ERROR);
}tc is not derived from -t. send-keys carries CMD_CLIENT_CFLAG, so cmdq_fire_command (cmd-queue.c:599) resolves it as cmd_find_client(item, args_get(args, 'c'), quiet) — and with no -c that is cmd_find_current_client, which for a command client with no session of its own falls through to cmd_find_best_session(NULL, …) → cmd_find_best_client(s): the most-recently-active client attached to the session. That is the read-only observer.
flowchart TD
A["daemon runs<br/>tmux send-keys -t loop-foo Enter"] --> B
B["cmdq_fire_command: send-keys has CMD_CLIENT_CFLAG<br/>no -c given → cmd_find_client(item, NULL, quiet)"] --> C
C["cmd_find_current_client: our command client has no session<br/>→ cmd_find_best_session → cmd_find_best_client"] --> D
D["tc = the read-only observer<br/>(-t never enters this resolution)"] --> E
E["cmd_send_keys_exec: tc is CLIENT_READONLY<br/>→ 'client is read-only', exit 1"] --> F
F["dispatch.failed · will_retry: true"] --> G
G["3 attempts spent → poll.comment_failed<br/>comment written to gaveUp, never retried"] --> H
H["😕 reaction. Nothing on the ticket."]Two consequences the ticket did not have:
- It is version-gated, not universal.
send-keysacquired bothCMD_READONLYand the guard above only after 3.6. Fetchingcmd-send-keys.cat each release tag and counting the string gives3.4: 0,3.5a: 0,3.6: 0,master: 1— and reproducing on tmux 3.4 locally, with a read-only client genuinely attached (tmux list-clients -F '#{client_readonly}'→1),send-keysexits 0. So the defect appears when an operator upgrades tmux, with no the-loop change involved. - The ticket's suggested fix 1 does not work. "Resolve and cache the concrete pane id (
%N) and send to that" addresses-t, which setsitem->target(a pane) and has no part in theitem->target_clientresolution above. Asend-keys -t %0is refused by exactly the same branch.
The second defect is independent of tmux. Poller._process_comment (cli/the_loop/poller/poller.py:964-985) logs, emits poll.comment_failed, and calls resolve_comment(…, gave_up=True). Nothing writes to the ticket. The reaction machinery sets 😕 on the dispatch failure, which is a different event and says nothing about the comment having been abandoned.
Requirements
Requirement 1 — a read-only observer does not block delivery
User story: as an operator watching a session over SSH, I want to attach read-only precisely because it cannot disturb the session, so that observing costs me nothing.
Acceptance criteria (EARS)
- WHEN the poller or webhook dispatcher delivers an event into a tmux session AND a read-only client is attached to that session THEN the system SHALL deliver the prompt and submit it, exiting successfully.
- The delivery path SHALL NOT invoke
tmux send-keysfor the submit keystroke, because no invocation of it can avoid the target-client resolution described above. - WHEN the prompt is delivered THEN the system SHALL still paste the prompt bracketed, so the harness TUI receives it as a single message rather than as typed input.
- WHEN the submit is delivered THEN it SHALL arrive unbracketed, so the TUI reads it as a submit rather than as literal text inside the pasted message.
- WHILE no client is attached at all THE SYSTEM SHALL deliver exactly as it does today — the ordinary case must not regress.
Requirement 2 — an abandoned comment is reported to the human who wrote it
User story: as a collaborator who told the agent to do something on the ticket, I want to be told when that instruction was never delivered, so that I can act instead of waiting for a session that never heard me.
Acceptance criteria (EARS)
- WHEN the poller abandons a comment after exhausting
polling.maxRetriesTHEN it SHALL post a comment on that work item naming the abandoned comment, the number of attempts, and the fact that it will not be retried. - The posted comment SHALL carry the loop-prevention marker (
<!-- the-loop:agent-comment -->) and a visible attribution line, so the poller's own notice is never read back as human input. - The posted comment SHALL state the recovery, in terms the reader can act on without editing any file the-loop owns.
- WHEN posting fails for any reason — no
ghon PATH, a non-GitHub work item, an API error — THEN the give-up SHALL still be recorded exactly as it is today, and the poll cycle SHALL continue. Notifying is best-effort; the ledger is not. - Exactly one such comment SHALL be posted per abandoned comment.
- The notice SHALL contain no text taken from the abandoned comment's body, so no payload-controlled content is echoed back into a comment the-loop authors.
Requirement 3 — nothing else about delivery changes
User story: as the maintainer of the dispatch path, I want the fix confined to how the submit byte is written, so that a delivery bug cannot hide behind it.
Acceptance criteria (EARS)
- WHEN the target session is absent, or every pane in it is dead THEN
deliverSHALL reportsession_missingexactly as it does today, so the dispatcher still respawns. - WHEN any tmux command in the delivery fails for a non-terminal reason THEN
deliverSHALL return that failure withoutsession_missing, exactly as it does today. - The temporary file carrying the prompt SHALL still be removed on every path, including failures.
- The delivery SHALL leave no tmux paste buffer behind.
Requirement 4 — the regression is pinned
User story: as a future maintainer, I want a test that fails if send-keys comes back into the delivery path, so that this cannot regress silently on somebody's tmux upgrade.
Acceptance criteria (EARS)
- The fix SHALL include a regression test that fails before the fix and passes after it.
- A test SHALL assert the exact tmux argv sequence the delivery issues, so re-introducing
send-keysfails the suite rather than only failing on tmux ≥ 3.7. - A test SHALL assert that a give-up posts exactly one notice, and that a failure to post it does not change the ledger.
Security considerations
This change touches two surfaces that write outside the process: a tmux argv, and a comment posted with the operator's own GitHub credentials.
- Untrusted actors. (a) Anyone who can comment on a watched ticket controls the comment body and its author login. (b) Anyone with a shell on the machine running the daemon can attach to a tmux session. Neither is new here.
- Trust boundary — the tmux argv.
deliverbuilds a command line. The prompt already travels viamkstemp→load-bufferso its bytes never reach an argv, and this change keeps that: the submit is a second buffer whose content is a constant carriage return, not caller data. The target keeps the_LOOP_TARGET_RE-shaped name minted bytarget_for. No payload-derived string enters an argv, before or after. - Trust boundary — the posted notice.
the_loop.commentsposts with the operator's credentials, which is why R2.6 forbids echoing the abandoned comment's body: the notice is built from the comment id, the attempt count and the work-item ref, all of which are either the-loop's own values or already-validated coordinates.mark_self_authoredis applied to text the-loop wrote in full, never to foreign text — the ruleannounce.pyalready states. - Abuse case — a forged marker. A commenter can write
<!-- the-loop:agent-comment -->into their own comment and be ignored by the poller. That is true today for every comment the-loop posts and is unchanged; the marker is loop prevention, not authentication, and authorization isauthorized_users. - Abuse case — notice flooding. A ticket where every delivery fails would post one notice per abandoned comment. Bounded by the same budget that bounds the retries (
maxRetriesattempts per comment, and a comment is abandoned at most once — R2.5), so the notice rate cannot exceed the rate at which authorized users comment. - Does the fix widen what a read-only client can do? No, and this is the question worth asking, because the bug is tmux refusing a write. The read-only flag is a property of a client, and the daemon's delivery is a different client that was never read-only. Nothing here clears
CLIENT_READONLY, detaches an observer, or passes-c; the observer's own keyboard stays as inert after the fix as before it. - Fail-closed. A delivery that cannot be written still fails, and a failure still spends a retry. The notice is best-effort in one direction only: it can fail to appear, and it can never cause a comment to be treated as delivered (R2.4).
Out of scope
- Caching the pane id and targeting
%N(the ticket's fix 1). Proven ineffective above:-tis not what resolves the target client. - A pre-flight
tmux list-clients -F '#{client_readonly}'probe (the ticket's fix 2). It would add a tmux round-trip to every dispatch in order to produce a better message for a failure that, after Requirement 1, cannot occur. If a read-only rejection ever reaches the log again it will name a command the-loop no longer issues, which is itself the diagnostic. - Expiring
gaveUpentries so a later cycle retries (the ticket's fix 3). issue-146 made the re-arm gate a CLI version change on purpose:poll --oncefrom cron would otherwise re-forward abandoned comments every minute, turning a bounded give-up into the endless retry the budget exists to prevent. Two recoveries already exist and neither needs the state file touched — upgrading re-arms every comment an older version abandoned (rearm_gave_up_comments), and posting the instruction again is a new comment id with a full budget. What was missing is that nobody was told; that is Requirement 2, and the notice names both. Changing the gate itself is a separate decision. - The 😕 reaction on dispatch failure. It is about a dispatch, not about a give-up, and it stays as it is.
Open questions
None blocking. One choice is deferred to design.md: which tmux mechanism carries the submit byte, given that send-keys is ruled out by R1.2. The candidates are a second, unbracketed paste-buffer, and run-shell-style indirection; the design settles it against R1.4 and against what tmux actually guarantees for each.