TUI hooks and recording
The reference TUI's consumer-facing product surface: subcommands, keybinds, status bar, layout, hooks, recording.
Full source summary
The reference TUI's consumer-facing product surface: subcommands, keybinds, status bar, layout, hooks, recording. The TUI is the wedge — the daily-driver adoption surface — and its differentiator is the wire: attach/detach, remoting, and a human and their agents sharing the same live terminals. It is held a pure consumer with no protocol privilege by ADR-0017. What's normative lives in ../spec/; this file is the human-facing reference for the tmux-shaped consumer that ships in tree.
9. Hooks
Status: Partially shipped (phux-r82.1). Config parsing for
[[hooks.<name>]]entries ships inphux-config(seeschema.rs), and the server-side dispatcher (phux-server::hooks) fires a starter set of real events:after-new-pane,pane-exit,focus-changed,client-attached,client-detached, andagent-state-changed. Enabled plugin manifests’[[events]]entries whoseonnames one of these events fire through the same dispatcher. The remaining hook points in the table below (after-new-session,after-new-window,after-kill-pane,output-silenced,output-active) stay design intent — the server does not observe those edges yet.
Hooks fire at named events. Each hook in the config is an
array-of-tables (TOML [[hooks.<name>]]) of { when, action } pairs.
[[hooks.after-new-pane]]
when = { session-startswith = "work" }
action = { kind = "run", command = "echo pane up >> ~/.cache/phux/hooks.log" }
[[hooks.pane-exit]]
when = { exit-code = 0 }
action = "noop"
[[hooks.pane-exit]]
when = { exit-code = "*" }
action = { kind = "run", command = "say 'pane exited'" }
The hook system is intentionally small:
- Match clauses (
when = { key = value }) are exact-string or simple glob matches ("*"). No regex; no expression language. - First match wins per hook event. Subsequent entries don’t fire.
- Async by default. Hook actions fire and the server moves on. Sync hooks (where the result blocks the trigger) are reserved for v0.2.
Hook points (initial). The context keys are what when clauses can
match (and what the hook child receives as PHUX_* variables); keys in
parentheses may be absent on a given firing — exit-code for a
signal-killed child, session when none applies, agent-name for an
anonymous agent, from on a first sighting. This table mirrors
phux_config::vocab::hook_context_keys, which is itself pinned to the
server’s event constructors by an agreement test.
| Hook | Fires after / on | Context keys |
|---|---|---|
after-new-session | session creation | design intent |
after-new-window | window creation | design intent |
after-new-pane | pane creation, before exec | (session), terminal-id |
after-kill-pane | pane removed from layout | design intent |
pane-exit | inner process exit | (exit-code), terminal-id |
client-attached | client attach completed | client-id, session |
client-detached | client detach (any reason) | client-id, (session) |
focus-changed | any client changes focus | client-id, terminal-id |
agent-state-changed | a pane’s derived agent state changed | agent-kind, (agent-name), (from), terminal-id, to |
output-silenced | configurable silence threshold elapsed | design intent |
output-active | first byte after a silence | design intent |
phux config check validates this whole surface — an unknown event
name (including the design-intent rows, which the server does not fire
yet), a when key outside the event’s context (the -startswith
suffix strips off before the lookup), or an action that can never
execute server-side is reported there and warned about again at server
startup, instead of silently never firing.
Server-side execution semantics (the shipped subset):
- Child processes only. There is no in-process plugin host. A
runaction’scommandmay be a string (executed via/bin/sh -c) or an argv array (executed directly).noopmatches and does nothing; other action kinds (e.g.message) are client-side and the server dispatcher skips them (the entry still consumes the event under first-match-wins). - Event context rides environment variables. Every hook child gets
PHUX_EVENTplus onePHUX_*variable per context key:PHUX_TERMINAL_ID,PHUX_SESSION,PHUX_EXIT_CODE(absent for signal-killed children),PHUX_CLIENT_ID. Every hook child also getsPHUX_SOCKET— the UDS path the firing server listens on — so a barephuxinvocation inside a hook script targets that server even when it runs off the default socket path. Plugin event hooks additionally getPHUX_PLUGIN_ID,PHUX_PLUGIN_EVENT_ID, andPHUX_PLUGIN_ROOT, and run with the plugin root as their working directory. - Fire-and-forget, bounded. Events queue onto the dispatcher through a non-blocking bounded channel (a full queue drops the event); at most a fixed number of hook children run concurrently, each under a timeout with kill-on-drop. A slow or wedged hook never blocks the terminal actor hot path.
9.1 Agent notifications ride agent-state-changed
agent-state-changed fires when the ADR-0046 detector’s published state
for a pane actually changes. Its context adds agent-kind, agent-name
(omitted when the record is anonymous, so a hook child can tell “unnamed”
from “unset”), from, and to — exported as PHUX_AGENT_KIND,
PHUX_AGENT_NAME, PHUX_FROM, and PHUX_TO.
from is absent on a first sighting. “We have never seen this pane” is
a different fact from “it was idle”, and a notifier that conflates them
announces every agent launch as a transition. A withdrawn record (the agent
exited) arrives as to = "unknown", and so does an occupant change — a
pane whose Claude was replaced by a Codex passes through unknown with its
agent-kind already corrected, rather than reporting the new occupant’s
state under the old occupant’s kind.
This is deliberately the only notification surface. phux ships no sound player and no desktop-notification client:
# Tell me when an agent stops and wants a human.
[[hooks.agent-state-changed]]
when = { to = "blocked" }
action = { kind = "run", command = "afplay /System/Library/Sounds/Glass.aiff" }
# ... and when one finishes its turn.
[[hooks.agent-state-changed]]
when = { to = "idle" }
action = { kind = "run", command = "osascript -e 'display notification \"turn done\" with title \"phux\"'" }
A built-in notifier would have to grow a config surface for the player, the
sound, the per-state mapping, and the mute switch — reimplementing, badly,
what osascript, notify-send, afplay, and tput bel already do. What
the server owes the operator is the edge, delivered once, with enough
context to decide. Remember that hooks are first-match-wins per event,
so order the when clauses most-specific first.
The hook is a true edge in both directions: the detector’s own filter models its emissions rather than the store, so the drain compares against the recorded state and fires nothing when a republish lands on the state already there. A notifier that fires on a non-change is a notifier the operator turns off.
10. Recording
Two surfaces ship. phux rec [TARGET] -o PATH records one pane headlessly:
it subscribes as a pure ATTACH_TERMINAL observer, so it neither attaches the
session nor resizes the pane and is safe against a session someone is using.
phux --rec PATH records the session you are attached to by teeing the
client’s own composited output, so the artifact carries the chrome — tiled
panes, dividers, status bar, sidebar, overlays, cursor — and not just one
pane’s bytes.
The output extension picks the format: .cast for an asciinema cast, .gif
for an animated GIF, .png/.apng for an animated PNG, and a path with no
extension gets .gif. GIF and APNG are encoded in-process — no agg, no
vhs, no ffmpeg
— and phux rec --from FILE.cast -o out.gif re-renders an existing cast
offline at a different frame rate or idle limit.
The default asciicast version is 2, and --cast-version 3 is opt-in. v3
is not backward compatible with v2: the header schema changed and event
times became relative intervals, so a v2-only reader that tolerates a v3
header replays a four-minute recording in a fraction of a second. There is no
consumer that reads v3 but not v2.
recording.md is the full surface — flags, formats, and the
three fidelity limits worth knowing before you record something long.
ADR-0060 owns the
reasoning.
Playing one back. phux play FILE.cast [TARGET] creates a pane whose
PTY is fed from the recording and prints its Terminal id. It is not a viewer
for your own shell — asciinema play is that, and it needs no server. What
this produces is an ordinary pane: attach it, phux snapshot it,
phux resize it, watch it from an agent, share it with a second client,
phux kill it. TARGET says where the pane goes (it is created beside it,
splitting that window, default .); TARGET is never written to, and no flag
plays into a pane that already has a shell in it.
The pane is resized to the recording’s own grid and to each resize the
recording contains, so lines wrap where they wrapped when it was captured;
--no-fit opts out. --speed, --idle-limit, and --loop shape the
timeline, and when the recording ends the pane holds its final frame until it
is killed (--close ends it instead). Full surface in
recording.md §6; the reasoning, including why the
shell-level player stays unbuilt, is in
ADR-0064.
Superseding the earlier design. This section previously specified
phux capture --record TARGET --out FILE.cast plus a server-side
PANE_OUTPUT tee. Neither was ever built and neither is coming: recording is
consumer-side, the verb is rec, and capture is retired rather than
aliased. The phux play that section sketched now exists, but as the pane
above rather than as the shell-level replayer it described.