Skip to content

exec: nudge an agent when a pad moves, so nobody has to arm a wait - #27

Merged
madnh merged 7 commits into
mainfrom
exec-relay
Aug 11, 2026
Merged

madnh merged 7 commits into
mainfrom
exec-relay

Conversation

@madnh

@madnh madnh commented Aug 11, 2026

Copy link
Copy Markdown
Owner

The problem

Waiting is the one thing agents reliably fail at. pad wait prints 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:

scratchpad notification 'new activity — run: scratchpad pad read default-ab3k9x --since 12'

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), and TIOCSTI is disabled on modern Linux and unavailable safely on macOS. Owning the pty is the only arrangement that can both know the child is alive (from wait(), 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 as PostRequest.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. since moves 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:

  • The Enter has to be written separately from the line. A TUI treats a burst of bytes as a paste, where Enter means newline rather than send. Measured with Codex: text and \r in one write sat in the composer indefinitely; the same \r alone submitted at once.
  • The nudge has to be a real command. A crashed agent leaves the shell it was launched from, which executes what it is given. A bare sentence is harmless only by luck (zsh refuses [scratchpad] as an empty glob, bash says command not found), so notification exists and only prints.
  • A failed registration must be loud. It used to be swallowed, leaving the agent believing somebody was listening for it. Now the command still succeeds but warns to arm a wait.
  • exec was leaking a socket per run — it exited before the relay finished cleaning up.
  • relay reported "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.
  • A watcher race that lost writes, not delayed them (see below).

internal/watch fix, worth its own look

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 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/watch consumer.

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.

$ scratchpad relay
▸ pid 18223  codex
  socket   /Users/you/.scratchpad/relays/18223-ce846a.sock
  nudges   9, last 44s ago: scratchpad notification 'new activity — run: …'
  watching
    chitchat-xs56hi     as teo     from §21   quiet

Plus exec --log (→ <dir>/relay.log, one file per store, pid on every line) and doctor reporting 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.md already draws the line — SKILL.md is what an agent acts on, the topics are the reference. What prevents a wrong action stayed; writing rules, the pad search walkthrough and the full-pad detail moved to skills 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

  • Opt-in. exec sits 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.
  • No new state. The registry is in memory and dies with the process; sockets and relay.log are derived from the dir like projects/.
  • The turn rule is untouched — verified in a live three-agent session, not_your_turn still refuses a double post.
  • MCP is deliberately unchanged: exec must be the agent's parent, and an MCP host owns its own process and terminal.
  • The website is not updated yet — staying off until the feature has been used for a while.

Verification

make check green. 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.

madnh and others added 7 commits August 11, 2026 18:12
… 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>
@madnh
madnh merged commit c5d9ccc into main Aug 11, 2026
1 check passed
@madnh
madnh deleted the exec-relay branch August 11, 2026 11:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant