Add hook system.

This commit is contained in:
Greyson Parrelli
2026-08-11 16:24:53 -04:00
parent 73a0187db5
commit dd60e52005
9 changed files with 788 additions and 3 deletions
+97
View File
@@ -278,6 +278,11 @@ in principle, but a terminal grid is small.
- **Saved layouts**: whole tabs — panes, splits, ratios, per-pane directories
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
[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
### Shortcuts
@@ -289,6 +294,7 @@ in principle, but a terminal grid is small.
| `Ctrl+Shift+W` | close the focused pane (closes the tab with its last one) |
| `Ctrl+Shift+←/→/↑/↓` | move the focused pane within its view |
| `Ctrl+Shift+V` | paste into a terminal (bracketed-paste aware, refuses unsafe pastes) |
| `Ctrl+Shift+R` | rename the current tab (empty name = follow the terminal) |
| `Ctrl+PageUp/PageDown` | previous / next tab |
| `Alt+1`..`Alt+8` | jump to tab N, `Alt+9` jumps to the last |
@@ -300,6 +306,97 @@ competes with the content's own mouse handling.
Both add-pane shortcuts are also buttons in every pane header, which is how you
open a web view without remembering the chord.
## Agent status
Playpen is mostly used to keep several Claude Code sessions side by side, and
the question a sidebar full of them has to answer is "which of these needs me?".
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** |
| red | stopped on an error |
| none | idle |
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.
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.
### How it gets there
Programs report state with **OSC 9;4** — the ConEmu progress protocol, the same
one Windows Terminal and Ghostty use for taskbar progress — and their title with
OSC 0/2. Nothing about this is Claude-specific: a build that reports progress
lights the same dot.
Claude Code has no idea about any of this, so a hook tells it:
```sh
mise run install-claude-hooks # merges into ~/.claude/settings.json
mise run uninstall-claude-hooks
```
That installs `hooks/playpen-status.sh` into `~/.claude/hooks/` and wires it to
five events: `UserPromptSubmit` → working (and sets the tab title from your
prompt), `Notification` → waiting, `Stop` / `SessionStart` / `SessionEnd`
idle. The red state is deliberately not wired to anything: the obvious
candidate, `PostToolUseFailure`, fires for tool errors Claude then goes on to
recover from, so a tab would turn red constantly during ordinary work. The
script still accepts `error` for anything of your own worth flagging that way.
Existing hooks are left alone — every entry is tagged with a marker
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.
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
answer — there is no task any more. A name you type yourself outranks both.
### Inside a VM
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:
b'\x1b]9;4;3\x07' b'\x1b]0;hello-from-guest\x07' b'\x1b]9;4;0\x07'
```
This is the reason for choosing escape sequences over a socket. A unix socket on
the host is not reachable from inside a guest, and matching a message to a pane
by cwd breaks as soon as guest paths stop corresponding to host ones. A console
you already have is the one channel guaranteed to cross that boundary.
The guest has its own `$HOME` and so its own `~/.claude/settings.json`. Install
the hook inside the image rather than on the host — the script is dependency-free
POSIX `sh` and behaves identically on both sides:
```sh
smolvm machine exec --name NAME -- mkdir -p /root/.claude/hooks
# copy hooks/playpen-status.sh to /root/.claude/hooks/ via a mount or a heredoc,
# then merge the same five events into the guest's settings.json.
```
`jq` is used for the title when present and a `sed` fallback covers a minimal
image that has no `jq`; if the title can't be read the state still reports and
the tab simply keeps the name it had.
## Not implemented
This is a proof of concept, and the following are deliberately absent: