Goal
Make PadIO switch mode automatically based on which app is running in the active herdr pane, so controller bindings follow the app under the cursor without a manual mode pick.
Primary use case: navigation plus voice control of AI agents from an Xbox controller, with herdr (in Ghostty) replacing tmux. Herdr has three navigation levels (workspaces, tabs, panes) plus whatever app runs in each pane.
Design
Herdr's navigation levels live in profile.global for the Ghostty profile, so workspace/tab/pane bindings are always live. The app running in the focused pane selects the mode. No changes to the profile resolution cascade are needed.
herdr (socket API) bridge PadIO
───────────────────── ────── ─────
events.subscribe pane.focused ──┐
├──> resolve leaf ──> write ──> DispatchSource watch
poll pane.process_info (~3Hz) ──┘ process name context → context → mode
Herdr side (from its docs): newline-delimited JSON over a Unix socket at ~/.config/herdr/herdr.sock (or HERDR_SOCKET_PATH). events.subscribe supports pane.focused; pane.process_info returns the shell pid, foreground process group id, and foreground processes with pid, name and argv/cmdline.
PadIO side: today mode is set only by controller input (ControllerManager.switchMode, reachable from executeAction's .setMode case and the mode picker). Profile selection reads exactly one external signal, the frontmost bundle ID (ControllerManager.refreshActiveProfile). There is no URL scheme, socket, XPC or command listener. PadIO needs a new inbound channel.
Decisions already made
- Channel: a watched file,
~/.config/padio/context, holding a single token. Reuses the DispatchSource pattern already proven in ConfigLoader (MappingConfig.swift:309). No entitlements, no networking, no Info.plist changes.
- Policy lives in PadIO. The bridge reports a raw process name; PadIO's config maps it to a mode via
context_modes. All user configuration stays in one file.
- No-match leaves the mode alone. If the context token has no
context_modes entry, the current mode is kept. It does not fall back to default_mode.
- Manual override sticks. External context applies only when the token changes. A mode picked by hand stays until the context token changes.
- Hidden modes. Because context-driven modes can be numerous (one per app), the mode picker needs to show only a chosen few (e.g.
media, zsh). Hidden modes stay reachable via setMode and external context.
- Bridge ships as a herdr plugin using
[[startup]], so install is herdr plugin install <owner>/<repo> with no launchd agent to manage. It talks to the socket directly rather than relying on the plugin [[events]] hook, whose docs only demonstrate worktree.created.
Known gap
Launching an app from the shell inside an already-focused pane fires no event: pane.focused does not fire, and pane.updated is documented as firing on terminal-title changes (excluding spinner-only churn), not on foreground-process changes. Hence the ~3Hz poll of pane.process_info on the focused pane alongside the event subscription, so both pane switches and in-pane launches go through one code path.
Sequence
The two tracks are independent and can proceed in parallel. Everything except #11 and #12 is unblocked today; those two need a live herdr session.
Caveat
All herdr detail here comes from reading its published docs, not from a live session. The probe sub-issue validates the field names and subscription semantics before the bridge is built.
Goal
Make PadIO switch mode automatically based on which app is running in the active herdr pane, so controller bindings follow the app under the cursor without a manual mode pick.
Primary use case: navigation plus voice control of AI agents from an Xbox controller, with herdr (in Ghostty) replacing tmux. Herdr has three navigation levels (workspaces, tabs, panes) plus whatever app runs in each pane.
Design
Herdr's navigation levels live in
profile.globalfor the Ghostty profile, so workspace/tab/pane bindings are always live. The app running in the focused pane selects the mode. No changes to the profile resolution cascade are needed.Herdr side (from its docs): newline-delimited JSON over a Unix socket at
~/.config/herdr/herdr.sock(orHERDR_SOCKET_PATH).events.subscribesupportspane.focused;pane.process_inforeturns the shell pid, foreground process group id, and foreground processes with pid, name and argv/cmdline.PadIO side: today mode is set only by controller input (
ControllerManager.switchMode, reachable fromexecuteAction's.setModecase and the mode picker). Profile selection reads exactly one external signal, the frontmost bundle ID (ControllerManager.refreshActiveProfile). There is no URL scheme, socket, XPC or command listener. PadIO needs a new inbound channel.Decisions already made
~/.config/padio/context, holding a single token. Reuses theDispatchSourcepattern already proven inConfigLoader(MappingConfig.swift:309). No entitlements, no networking, no Info.plist changes.context_modes. All user configuration stays in one file.context_modesentry, the current mode is kept. It does not fall back todefault_mode.media,zsh). Hidden modes stay reachable viasetModeand external context.[[startup]], so install isherdr plugin install <owner>/<repo>with no launchd agent to manage. It talks to the socket directly rather than relying on the plugin[[events]]hook, whose docs only demonstrateworktree.created.Known gap
Launching an app from the shell inside an already-focused pane fires no event:
pane.focuseddoes not fire, andpane.updatedis documented as firing on terminal-title changes (excluding spinner-only churn), not on foreground-process changes. Hence the ~3Hz poll ofpane.process_infoon the focused pane alongside the event subscription, so both pane switches and in-pane launches go through one code path.Sequence
context_modesresolution)The two tracks are independent and can proceed in parallel. Everything except #11 and #12 is unblocked today; those two need a live herdr session.
Caveat
All herdr detail here comes from reading its published docs, not from a live session. The probe sub-issue validates the field names and subscription semantics before the bridge is built.