Two OMP agents were running in separate Herdr panes, but changing focus did not change which agent could continue working.
Herdr is a terminal multiplexer built for coding agents. Oh My Pi (OMP) is a terminal coding agent with a native /pause command. Together, they expose the pieces for an attention policy:
Only the OMP agent in the currently focused Herdr pane may run. Every background OMP agent should enter its native pause state and remain there until I resume it manually.
This post documents the attention-pause plugin I built, the real UI tests that broke its early designs, and the difference between a useful best-effort guard and a deterministic focus lock.
Why native pause matters
The wrong implementation would stop a process, send Ctrl-C, or suspend the terminal pane. Those actions can interrupt a write, leave a tool call incomplete, or corrupt conversational state.
OMP already owns the correct control boundary. Its /pause command:
- allows an in-flight tool call to finish;
- prevents new agent work from starting;
- displays a native pause overlay;
- resumes only after manual input.
The first version therefore did not invent another pause mechanism. It submitted the command OMP already understood. A live drafting failure the next morning proved that the transport was wrong; the August 15 update below replaces it with an out-of-band key action while retaining OMP’s process-wide pause gate.
Herdr’s plugin surface
Herdr plugins are TOML manifests that bind commands to lifecycle events. The first manifest subscribed to startup, agent-status changes, and three kinds of focus changes:
[plugin]
name = "Attention Pause"
plugin_id = "ankit.attention-pause"
version = "0.1.0"
platforms = ["windows"]
[[startup]]
command = ["python", "attention_pause.py"]
platforms = ["windows"]
[[events]]
on = "workspace.focused"
command = ["python", "attention_pause.py"]
platforms = ["windows"]
[[events]]
on = "tab.focused"
command = ["python", "attention_pause.py"]
platforms = ["windows"]
[[events]]
on = "pane.focused"
command = ["python", "attention_pause.py"]
platforms = ["windows"]
[[events]]
on = "pane.agent_status_changed"
command = ["python", "attention_pause.py"]
platforms = ["windows"]
The hook receives event data through HERDR_PLUGIN_EVENT_JSON, then uses Herdr’s CLI to inspect and control agents.
The reconciliation loop
The Python handler follows five steps:
- list Herdr agents;
- select background OMP panes;
- read each pane’s current detection screen;
- skip panes that already show OMP’s live pause footer;
- recheck focus and submit
/pause.
The selection rule deliberately includes idle, working, and done OMP agents. Agent status is not the policy. Focus is.
def omp_candidates(agents, *, include_focused=False):
return [
agent
for agent in agents
if agent.get("agent") == "omp"
and agent.get("agent_status") in {"idle", "working", "done"}
and (include_focused or not agent.get("focused", False))
]
A pane can contain old P A U S E D text in its scrollback, so detecting that heading alone is unsafe. The handler also requires the live pause footer:
PAUSE_FOOTER = "esc · enter · space — resume"
def is_paused(herdr, pane_id):
screen = herdr_text(herdr, "agent", "read", pane_id, "--source", "detection")
nonempty_lines = [line.strip() for line in screen.splitlines() if line.strip()]
return bool(nonempty_lines) and nonempty_lines[-1] == PAUSE_FOOTER
Before submitting the command, the handler reads focus again:
if is_focused(herdr, pane_id):
print(f"now-focused pane={pane_id}")
continue
herdr_json(herdr, "agent", "prompt", pane_id, "/pause")
print(f"pause-sent pane={pane_id}")
That last check closes one time-of-check to time-of-use race: a pane that became focused after the initial snapshot must not be paused.
Unit tests passed before the interaction did
The test suite covered the policy table:
| Pane state | Focused? | Action |
|---|---|---|
| working | yes | skip |
| working | no | pause |
| idle | no | pause |
| already paused | no | skip |
| non-OMP pane | no | skip |
| becomes focused after snapshot | yes at recheck | skip |
It also covered stale pause text, unknown status values, failed screen reads, lock contention, and a focus event arriving during reconciliation.
The suite passed. Python syntax compilation passed. Herdr reported the plugin as linked and enabled.
Then real switching failed.
One UI switch emitted three focus hooks
Switching between two Herdr workspaces emitted:
workspace.focused
tab.focused
pane.focused
Each event launched a separate Python process. Every process then made several command-line round trips back to Herdr:
focus event
↓
start Python
↓
herdr agent list
↓
herdr agent read <pane>
↓
herdr agent list # focus recheck
↓
herdr agent prompt <pane> /pause
The event was immediate. The control path was not. Individual handlers took several seconds, which was enough time to switch focus again.
This produced three distinct outcomes:
pause-sent pane=w1F:p1
already-paused pane=w1G:p1
now-focused pane=w1F:p1
All three logs are locally correct. Only the first changes agent state. A late now-focused result means the policy missed the interval during which that pane was in the background.
The lock detour
The first response to duplicate handlers was a non-blocking file lock. One handler reconciled; overlapping handlers returned coalesced.
That dropped the newest focus change. If an event arrived while reconciliation was active, no final pass was guaranteed.
The next version made waiters acquire the lock eventually. This preserved events but created a stale queue:
event A ── reconcile old focus
event B ── wait ── reconcile older focus
event C ── wait ── reconcile stale focus
The pause sometimes appeared tens of seconds later. That was not OMP waiting for an active tool call. Both agents were idle. The delay came from queued plugin handlers.
This is the important distinction:
| Delay | Meaning |
|---|---|
Handler has not logged pause-sent |
The plugin has not submitted /pause |
pause-sent logged; tool still finishing |
OMP is reaching its safe boundary |
| Pause appears after several stale handlers | Plugin queue latency, not agent safety latency |
Serial execution can preserve every event and still violate current-state correctness.
Unique markers and trailing-edge reconciliation
A correct coalescer needs two properties:
- single flight — only one expensive reconciliation runs at a time;
- trailing edge — an event arriving during that run guarantees one final pass over the newest state.
A single dirty file was insufficient. This sequence loses an event:
handler A: sees dirty file
handler B: touches the same existing file
handler A: deletes file
Handler B’s signal disappears with handler A’s delete.
Unique marker files avoid that overwrite race. A claimant snapshots and removes only the markers it observed; a marker created during deletion remains for the next waiter.
def mark_dirty():
marker = tempfile.NamedTemporaryFile(
prefix="attention-pause.dirty.",
dir=state_dir,
delete=False,
)
marker.close()
def claim_dirty():
markers = list(state_dir.glob("attention-pause.dirty.*"))
for marker in markers:
marker.unlink()
return bool(markers)
This makes event coalescing lossless. It does not make Herdr CLI round trips fast.
Reduce amplification first
Real logs proved that switching workspaces also emitted pane.focused. The plugin therefore removed the broader workspace.focused and tab.focused subscriptions.
The active event surface became:
[[events]]
on = "pane.focused"
command = ["python", "attention_pause.py"]
platforms = ["windows"]
[[events]]
on = "pane.agent_status_changed"
command = ["python", "attention_pause.py"]
platforms = ["windows"]
pane.focused handles human navigation. pane.agent_status_changed catches a background agent that becomes active without another focus change.
This reduced one switch from three focus handlers to one. It improved the system, but live tests still showed variable delay. The plugin is therefore useful as a best-effort attention guard, not as a deterministic real-time lock.
Logs are part of the product
The handler prints one decision for every candidate:
pause-sent pane=w1F:p1
already-paused pane=w1G:p1
now-focused pane=w1F:p1
read-failed pane=w1G:p1
coalesced
Herdr preserves each event’s start time, finish time, status, stdout, and stderr:
herdr plugin log list --plugin ankit.attention-pause --limit 60
Two more commands ground the UI state:
herdr agent list
herdr agent read w1F:p1 --source detection
These evidence layers answer different questions:
| Evidence | Question answered |
|---|---|
| Plugin event log | Did Herdr invoke the hook? |
| Handler stdout | Why did reconciliation pause or skip? |
| Agent list | Which pane is focused now? |
| Detection screen | Is OMP’s native pause overlay visible? |
| OMP session log | Did new agent work start after the boundary? |
The remaining observability gap is per-step timing inside the Python handler. Total duration cannot show whether process startup, agent list, screen reading, or prompt submission consumed the time.
Scheduled breaks need ownership, not polling
Focus changes are not the only reason to pause every agent. Windows Task Scheduler
already owns my Stretchly schedule, including a 35-second mini break every ten
minutes and a longer break at :50. Making every OMP session watch Stretchly would
duplicate state, create one poller per process, and make resume decisions locally.
Instead, the Scheduler invokes the same Herdr controller directly:
attention_pause.py break-start --run-id <guid>
Stretchly renders the break
attention_pause.py break-end --run-id <guid>
break-start captures Herdr’s focused pane and requests OMP’s native pause for every
eligible OMP pane, including the focused one. The first implementation submitted
/pause as a prompt; the August 15 repair routes the request through a dedicated
control key instead. While a break is active, normal pane.focused reconciliation
cannot exempt an agent. Break end clears the hold but resumes nothing; I choose
which agent deserves attention next.
The controller stores a set of active run IDs rather than one Boolean. That matters
because the :50 long-break process can still be sleeping when the :00 task
starts. An older invocation may remove only its own token, so it cannot falsely end
the newer break. An optional flag resumes only the pane captured as last active,
never every OMP process.
The 20:20 live test produced three independent pieces of evidence: the Scheduler logged its unique run ID, the controller’s ownership set was empty after the break, and Herdr detection screens showed two agents still paused while only the pane I selected had resumed. The complete plugin test file also passed all 17 tests, including overlap and default no-resume cases.
This path removes event-discovery ambiguity because the component that owns the schedule initiates the policy directly. It does not make enforcement real-time: in-flight calls still finish, and OMP pauses only at its next safe boundary.
Update: the prompt editor is not a control plane
On August 15, a real interaction exposed a more serious defect than latency. I was writing a prompt when focus changed. The plugin ran this line:
herdr_json(herdr, "agent", "prompt", pane_id, "/pause")
Herdr’s agent prompt operation submits text through OMP’s editor. The editor was
already holding my draft, so /pause joined user text instead of remaining a local
command. The plugin had treated a shared data channel as a control channel.
Clearing the editor first would merely exchange prompt corruption for prompt loss. Capturing, clearing, submitting, and restoring the draft would still race with the user’s keyboard. The repair therefore had one non-negotiable property: pause must not write text into the editor at all.
OMP’s RPC mode was not an escape hatch. omp --mode rpc creates a separate process
that reads JSON commands from its own standard input; it does not attach to an
already-running interactive TUI, and its command union has no process-global pause
operation.
OMP extensions already provide the needed control surface: a keyboard shortcut can
run code without entering editor text. A small extension binds Ctrl+Alt+P directly
to OMP’s existing process-wide agentPauseGate. The Python controller now sends
that logical key through Herdr:
PAUSE_SHORTCUT = "ctrl+alt+p"
def request_pause(herdr, pane_id):
herdr_json(herdr, "agent", "send-keys", pane_id, PAUSE_SHORTCUT)
The extension engages the same pause gate used by native /pause, presents a pause
overlay, and resumes on Escape, Enter, Space, or Ctrl-C. Main-agent, subagent, and
advisor semantics therefore remain process-wide; only the transport changed.
The live regression test used a fresh OMP process in a disposable Herdr pane:
- Type
DRAFT-SENTINEL-7f31 keep this exactwithout pressing Enter. - Send
Ctrl+Alt+Pthroughherdr agent send-keys. - Confirm that the pause overlay appears.
- Resume with Space.
- Read the editor again and compare the draft byte for byte.
The draft remained exact, and no /pause text appeared in it.
This repair also found a detection edge case. OMP’s wide pause screen ends with
esc · enter · space — resume, while a narrow or short pane ends with
esc to resume. Detection now requires the common P A U S E D title within the
final five non-empty lines and one of those exact final footers. A realistic wide
fixture matters: its two body lines and clock place the title fifth from last, so a
four-line tail check silently misses the native screen.
The resulting test suite passes 18 tests, including compact and full native layouts,
stale scrollback text, focus races, overlapping scheduled breaks, and the out-of-band
send-keys request. The current plugin subscribes only to pane.focused; removing
pane.agent_status_changed avoids a feedback loop in which pause-induced state
changes start more reconciliation handlers.
The revised lesson is stronger than “use the native pause mechanism”: use the native state transition through a control channel that cannot collide with user data.
What deterministic would require
A real-time focus invariant should live near the authoritative focus transition:
Herdr changes focused pane
├── records the new focus owner
└── applies the background pause policy
An asynchronous plugin can observe the transition later. It cannot make its observation atomic with the transition.
The honest guarantee table is:
| Architecture | Claim |
|---|---|
| Focus owner applies policy in-process | Deterministic focus safety |
| Event plugin with asynchronous CLI calls | Best-effort delayed pause |
| Plugin disabled | No automated protection |
The next useful investigation is not another lock variant. It is whether the Herdr event payload and plugin API can remove the slow CLI discovery calls, or whether the pause policy belongs inside Herdr itself.
Verification checklist
A final test must use the real Herdr UI:
- Start two unpaused OMP agents.
- Leave an identifiable unsent draft in agent A.
- Switch from agent A to agent B.
- Record the
pane.focusedevent time. - Confirm A receives the out-of-band control key and shows its pause overlay.
- Return to A, resume it manually, and confirm the draft is byte-identical.
- Repeat rapidly enough to overlap handlers.
- Repeat while one OMP tool call is in flight.
- Correlate focus, key dispatch, overlay, and session-log timestamps.
- Reject a deterministic claim if either agent starts new work after its safe boundary.
Unit tests prove selection rules. Only this end-to-end test proves that Herdr events, subprocess startup, CLI transport, terminal state detection, and OMP control compose into the intended attention boundary.
The larger lesson is simple: Herdr can tell a plugin that focus changed, and OMP can pause safely, but the latency between those facts determines the guarantee.
Source
- Herdr — runtime for coding agents
- Oh My Pi — terminal coding agent
- Ambient Attention Without Interruption
August 18 update: a keypress is not a durable pause request
The August 15 repair removed prompt corruption, but it still treated one successful
send-keys call as successful pause delivery. That assumption failed during a
scheduled break. Most OMP sessions paused; one busy session did not.
Tracing OMP’s input controller exposed the missing distinction. Herdr can write F12 to a pane’s PTY, and the PTY may buffer it briefly, but OMP has no durable “pause pending” queue. Extension shortcuts are registered on the main editor. If a dialog or another component owns input, the key can be routed there and consumed. Transport success therefore proves neither shortcut dispatch nor pause acceptance.
The first repair used a small filesystem mailbox per pane. Before sending F12, the
Python controller atomically wrote the active break run IDs and deadlines to a
deterministic pending file. The OMP extension checked that mailbox on F12, at
agent_end, and every two seconds. It atomically renamed a valid pending file to a
claim.
The 21:30 scheduled break exposed a subtler bug: this session logged pause-sent and
produced an acknowledgement, yet continued working. The extension had acknowledged
the claim before runPauseScreen() proved that the process-wide agent gate and the
fullscreen overlay had engaged. The acknowledgement meant “mailbox consumed,” not
“agent paused.”
The corrected extension owns the engagement sequence directly. It pauses the
process-global gate, creates and focuses the native PauseScreenComponent, and only
then renames the claim to an acknowledgement. If another modal owns the TUI, the
gate is already unavailable, or overlay creation fails, it restores the claim to the
pending path. The two-second poll then retries while the deadline remains valid.
pending.<pane>.json
│ atomic claim
▼
pending.<pane>.json.<pid>.<uuid>.claim
│ gate engaged + overlay created + focused
▼
ack.<pane>.json
failure before confirmation
claim ───────────────────────────────► pending (retry)
Globbing is used only for cleanup and discovery at break end; it is not the synchronization primitive. Direct reads and same-directory atomic renames own the claim transition. Break end removes its run from both pending and in-flight claim records, so a dialog queued behind another UI cannot produce a late pause after the break has ended. Overlapping breaks remain independent: ending one run preserves any other live run in the same pane record.
The event-origin policy also became explicit. Herdr exposes the triggering pane as
HERDR_PANE_ID. Creating a new OMP session pauses every other eligible OMP session,
not the newly created session itself. The extension likewise discards an inherited
mailbox on a genuinely new session before starting its poller.
August 18 update: Stretchly Skip must end controller ownership
The original fixed-break task treated elapsed time as the only end signal:
break-start → launch Stretchly → sleep 35s or 605s → break-end
That was wrong when I clicked Stretchly’s Skip button. Stretchly ended its UI, but the attention controller retained the active long-break run until the sleep expired. Restarting OMP during that interval correctly loaded the mailbox poller and immediately paused again because the controller still believed the break was active.
The scheduled orchestration now lives in a Python driver instead of PowerShell. Before launching the break it records the current byte offset of Stretchly’s log. It accepts a finish only after observing, in order and after that offset, this run’s matching pair:
Stretchly: starting Mini break
Stretchly: finishing Mini break
The same state machine handles Long breaks and markers split across log reads.
Clicking Skip emits the normal finishing ... break line, so the driver calls
break-end immediately. The configured duration remains a fallback deadline. A
five-line PowerShell compatibility launcher remains only because the existing Task
Scheduler action cannot be changed without elevation; all policy and lifecycle code
now runs in Python.
The 21:10 live run provided end-to-end evidence:
- Task Scheduler launched the compatibility wrapper and Python driver;
- the controller created one active run and per-pane pending records;
- this OMP session entered its native pause screen;
- Stretchly logged Mini-break start at 21:10:13 and finish at 21:10:43;
- the driver cleared the ownership set and all pending records;
- Task Scheduler returned result
0.
The full controller/driver suite passes 39 tests and the extension bundles
successfully against OMP. A fresh disposable OMP pane then received a scheduled
mailbox record, displayed the native P A U S E D overlay, and produced its ACK only
after the overlay was visible. The ordinary scheduled lifecycle and the corrected
acceptance boundary are therefore both verified live. The remaining
operational check is a deliberate live Skip during a future break; its ordered log
state machine is covered by unit tests but has not yet been exercised manually end to
end.
The updated lesson is: a control key is still only a transport event. Reliable attention control needs durable intent, explicit acceptance, expiry, and cancellation.