Fix hooks.

This commit is contained in:
Greyson Parrelli
2026-08-12 18:50:16 -04:00
parent dca5ec0b7d
commit 4e0e400372
7 changed files with 471 additions and 126 deletions
+57 -15
View File
@@ -279,7 +279,7 @@ in principle, but a terminal grid is small.
and scripts — opened in one go, parameterised by `{{name}}`, authored by
arranging a tab and saving it. See [Layouts](#layouts)
- **Pane status in the tab strip**, driven by OSC 9;4, so a tab can say whether
it is working, waiting on you, or finished while you were elsewhere. See
it is working, waiting on you, or finished and still unanswered. See
[Agent status](#agent-status)
- **Renaming a tab**: `Ctrl+Shift+R`, right-click or double-click a tab row.
A typed name pins the label; clearing it hands the label back to the panes
@@ -345,18 +345,45 @@ So a tab can carry a dot:
|---|---|
| purple, pulsing | working |
| amber | waiting for you — a permission prompt, or a question |
| green | **finished while you were looking at another tab** |
| green | **finished, and you haven't answered it** |
| red | stopped on an error |
| none | idle |
Each colour is carried at three sizes, so it survives being glanced at rather
than read: an 11px dot on the row, a bar down the row's leading edge, and a wash
behind the whole row. Working gets the bar without the wash — it is the resting
state of a busy afternoon, and tinting half the sidebar all day would only teach
you to stop looking. Inside a tab, a pane that is asking colours its own frame
and header the same way, which is what picks it out of a four-way split.
The green one is the point of the feature. A session that has gone back to idle
looks exactly like one that never ran, so a tab that finishes work while it is
not the visible one latches green and stays that way until you actually visit
it. Nothing else tells you a tab is worth going back to.
looks exactly like one that never ran, so a pane that finishes latches green and
stays that way until you deal with it. Nothing else tells you a tab is worth
going back to.
**Opening the tab clears the row.** A pane's own dot clears when you go to that
pane or type in it. The two answer slightly different questions: the row's is
"should I go there?", which visiting settles whether or not you then deal with
everything inside, and a pane's is "have you dealt with me?", which only you can
answer. In a split that difference is the whole point — you open a flagged tab,
the row goes quiet, and the panes you haven't been to yet are still marked.
Work that finishes while you are sitting in the tab flags it too, deliberately.
Having a tab on screen when a session stopped says only that the pixels were in
front of you; watching it finish and then moving on to something else is the
case this is most needed for. It goes quiet as soon as you answer that pane, or
the next time you come back to the tab.
Green also outranks purple. A tab holding three sessions goes green as soon as
any one of them finishes rather than waiting for the last one to stop, because
"one of these is ready for you" is the news. The two states that actually want
something from you — amber and red — outrank it in turn.
Panes report this individually and the tab shows the most urgent of them, so a
four-pane tab still reduces to one dot. The pane headers carry their own dots to
say which pane inside it was the one asking.
four-pane tab still reduces to one dot. The pane headers carry their own dots,
so once the row has brought you to the tab, they say which pane inside it was
the one asking; the row stays lit until every finished pane in it has been
answered.
### How it gets there
@@ -385,13 +412,28 @@ comment, so re-running replaces Playpen's own entries rather than stacking
duplicates, and the previous file is kept at `settings.json.playpen-backup`.
Hooks are read at startup, so open a new session to pick them up.
The hook writes to `/dev/tty`, not to a socket or a daemon. A hook runs as a
child of Claude Code, so its controlling terminal *is* the pty of the pane
Claude is running in — the bytes land in that pane and no other, with nothing to
configure and no way for two concurrent sessions to be mistaken for each other.
It writes there rather than to stdout because Claude Code parses hook stdout as
the hook's JSON result; an escape sequence written there would corrupt the hook
protocol instead of reaching the terminal.
The hook writes to a pty, not to a socket or a daemon: the bytes land in that
pane and no other, with nothing to configure and no way for two concurrent
sessions to be mistaken for each other. It writes there rather than to stdout
because Claude Code parses hook stdout as the hook's JSON result; an escape
sequence written there would corrupt the hook protocol instead of reaching the
terminal.
Finding that pty is the one genuinely fiddly part, and `/dev/tty` — the obvious
answer, and what this used to do — is the wrong one. **Claude Code starts each
hook in its own session**, so a hook has no controlling terminal at all and
opening `/dev/tty` fails with `ENXIO`. It fails invisibly, too: `[ -w /dev/tty ]`
returns true regardless, because it stats a path whose mode is `0666` rather
than opening it, so guarding on that reports success and then writes into
nothing. Every state change was being dropped on the floor with no error
anywhere.
The pty is only one hop away, though — it is on Claude Code's own standard file
descriptors. So the script tries its controlling terminal by *opening* it, and
failing that walks up `/proc` for the nearest ancestor holding a pty, with a
`ps -o tty=` fallback for systems without `/proc`. The nearest ancestor is the
right answer even with something in between: a Claude running inside tmux inside
a pane finds tmux's pty, which is where its output actually goes.
A tab's title follows your prompt for as long as Claude holds the foreground.
Once it exits, the shell's own prompt sets the title back, which is the right
@@ -403,7 +445,7 @@ Claude in a microVM (`smolvm`, `krunvm`, anything libkrun-based) still works,
because the guest console passes these bytes through to the host pty unchanged:
```
$ # written to /dev/tty inside the guest, observed on the host pty:
$ # written to the guest's console, observed on the host pty:
b'\x1b]9;4;3\x07' b'\x1b]0;hello-from-guest\x07' b'\x1b]9;4;0\x07'
```