I keep saying "Window 1, Shell 5" when I talk about my desktop. But what is a window here? Is it an AwesomeWM workspace, an X11 window, a Terminator tab, or a split terminal? Those are four different things. Once I started trying to send voice requests to one agent from another shell, that distinction stopped being academic.
This is a tour of the actual layers I am using. I run AwesomeWM on X11, put many shells inside Terminator, and use LinuxUtilities to discover and address a pane. CKE does the speech work. The window-routing part is separate: D-Bus tells my script what Terminator knows, while X11 and accessibility APIs help it reach the right pane. None of this means the agent has completed the request. The companion article, From My Microphone to an Agent and Back, shows the human voice loop; here I am opening the desktop plumbing underneath it.
Before the window manager: what Linux is doing for me
When I press a key, I am not talking directly to a shell. The kernel handles the physical device and exposes input events. In my X11 session, Xorg receives input, tracks focus, and normally delivers key events to the focused client. AwesomeWM is another X client with window-manager authority: it decides which top-level window is tiled, visible, or focused, and it can handle configured shortcuts. It does not forward every ordinary character. When Terminator receives a key, its focused VTE widget writes bytes to a pseudo-terminal. The shell or agent CLI reads those bytes. Output travels back through the PTY to the terminal. This distinction matters because each layer has a different identifier and a different API.
The PTY has a master end held by the terminal emulator and a slave end presented to the shell as a terminal, often /dev/pts/N. That is why reading /proc/<pid>/fd/0 can help correlate a local agent process with a pane's shell: they can share a TTY. It is a clue about a local process, not proof of what a remote SSH process is doing. The PTY manual explains the master/slave contract in detail.
Where the distribution ends and the desktop begins
I casually say "my Ubuntu machine," but this laptop currently reports Linux Mint 22.3, an Ubuntu/Debian-derived distribution. That distinction helps: the distribution assembles packages and defaults; it is not the X11 protocol. The kernel provides devices, processes, permissions, sockets, and PTYs. A login/display manager starts a graphical session. On my current session that is LightDM launching Xorg and AwesomeWM. PipeWire handles my audio graph. A user-session D-Bus service lets desktop processes exchange structured messages. Terminator and my Python scripts run as applications in that session. I checked the session type with loginctl, rather than inferring it from a wallpaper.
Another Linux desktop might use GNOME on Wayland, KDE on X11, or a different display manager. The kernel still supplies core primitives, but the display protocol and routing tools change. My wmctrl/xdotool path is specifically an X11 choice, not a universal Linux command surface.
First, the Linux desktop underneath the shells
On this machine, the kernel and Xorg handle the input path; AwesomeWM manages the X11 clients and focus policy. Terminator is one of those clients. It draws terminals using VTE, and each pane hosts a shell with a pseudo-terminal (PTY). The shell may run Codex, Qwen, SSH, or nothing at all. A remote SSH agent does not magically become visible to my local process scanner.
The EWMH window-manager hints give X11 clients common properties such as the active top-level window. AwesomeWM's client API can focus or move that window, and its tag API organizes workspaces. But AwesomeWM does not have a first-class object for "Terminator Shell 5." That object belongs to Terminator.
My naming trap: tag 3 is not Window 3
AwesomeWM tags are the numbered workspaces I move among, often on two screens. A Terminator window is a top-level X11 window living on one or more of those tags. Inside it, Terminator has tabs and split terminals. The LinuxUtilities script currently prints "Window 1" after sorting Terminator's D-Bus window identifiers, then "Shell 5" after sorting the pane UUIDs in that window. Those numbers are a current list position, not AwesomeWM tag numbers and not stable identities. Opening or closing a pane can renumber the list. The UUID is a stronger address while the pane lives.
I can have two monitors and tag 3 selected on one of them, while my script prints Window 1, Shell 5. That does not contradict anything. The tag belongs to AwesomeWM. The window number is a temporary sorted list of Terminator windows discovered over D-Bus. The shell number is a temporary sorted list of panes inside that Terminator window. The pane's UUID is the useful identifier for this session; even that disappears when the pane closes.
Where D-Bus fits
D-Bus is a message protocol and a local bus for processes to find and call one another. A method call is not a magic Python function across the desktop. It has an address: a bus name (which service), an object path (which object), an interface (which API), and a method with typed arguments. Replies return values or errors; signals announce events. The user-session bus is the relevant one here, not the system bus used for machine-wide services.
Terminator's IPC source registers a service whose name starts with net.tenshu.Terminator2 at /net/tenshu/Terminator2. In my running version the name may include a display-derived suffix, so the script discovers matching names instead of hard-coding one. It asks get_terminals() for pane UUIDs, then get_window(uuid), get_tab_title(uuid), and get_focused_terminal(). Terminator's remotinator is another client of this interface. None of these calls proves an agent is attached to a pane.
What is a D-Bus call in plain English? My process sends a message on the session bus: "To this named Terminator service, at this object path, on this interface, call this method with this UUID." The bus routes the message; Terminator answers or reports an error. The four coordinates are not interchangeable. The service name locates a process, the object path locates an exported object, the interface groups methods, and the method is the operation. Signals are the reverse pattern: a service announces an event without a caller waiting for a direct return value. The D-Bus specification defines that message model.
busctl --user list, then inspect the Terminator service. Do not guess a service name from a screenshot.bus = dbus.SessionBus()
for name in bus.list_names():
if not name.startswith("net.tenshu.Terminator"):
continue
api = dbus.Interface(
bus.get_object(name, "/net/tenshu/Terminator2"), name
)
for pane_uuid in api.get_terminals():
window_uuid = api.get_window(pane_uuid)
tab_title = api.get_tab_title(pane_uuid)That is adapted from scripts/voice_terminator_targets.py in my local LinuxUtilities checkout. Try python3 scripts/voice_terminator_targets.py --focused-window from a Terminator pane. Add --scan-titles to read the pane headers you actually see. That extra scan uses AT-SPI accessibility information and briefly cycles panes, then restores focus. It is not the same thing as the D-Bus tab title. If the focused-pane or accessibility check fails, it reports uncertainty rather than inventing a name. Publication note: these newest pane-targeting scripts are still local changes, not files you can download from GitHub's main branch yet. I will link the exact files when they are merged.
Not every desktop message is a D-Bus message
This was the part I kept mentally flattening into "Linux APIs." There is no single desktop post office. When xdotool asks for an active X window or sends input, that goes through X11. When my script asks Terminator for pane UUIDs, it calls Terminator on the user D-Bus. When it reads a visible pane title, AT-SPI exposes the accessibility tree, backed by its own bus machinery. When Terminator sends text to a shell, those are bytes on a PTY, not an RPC. When my microphone records, PipeWire moves audio buffers through its graph. When the reader asks P3 to speak, SSH carries a command to that machine, where a local HTTP request reaches CKE Kokoro. Each protocol has its own trust boundary and failure mode.
That map also explains why I do not use D-Bus to move raw microphone samples or pretend an AT-SPI label is an authenticated agent identity. D-Bus is good for structured local control calls. X11 is good at the window protocol it was designed for. A PTY is good at terminal bytes. My script has to bridge the boundaries deliberately, and it should stop whenever it cannot prove the join it is about to make.
From a D-Bus UUID to a real focused pane
This is the awkward part. The Terminator window UUID returned by D-Bus is not the X11 window ID (XID) used by wmctrl and xdotool. And the Terminator D-Bus API this script uses does not provide a direct "send text to this existing pane" call. So the local scripts/voice_send_pane.py matches the layers: enumerate X11 Terminator windows, activate one, then ask Terminator which pane UUID is focused. Only one X11 window may match the target. Within that window it cycles panes with the configured Ctrl+Tab binding, checking the UUID after each step. It types only when both the active XID and focused UUID agree.
What the script actually checks before it types
The local voice_send_pane.py first resolves the requested pane from a fresh Terminator D-Bus listing. It checks that pane still belongs to the same Terminator window UUID. It then enumerates top-level X11 windows with wmctrl -lx and activates each candidate using wmctrl and xdotool. After activation it asks Terminator for the focused pane UUID. Exactly one X11 window must map back to the requested Terminator window. Then it cycles panes with Ctrl+Tab, checking the focused UUID until it reaches the exact target. Finally it checks the active XID and pane UUID immediately before typing and again before sending Return. If a window closes or focus changes, the script stops rather than choosing a likely-looking pane.
That is also why a pretty title is not enough. A title such as "CKE serving agent" comes from the terminal's visible header and helps me recognize it. The script's stronger local evidence is the pane UUID and, when possible, a process on the same /dev/pts/N as the shell. It knows how to recognize specific local Codex and Qwen command lines; it does not claim to discover every agent and cannot identify a remote process just because an SSH pane title looks convincing.
The send routine caps the message at 4,096 UTF-8 bytes and rejects embedded newlines. It confirms the destination and text, types with xdotool, and checks focus again. If you choose to submit, it then sends Return. That is a stronger boundary than "type into whichever window was last focused," but it is still keystroke injection. If the target changes during delivery, the state is uncertain and the safe response is to inspect the pane, not automatically resend. A terminal displaying text is not an agent acknowledgment.
A smaller C4 map: who owns which decision?
The earlier diagrams show the whole desktop. Here is the part that matters when I select a single pane. My Python controller does not own Terminator's tabs or AwesomeWM's tags. It asks the process that owns each fact, then joins the answers. The arrows below name the actual communication mechanism, not a generic "API" arrow.
Part one: what does “select Shell 5” actually call?
voice_note.py --list-panes calls discover_terminals(). It opens the session D-Bus, lists names beginning net.tenshu.Terminator, then asks each service for its pane UUIDs. For every pane it asks for the parent window UUID and window or tab title. LinuxUtilities sorts the UUIDs and prints friendly positions such as Window 1, Shell 5. Those numbers are a snapshot; the UUID is the address to carry into a later send.
These methods have different answers: get_terminals() gives pane UUIDs; get_window(pane_uuid) gives a Terminator window UUID, not an X11 XID; get_focused_terminal() gives Terminator's last-focused pane UUID; get_window_title() and get_tab_title() give labels. A split pane may not have a useful tab title. None confirms which agent accepted a prompt or returns the terminal's output text. This is the contract of the Terminator IPC implementation, not a capability inferred from the diagram.
What each interface actually gives me
| Call or command | Useful answer | Limit |
|---|---|---|
get_terminals() on Terminator D-Bus | Pane UUIDs | No XID, text, or agent state |
get_window(pane_uuid) | Parent Terminator window UUID | Not an X11 window ID |
get_focused_terminal() | Last-focused pane UUID | Can change between calls |
wmctrl -lx | Top-level XIDs and classes | No internal tabs or panes |
xdotool getactivewindow | Active X11 window ID | No agent delivery receipt |
xdotool type / key Return | Synthetic X key events | Focus-sensitive, no acknowledgment |
/proc + pane TTY | Known local agent process clue | Cannot verify a remote SSH agent |
To inspect rather than guess, run busctl --user list to find the current Terminator bus name and busctl --user introspect NAME /net/tenshu/Terminator2 to see its exported methods. NAME is the discovered name, not a literal service. That inspection is read-only; it does not select a pane.
Part two: bridge Terminator's UUID to an X11 window
At send time the script resolves the pane again. It checks that its UUID still belongs to the expected Terminator window and that exactly one Terminator D-Bus service owns it. Then it lists top-level X11 windows with wmctrl -lx and filters for Terminator's window class. It cannot compare an XID with a D-Bus UUID: different systems issued them. Instead it activates each candidate window, asks Terminator for the focused pane, maps that pane back to its Terminator window UUID, and requires one unambiguous match. This scan can briefly change visible focus.
Once it has the XID, the script activates that window and cycles panes with Ctrl+Tab, asking get_focused_terminal() after each step. It first checks that Terminator's configured cycle_next binding really is <Control>Tab; otherwise it refuses to guess. The loop is bounded by the number of panes in that window. A final check requires both the active X11 window and focused pane UUID to match.
Part three: what makes words appear in that pane?
Only after that gate does xdotool type send synthetic key events through X11. In this invocation it has no explicit target window, so xdotool uses XTEST to inject keys toward the current focus. Xorg delivers them to Terminator's focused VTE widget; VTE turns the characters into bytes on the PTY; the shell or agent CLI reads them as terminal input. This is the apparent "automatic typing." It is not an accessibility-tree write, not a D-Bus message containing the prompt, and not a direct call into the agent. If I submit, xdotool key Return sends one more key event. A draft stops before Return.
wmctrl -lx # list top-level XIDs and WM_CLASS
wmctrl -ia 0x00a5b4e9 # activate one numeric XID
xdotool windowactivate --sync 0x00a5b4e9
xdotool windowfocus --sync 0x00a5b4e9
xdotool getactivewindow # verify the active XID
xdotool key --clearmodifiers ctrl+Tab # move to another pane
xdotool type --clearmodifiers --delay 35 -- "review the tests"
xdotool key --clearmodifiers Return # only after confirmed submission-l lists windows, -x includes their class, and -i -a activates by numeric XID. --sync waits for the X window operation; it does not wait for an agent answer. --clearmodifiers avoids a held Shift/Ctrl changing the simulated input. --delay 35 spaces synthetic keystrokes in milliseconds. The -- marks the end of options so the message is treated as text. These are X11 controls: AwesomeWM can still change focus according to its policy, and another action can race this script.
The delivery contract is narrow: one line, at most 4,096 UTF-8 bytes; no newline, carriage return, or NUL. voice_note.py keeps the WAV and transcript even if targeting fails. In its normal confirmation prompt, Enter types and submits; SEND types a draft without Return; CANCEL leaves the transcript unsent. With --summary-return, it appends a request-ID-matched file instruction, but that is not a transport-level agent acknowledgment. If focus changes after typing, the script withholds Return and marks delivery uncertain; it must not silently resend a possibly accepted instruction.
One real request, step by step
- Discover.
voice_terminator_targets.py --focused-window --scan-titlesshows panes in the invoking Terminator window.--agents-onlyfilters to locally verified Codex or Qwen processes when possible. - Record.
voice_note.pyselects a microphone, writes a WAV, calls CKE Whisper, and saves a transcript and JSON report. I can correct the text before sending. - Address. I select a pane UUID using
--pane-id, or the current window/shell number. The UUID is safer across list reordering, but only for that pane's lifetime. - Confirm. Enter submits the request; SEND leaves a draft without Return. The script records a request ID and asks the agent to write a summary to a unique path.
- Read back. The summary ledger recognizes the exact request file. A text-following Kokoro reader can speak it. That return path is separate from Terminator and still needs the agent to honor the file instruction.
The speech loop is two routes today, not one imaginary server
Here is the detail I do not want to blur. In the current voice_note.py, pw-record records 16 kHz mono PCM to a private voice-note directory on my laptop. The script calls voice_agent.transcribe(), which invokes the local CKE run_whisper_v8.py with a prepared Whisper Base encoder and decoder. That is a local runner, not the P3 HTTP Whisper service. It saves recording.wav, whisper.json, and transcript.txt. I can inspect or edit that transcript, and --send-file lets me send corrected text without recording again.
The spoken return is different. agent_summary_inbox.py polls a local ledger for a completed summary with the same request ID. cke_summary_reader.py sends that text over SSH to a Kokoro endpoint on P3's loopback address, checks the returned source hash, chunk IDs, text spans, audio samples, and WAV format, then builds a local HTML reader beside the WAV. The browser is a captioned player here, not the agent transport. The local reader can highlight the part of the summary being spoken. If P3 is unavailable or a chunk fails validation, it does not pretend an older WAV is the new answer.
That little ledger matters more than it sounds. A summary is not "the latest text file I can find." The expected path includes the target's pane UUID and a unique voice-... request ID. A completed file must start with matching Request ID and Status: complete lines. The agent writes a temporary file and atomically renames it when done. The inbox checks age, size, and the envelope before it builds speech. I can ask for --list to see which request has a new summary instead of listening to a stale answer from another shell.
cd ~/Workspace/LinuxUtilities
python3 scripts/agent_summary_inbox.py --list
python3 scripts/agent_summary_inbox.py \
--id 'voice-PASTE-THE-REQUEST-ID' --wait 120 --opencd ~/Workspace/LinuxUtilities
python3 scripts/voice_terminator_targets.py --focused-window --scan-titles
python3 scripts/voice_note.py --list-panes
python3 scripts/voice_note.py \
--mic-target "$(pactl get-default-source)" \
--pane-id 'urn:uuid:PASTE-THE-SELECTED-PANE' \
--summary-returnMy laptop's built-in microphone and Sony headset do not behave identically, so I name the source explicitly when testing. These commands describe my X11/Terminator setup, not a generic cross-desktop installer. The repository is where the scripts and tests belong; check the current tree before expecting a local-only piece from my screenshot to be available there.
Why not just ask AwesomeWM to do everything?
AwesomeWM does expose a Lua API, and awesome-client can execute Lua through D-Bus when remote support is enabled. That is useful for choosing a tag, finding a client, moving a window, or building a predictable project map. It does not replace Terminator's pane model or tell me whether an AI agent understood a request. I could write a tiny AwesomeWM helper that returns the selected tag and top-level XID; I would still have to join that to Terminator's pane UUID and the agent's own status.
When I want to see the layers without sending anything, I start with read-only commands. wmctrl -lx lists top-level X11 windows with XIDs and classes. xdotool getactivewindow reports the currently active XID. busctl --user list shows names on my user session bus. The LinuxUtilities discovery command adds Terminator's window and pane UUIDs and, optionally, visible headers. I can correlate these views, but I should never compare the literal number in "tag 3" with the literal number in "Window 3."
wmctrl -lx
xdotool getactivewindow
busctl --user list | grep net.tenshu.Terminator
python3 ~/Workspace/LinuxUtilities/scripts/voice_terminator_targets.py \
--focused-window --scan-titlesThese commands have scope limits. The AwesomeWM API can see its clients and tags, but not the semantic state of an agent. Terminator's IPC can report its own windows and terminal UUIDs, but it does not own my spoken request ledger. AT-SPI can expose accessible widget labels, but a label can be stale. /proc can reveal local process details, but not a remote agent behind SSH. That is why my controller combines them and still asks for confirmation.
This is also why I would not call the current script Wayland-compatible. Wayland's protocol does not give ordinary clients the same global view of other clients' windows. The X11 focus and keystroke parts would need a compositor-approved adapter or an application-owned delivery API. That is a future port, not a flag we can flip.
The next interface I actually want
The current route is useful because it makes my existing desktop addressable. It is not the final architecture. I want a stable agent registry above windows: project name, agent name, current pane UUID, transport, verified availability, last request ID, and last completed summary. Then my voice command can say "CKE serving agent" instead of "Window 3, Shell 2." The application can show or speak the resolved destination before sending. Terminator remains a place to see the work, but not the only source of truth.
A native Terminator plugin or a supported agent-session adapter might eventually avoid focus-based typing. Until then, the reliable habits are simple: resolve one exact pane, confirm the transcript, fail closed on ambiguity, keep the original text, and only call a request complete when its matching answer arrives. That is how this memory palace becomes a usable interface rather than a wall of windows.
Sources and code
- LinuxUtilities repository. The newest
voice_note.py,voice_terminator_targets.py,voice_send_pane.py,agent_summary_inbox.py, andcke_summary_reader.pydetails above are from my local checkout; their current GitHubmainfile links are not available yet. - Terminator's D-Bus implementation and remotinator.
- AwesomeWM API, including clients, tags, and awesome-client.
- X11 protocol, EWMH, and Wayland protocol model.
- D-Bus specification, Linux PTY manual, and AT-SPI documentation.
Comments
Join the conversation. Replies can go three levels deep.