Repository navigation
feat: capture_since — non-blocking, cursor-based delta capture for agentic flows #60
Description
Problem
wait_for_text is the wrong shape for many agentic terminal workflows. It answers a narrow question: did an exact future pattern appear before timeout? In real dev loops, the agent often needs to inspect output, fuzzy-match it, notice a different port, watch several panes, or recover from a wrong assumption.
The current failure modes are tracked across the wait-family issues:
wait_for_text's delays (90 seconds) is can lock agentic flows - blocking #59: blocking lockout when the pattern is wrong or output is unexpected.- wait_for_text: two-call state/capture race within a single poll tick #50: a state/capture race within a
wait_for_textpoll tick. - wait_for_text: optional include_baseline_row=True for in-place row writes #51: same-row output such as
printf, spinners, and carriage-return redraws can sit on the baseline row. - wait_for_text: adaptive backoff to reduce subprocess pressure on parallel waits #52: parallel waits can hammer tmux with subprocess-backed polling.
- wait_for_text: expose risk_band_warned: bool in WaitForTextResult for clients that don't subscribe to log notifications #54: history-limit risk is currently warning-only, not structured in the result.
- Observation-first architecture: eliminate agent lockout from blocking wait tools #61: umbrella architecture issue for shifting pane automation toward observation-first tools.
The common root is that wait_for_text hides terminal output behind found plus matched_lines. If the agent can see the actual pane delta quickly, it can apply its own judgment with full project context.
Proposal: capture_since
Add a non-blocking, cursor-based pane observation tool that returns output rather than deciding whether output is good enough.
async def capture_since(
pane_id: str | None = None,
cursor: str | None = None,
session_name: str | None = None,
session_id: str | None = None,
window_id: str | None = None,
socket_name: str | None = None,
max_lines: int = 200,
max_bytes: int = 20000,
) -> CaptureSinceResult: ...class CaptureSinceResult(BaseModel):
pane_id: str
cursor: str
lines: list[str]
elapsed_seconds: float
lines_missed: bool
truncated: boolThe exact field names can follow local model conventions, but the contract should stay simple: return bounded new pane text, a cursor to pass back next time, and structured metadata when output may have been lost or truncated.
Design decisions
| Decision | Choice | Why |
|---|---|---|
| Blocking model | Return immediately | Avoids agent lockout and lets the agent interleave panes. |
| First call | Return visible screen and establish a cursor | Useful immediately; avoids a wasted baseline-only call. |
| Cursor format | Opaque versioned string | Keeps the API stable if internals move from tmux grid coordinates to another backend later. |
| Judgment | Agent judges, not ctx.sample() |
No hidden LLM call, no client sampling dependency, full project context stays with the agent. |
| Output size | Bound with max_lines and max_bytes |
Prevents token blowups in log-heavy panes. |
The opaque cursor should encode enough server-side facts to detect stale cursors and history loss, likely including pane identity, history_size, cursor_y, pane dimensions or a content hash, and a cursor version. Callers must treat it as opaque.
Expected workflows
Start a server and inspect what happened:
send_keys("python server.py", pane_id="%5")
result = capture_since(pane_id="%5")
# Agent sees: Listening on 0.0.0.0:3333Monitor multiple panes without blocking on one:
api = capture_since(pane_id="%1")
web = capture_since(pane_id="%2")
worker = capture_since(pane_id="%3")Poll with caller-controlled backoff:
first = capture_since(pane_id="%5")
second = capture_since(pane_id="%5", cursor=first.cursor)Upstream mechanisms
The first implementation should use the same tmux grid facts already used by wait_for_text: history_size, cursor_y, and capture-pane ranges. tmux capture coordinates are evaluated against live history in cmd-capture-pane.c, and history trimming happens in grid_collect_history(), so loss detection is still best-effort near history-limit.
Future backends should remain possible behind the opaque cursor. tmux has event-oriented surfaces in pipe-pane and control-mode output via control_write_output(), but those are separate streaming designs and should not block the initial snapshot/delta tool.
FastMCP ctx.sample() is intentionally out of scope for this tool. The agent already has the project context and can inspect the returned output directly. FastMCP sampling is client-capability-dependent and would hide cost and reasoning inside the tool.
Relationship to existing tools
| Tool | Question it answers | Blocking | Best for |
|---|---|---|---|
wait_for_channel |
Has my authored command finished? | Blocking, zero-poll in tmux | Commands the agent sends. |
capture_since |
What happened since last inspection? | Non-blocking | Tailing, fuzzy judgment, multi-pane monitoring. |
wait_for_text |
Did this exact future pattern appear? | Blocking, polling | Known third-party output. |
wait_for_content_change |
Did the pane content change at all? | Blocking, polling | Tripwire before inspection. |
snapshot_pane |
What is the full pane state now? | Non-blocking | Full context and metadata. |
capture_since complements rather than replaces these tools. It removes the need to abuse boolean waits for exploratory observation.
Acceptance criteria
- New read-only MCP tool registered and documented as
capture_since. - First call with
cursor=Nonereturns bounded visible screen content and a cursor. - Subsequent calls with the returned cursor return only new output where tmux history still permits it.
- Result includes structured
lines_missedandtruncatedmetadata. - Invalid, stale, or wrong-pane cursors fail clearly or return conservative metadata; do not silently pretend output is complete.
- Tests cover first-call visible content, delta-only follow-up, max-line/max-byte truncation, history-loss behavior, pane respawn/death behavior, and multi-pane calls.
- Docs route tailing/diagnosis workflows to
capture_since, authored command completion towait_for_channel, and exact future matches towait_for_text.
Activity
[-]feat: capture_since — non-blocking, cursor-based delta capture for agentic flows[/-][+]feat: `capture_since` — non-blocking, cursor-based delta capture for agentic flows[/+]tony commentedon May 24, 2026
Triage pass (2026-05-24): keep this as the main architecture action after the targeted correctness fixes.
Current-code check: there is no
capture_sincesurface today. The proposed implementation can reuse the same tmux/libtmux facts already used by the wait tools:history_size,cursor_y, boundedcapture-paneranges, and pane lifecycle state. For source links, prefer the exact tmux capture-coordinate math atcmd-capture-pane.c#L150-L160, history trimming atgrid_collect_history(), and libtmuxPane.capture_pane()atf70cb62. Future streaming references are still correctly separate:pipe-paneand control-mode output viacontrol_write_output().Overlap/value: this will overshadow or at least de-prioritize #50, #51, and #52 because cursor-based non-blocking observation reduces the need for more polling sophistication inside
wait_for_text. It does not replace #53, #54, or #55, which are correctness fixes for existing tools. Recommended action: buildcapture_sincewith an opaque versioned cursor, bounded output, lifecycle/stale-cursor detection, and explicitlines_missed/truncatedmetadata before revisiting deeper wait polling changes.