Skip to content

feat: quiet events & fixed quiet detection#5

Open
cryppadotta wants to merge 1 commit intonicobailon:mainfrom
cryppadotta:main
Open

feat: quiet events & fixed quiet detection#5
cryppadotta wants to merge 1 commit intonicobailon:mainfrom
cryppadotta:main

Conversation

@cryppadotta
Copy link

I'm working on a new pi plugin pi-driver, that uses pi-interactive-shell.

I needed two things:

  1. an event to detect when the underlying shell was quiet and
  2. a bugfix so you can detect things like claude code are actually quiet.

Below is my Claude's summary:


PR: Emit hands-free update events + fix quiet detection for TUI apps

Title

feat: emit hands-free update events via pi.events + fix quiet detection for TUI apps

Summary

Two small, focused changes that enable extensions to receive hands-free mode notifications and fix a bug where quiet detection never triggers for TUI-based sub-agents.

1. Emit interactive-shell:update events via pi.events (index.ts)

The onHandsFreeUpdate callback was already wired in the blocking code path but not in the two non-blocking paths (attach and new session). This means extensions running in hands-free mode with updateMode: "on-quiet" had no way to receive quiet/exit/takeover notifications.

This PR wires onHandsFreeUpdate in both non-blocking paths to broadcast via pi.events.emit("interactive-shell:update", ...). The event payload is the same HandsFreeUpdate shape that already exists in types.ts.

Use case: An outer-loop driver extension (like pi-drive) launches a sub-agent in hands-free mode and needs to know when it goes quiet so it can evaluate output and send follow-up prompts.

2. Content-based quiet detection filtering (overlay-component.ts)

Bug: TUI apps built with Ink (e.g. Claude Code) emit periodic ANSI-only PTY data — cursor blink sequences, frame redraws, status bar updates — even when the agent is idle and waiting for input. The existing onData handler reset the quiet timer on every PTY data event regardless of content, so quiet was never detected.

Fix: Use stripVTControlCharacters() from node:util to check if PTY data contains visible content. Only reset the quiet timer when there's actual visible output. ANSI-only frames (cursor repositioning, color codes, etc.) are ignored.

This is a one-line behavioral change in the onData handler. It doesn't affect dispatch mode or interval-based updates — only the on-quiet update mode.

Files changed

  • index.ts — Wire onHandsFreeUpdate in 2 non-blocking paths (+28 lines, 0 removed)
  • overlay-component.ts — Add stripVTControlCharacters import, filter ANSI-only data in onData (+14 lines, -3 lines)

Why it's safe to merge

  • No breaking changes. The onHandsFreeUpdate callback and HandsFreeUpdate type already exist — this just wires them in paths that were missing them.
  • No new dependencies. stripVTControlCharacters is from Node.js built-in node:util (stable since Node 16).
  • No behavior change for dispatch mode. The quiet detection fix only applies when updateMode === "on-quiet" in hands-free mode. Dispatch mode and interval-based updates are untouched.
  • The event channel is additive. Extensions that don't listen for interactive-shell:update are unaffected.
  • Small diff. 39 lines added, 3 removed across 2 files. No refactoring, no structural changes.

…on for TUI apps

Wire onHandsFreeUpdate in non-blocking code paths (attach and new
session) to broadcast updates via pi.events.emit("interactive-shell:update").
This lets other extensions (e.g. pi-drive) receive quiet/exit/takeover
notifications without polling.

Fix quiet detection for Ink-based TUI apps (e.g. Claude Code) that
emit periodic ANSI-only frames (cursor blink, redraws). These frames
previously reset the quiet timer on every PTY data event, preventing
quiet from ever being detected. Now uses stripVTControlCharacters()
to filter ANSI-only output — only visible content resets the timer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@nicobailon
Copy link
Owner

Cool! The core ideas here are good, but heads up that onHandsFreeUpdate does double duty in the overlay's state machine beyond just being a callback, so wiring it into new paths has some side effects worth looking at. My feedback:

  1. Setting onHandsFreeUpdate in the non-blocking paths changes session lifecycle. The overlay uses its presence to decide whether to unregister sessions immediately on completion (see finishWithExit, finishWithKill, triggerUserTakeover, etc). Non-blocking hands-free sessions currently stay registered so the agent can query them later, but with this change they get cleaned up as soon as the event fires. So if a consumer receives an exited event and tries to query the session for full output, it's already gone. The change would need a separate flag to distinguish streaming vs non-blocking rather than overloading onHandsFreeUpdate.

  2. The ANSI fix is missing from HeadlessDispatchMonitor. Line 60 of headless-monitor.ts resets the quiet timer on every data event without filtering, so background dispatch with TUI apps still won't detect quiet.

  3. The event emission manually picks fields off the update object, so new HandsFreeUpdate fields won't show up in the event. Could just pass update directly since update.sessionId is already correct.

  4. The two non-blocking paths (attach to existing session and start new session) now emit pi.events, but the blocking path (where the tool call awaits the overlay until the session ends) still only calls the streaming onUpdate callback. Worth wiring pi.events there too so an extension can listen for interactive-shell:update and hear about every hands-free session regardless of which code path started it.

  5. Since you're already wiring onHandsFreeUpdate in the non-blocking paths, consider also adding pi.sendMessage with triggerTurn: true alongside the event emission. Right now pi.events only notifies external extensions, but the calling agent itself still has to poll to find out the session went quiet. Adding triggerTurn would wake the agent up with the tail output, same way dispatch already does on completion, except the session stays alive. That gives the agent the best of both worlds: no polling, and it can still send follow-up input.

No pressure to do any of this, I also don't mind taking it on directly. Thanks again!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants