Repository navigation
exec: nudge an agent when a pad moves, so nobody has to arm a wait - #27
Merged
Merged
Conversation
… was lost, not late Run() seeded its snapshot and only then registered the directory watches. A write landing in that gap is recorded as the starting state, so it emits nothing at the time AND matches the snapshot at the next rescan — lost, not delayed. Nothing in the store could tell afterwards that it had happened. Ready() closes after BOTH steps, so a caller whose next act would CAUSE such a write can wait for the watcher to be live first. It never closes if Run was not called, hence the bounded waits at the call sites. Found by a relay test that failed once in five runs: the agent registered, posted, and the post never woke anybody. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…has to arm a wait
Waiting is the one thing agents reliably fail at. `pad wait` prints one section and
exits, so staying reachable means re-arming after every turn — and an agent that is
mid-task, or that simply forgot, is one the other side is talking to in vain. Only
one host tested here even has a background mechanism whose exit reaches the agent.
internal/ptyrun runs the agent on a pty this process owns. That is not a style
choice: there is no way to put a line into another process's input from outside
(`TIOCSTI` is disabled on modern Linux, unavailable safely on macOS), and being the
parent is also the only way to KNOW the child is alive — from wait(), not by reading
its screen and guessing whether that prompt belongs to an agent or to the shell that
replaced it.
internal/relay holds the registry, the store watch and the control socket:
- What is delivered is a POINTER (`pad read <ref> --since <n>`), never another
agent's words. Content pasted into a prompt as if the operator typed it is
command injection whatever state the receiver is in.
- Registration is an ACT, not a claim: it arrives over this process's own socket
from a child it spawned. Two sessions may both call themselves `backend`, and an
author name only means anything WITHIN one pad — so the name is never what is
trusted. Same law as PostRequest.SystemPost.
- Nudges COALESCE rather than queue: every nudge for a pad says the same thing, so
a later one subsumes an earlier one. A pad already nudged about waits for a sign
the agent acted, with renudgeInterval underneath so nothing goes quiet for good.
- `since` moves only when the agent says so by registering again. A nudge is not
evidence anybody read anything, and a pointer that walked forward on its own
would let an agent skip sections by following only the newest nudge.
The registry is in memory and dies with the process: a registration is meaningless
once the terminal it points at is gone, and on disk it would outlive the machine's
last reboot. Socket names carry a random suffix because pids are REUSED — binding is
safe without it, but a grandchild that outlived a crashed agent still holds
SCRATCHPAD_RELAY in its environment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng is how it joins `scratchpad exec -- claude` is the whole surface. The agent inherits SCRATCHPAD_RELAY, so `pad create`, `pad post` and `pad get --as` register the pad and how far it has read: joining a pad IS posting to it, and there is nothing to arm, remember or re-arm. The RESULT says so — `relay: watching <ref> from §<n>` on stdout, plus a note that no wait is needed. The skill document says the same, but that is a copy on disk which can be months old and which not every host loads; the agent decides whether to arm a wait at exactly this moment, so the answer belongs here. A FAILED registration is louder: it used to be swallowed, leaving the agent believing somebody was listening for it, and now warns to arm a wait — without failing the command, since the pad file was written before any of this ran. `exec` is transparent (the agent's exit status is this process's) and opt-in: it sits between the operator's keyboard and the agent for a whole session, and nothing else in the tool depends on it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…shell receives it `exec` types into whatever is on the other end of the pty, and that is not always the agent: a crashed one leaves the shell it was launched from, which EXECUTES what it is given. So the nudge is a valid command whose entire behaviour is to print its arguments. Two shapes were tried first. A bare sentence is harmless only by luck — zsh refuses `[scratchpad]` as an empty glob while bash reports `command not found`, so it depends on which shell the operator uses. Sending `pad read` itself RUNS, printing a whole pad into a terminal nobody is reading and teaching the pattern that a nudge is something to execute — one step from delivering content that way. The message is single-quoted (relay.shellQuote) so the pointer inside stays inert; flag parsing is off so `--since 2` outside the quotes is text rather than an unknown flag; and it cannot fail, because a notification that turns into an error is one more thing to deal with. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…and a deaf one are both silent A relay that is nudging and one that never heard of your pad look identical from outside — both are silence — and that is the whole question when nothing arrives. `scratchpad relay` names, per pad, the identity registered, the section a nudge points at, and whether one is due, held, or already delivered and waiting on the agent. It separates the cases: no relay running, a relay watching nothing (the agent has not joined), a pad registered under a name the traffic is not addressed to, or a nudge that WAS delivered and simply not acted on. `exec --log` appends registrations and nudges to a file — never the screen, which belongs to the agent's UI for the whole session. Bare `--log` means <dir>/relay.log: one file per store, appended, with the writing relay's pid on each line, because a log whose name changes every run cannot be followed with `tail -f` and following it is why anybody turns it on. Relays are matched on the dir they REPORT, not on where their socket sits: a dir with a long path pushes its socket into the shared system temp dir, and looking only inside the dir reported "no relay running" while one was running perfectly well. doctor reports the dir's own dead sockets, and only those — a dead file in the shared temp dir cannot be attributed to a store. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ce moves out
Measured on a real session: Codex reads the first 240 lines and only then goes back
for more. A skill document past that is both noisy and, at the tail, unread — and
this one had grown to 457.
CLAUDE.md already says what belongs here: SKILL.md is the short document an agent
ACTS on, the topics are the reference it consults. What prevents a wrong action
stays; what explains how to use something goes to `skills docs usage`, which already
covered all of it (checked before cutting, not after):
- writing rules (36 lines → 5 and a pointer)
- the `pad search` walkthrough (~30 → 3, keeping only --oldest, the part agents
get wrong: a term under active argument buries its own definition)
- full-pad/continuation detail (46 → 13)
Added instead: a "When a command fails" table — nine error codes, one line each,
saying the RIGHT next move. It replaces prose scattered through the file and
recovered not_rules_owner, rules_conflict and limit_exceeded, which the first pass
had dropped. Every code and flag was grep-checked against the old file afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nd the README DESIGN.md gets the section that explains why any of this is shaped the way it is — why the parent process, why a pointer, why coalescing, why the registry is in memory, why the Enter is written separately from the line (a TUI treats a burst of bytes as a PASTE, where Enter means newline; measured with Codex, text and \r in one write sat in the composer indefinitely). config.md gets SCRATCHPAD_RELAY, the dir layout (relays/, relay.log) and how to look inside a running relay — the HARD RULE about keeping the embedded guide in step with the schema. The topics and README follow. Not touched: the website. It is staying off until the feature has been used for a while. internal/skills/topics/mcp.md is also deliberately silent — `exec` needs to be the agent's parent process, and an MCP host owns its own process and terminal. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Waiting is the one thing agents reliably fail at.
pad waitprints one section and exits, so staying reachable means re-arming it after every turn — and an agent that is mid-task, or that simply forgot, is one the other side is talking to in vain. Of the hosts tested here only Claude Code has a background mechanism whose exit reaches the agent at all; Codex can only block.scratchpad exec -- <agent>moves the listening off the agent. It runs on a pty this process owns, and when a pad it takes part in moves, one line is typed into its input:Nobody arms anything. Joining a pad is posting to it.
Why the parent process
There is no way to put a line into another process's input from outside — a pid answers only whether something is alive (
kill -0), andTIOCSTIis disabled on modern Linux and unavailable safely on macOS. Owning the pty is the only arrangement that can both know the child is alive (fromwait(), not by reading its screen and guessing whether that prompt belongs to an agent or the shell that replaced it) and type into it.Three properties, and what each one prevents
What arrives is a pointer, never content. Another agent's words pasted into a prompt as if the operator had typed them is command injection whatever state the receiver is in.
Registration is an act, not a claim. It arrives over this process's own socket from a child it spawned. Two sessions may both call themselves
backend— an author name only means something within one pad — so the name is never what is trusted. Same law asPostRequest.SystemPost.Nudges coalesce, they do not queue. Every nudge for a pad says the same thing, so a later one subsumes an earlier one: five new sections are one line, three pads are one line. A pad already nudged about waits for a sign the agent acted, with a slow floor so nothing goes quiet for good.
sincemoves only when the agent registers again — a nudge is not evidence anybody read anything, and a pointer that walked forward on its own would let an agent skip sections by following only the newest one.What running it for real changed
Every one of these came from a live session, not from review:
\rin one write sat in the composer indefinitely; the same\ralone submitted at once.[scratchpad]as an empty glob, bash sayscommand not found), sonotificationexists and only prints.execwas leaking a socket per run — it exited before the relay finished cleaning up.relayreported "no relay running" while one ran fine: a long dir path pushes its socket into the shared temp dir, and the lookup only searched the dir.internal/watchfix, worth its own lookRun()seeded its snapshot and only then registered the directory watches. A write landing in that gap is recorded as the starting state, so it emits nothing at the time and matches the snapshot at the next rescan — lost, not late, with nothing afterwards to show it happened.Ready()now closes after both steps.Found by a relay test that failed once in five runs. It predates this branch and affects every
internal/watchconsumer.Diagnostics
A relay that is nudging and one that never heard of your pad are both silent, which is the whole question when nothing arrives.
Plus
exec --log(→<dir>/relay.log, one file per store, pid on every line) anddoctorreporting sockets nobody is listening on.Skill document: 457 → 240 lines
Measured: Codex reads the first 240 lines and only then goes back for more, so the tail of a 457-line skill is both noise and, in practice, unread.
CLAUDE.mdalready draws the line — SKILL.md is what an agent acts on, the topics are the reference. What prevents a wrong action stayed; writing rules, thepad searchwalkthrough and the full-pad detail moved toskills docs usage, which already covered them (checked before cutting).Added a "When a command fails" table: nine error codes, one line each, saying the right next move. It recovered three codes the first cut had dropped — caught by grepping the old file, not by re-reading it.
Scope and safety
execsits between the operator's keyboard and the agent for a whole session. An agent launched any other way behaves exactly as before and arms its own waits.relay.logare derived from the dir likeprojects/.not_your_turnstill refuses a double post.execmust be the agent's parent, and an MCP host owns its own process and terminal.Verification
make checkgreen. Relay tests cover: the nudge is a pointer and carries no content, several sections coalesce into one nudge, several pads into one line, your own post never nudges you, no second nudge until the agent acts, a registration finds a backlog, and the nudge is a harmless command line (quoted, no shell metacharacters outside the quotes).Beyond that it ran a real three-agent conversation in zellij — Claude Code and Codex under
exec, 20+ sections, four consecutive nudge→post rounds with neither agent arming a wait.