phux
guidesinteractive TUI

TUI hooks and recording

The reference TUI's consumer-facing product surface: subcommands, keybinds, status bar, layout, hooks, recording.

evolving document
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 in phux-config (see schema.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, and agent-state-changed. Enabled plugin manifests’ [[events]] entries whose on names 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.

HookFires after / onContext keys
after-new-sessionsession creationdesign intent
after-new-windowwindow creationdesign intent
after-new-panepane creation, before exec(session), terminal-id
after-kill-panepane removed from layoutdesign intent
pane-exitinner process exit(exit-code), terminal-id
client-attachedclient attach completedclient-id, session
client-detachedclient detach (any reason)client-id, (session)
focus-changedany client changes focusclient-id, terminal-id
agent-state-changeda pane’s derived agent state changedagent-kind, (agent-name), (from), terminal-id, to
output-silencedconfigurable silence threshold elapseddesign intent
output-activefirst byte after a silencedesign 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 run action’s command may be a string (executed via /bin/sh -c) or an argv array (executed directly). noop matches 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_EVENT plus one PHUX_* variable per context key: PHUX_TERMINAL_ID, PHUX_SESSION, PHUX_EXIT_CODE (absent for signal-killed children), PHUX_CLIENT_ID. Every hook child also gets PHUX_SOCKET — the UDS path the firing server listens on — so a bare phux invocation inside a hook script targets that server even when it runs off the default socket path. Plugin event hooks additionally get PHUX_PLUGIN_ID, PHUX_PLUGIN_EVENT_ID, and PHUX_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.


View exact source