Skip to content

feat(subagents): add kitty as a supported terminal backend#31

Open
tcrasset wants to merge 5 commits intoHazAT:mainfrom
tcrasset:tc/add-kitty-support
Open

feat(subagents): add kitty as a supported terminal backend#31
tcrasset wants to merge 5 commits intoHazAT:mainfrom
tcrasset:tc/add-kitty-support

Conversation

@tcrasset
Copy link
Copy Markdown

@tcrasset tcrasset commented Apr 22, 2026

Done with the help of an AI agent. Manually tested that it works with kitty. Didn't test other implementations.

Each commit's body contains an explanation as to why that particular change was necessary.

Uses kitten @ remote control CLI to implement all surface operations.
Each subagent gets a new kitty tab (kitten @ launch --type=tab).
Surface identifier is the kitty window ID returned by launch.

Operations:

  • createSurface: kitten @ launch --type=tab, returns numeric window ID
  • sendCommand: kitten @ send-text --match id:
  • readScreen: kitten @ get-text --match id:
  • closeSurface: kitten @ close-window --match id: --no-response
  • renameTab: kitten @ set-tab-title (no match = current tab)
  • renameWorkspace: no-op (kitty has no session concept)

Detection: KITTY_WINDOW_ID env var + kitten command present.
Auto-detection order places kitty last so tmux-inside-kitty prefers tmux.
Force with PI_SUBAGENT_MUX=kitty.

Requires allow_remote_control yes in kitty.conf + full kitty restart.
Emits an actionable error when kitten reports remote control is disabled.

Screencast.From.2026-04-22.17-03-48.mp4

Uses kitten @ remote control CLI to implement all surface operations.
Each subagent gets a new kitty tab (kitten @ launch --type=tab).
Surface identifier is the kitty window ID returned by launch.

Operations:
- createSurface: kitten @ launch --type=tab, returns numeric window ID
- sendCommand:   kitten @ send-text --match id:<id>
- readScreen:    kitten @ get-text  --match id:<id>
- closeSurface:  kitten @ close-window --match id:<id> --no-response
- renameTab:     kitten @ set-tab-title (no match = current tab)
- renameWorkspace: no-op (kitty has no session concept)

Detection: KITTY_WINDOW_ID env var + kitten command present.
Auto-detection order places kitty last so tmux-inside-kitty prefers tmux.
Force with PI_SUBAGENT_MUX=kitty.

Requires allow_remote_control yes in kitty.conf + full kitty restart.
Emits an actionable error when kitten reports remote control is disabled.
Root cause
----------
kitten @ communicates via APC escape sequences sent through the
terminal pty. When invoked from the main pi Node.js process,
kitty's response bytes race with pi's own stdin reader (which runs
in raw terminal mode). For kitten @ get-text the response can be
several kilobytes of screen content; those bytes bleed into pi's
input buffer and appear as if typed by the user.

Fix: readScreen / readScreenAsync
----------------------------------
If KITTY_LISTEN_ON is set (requires listen_on in kitty.conf) use
  kitten @ --to <socket> get-text
which communicates via a Unix socket and never touches the pty.

If no socket is available, return "" so pollForExit falls through
to the file-based detection mechanisms (see sentinel-file commit)
instead of reading the terminal screen.

Fix: pollForExit sentinel file
-------------------------------
The sentinel file check previously always returned exitCode 0.
It now reads the file contents (the exit code written by the
launch script) so crash exits are reported correctly.
pollForExit has three detection mechanisms in priority order:
  1. .exit sidecar  (written by subagent_done / caller_ping)
  2. sentinelFile   (was Claude Code only)
  3. screen polling (reads terminal for __SUBAGENT_DONE_N__)

For pi subagents the sentinelFile was never set, leaving screen
polling as the only crash-detection mechanism. On kitty this triggers
kitten @ get-text every second, causing APC-over-pty interference.

The launch script now captures the exit code before any subsequent
command can overwrite $?:

  pi ...; _PI_EXIT=$?; echo $_PI_EXIT > /path/to/script.done; echo '__SUBAGENT_DONE_'$_PI_EXIT'__'

The .done path is stored on RunningSubagent.sentinelFile and passed
to pollForExit. The sentinel-file check (already in pollForExit and
now reading the exit code from the file contents) fires on the next
1-second tick after any crash — no screen read required.
Agents with auto-exit: true shut down by calling ctx.shutdown() from
the agent_end event handler — bypassing the subagent_done tool, which
is the only previous writer of the .exit sidecar file. So for every
auto-exit agent (scout, worker, reviewer) the sidecar was never
written and pollForExit always fell through to the slow screen-polling
path (readScreenAsync every second).

On kitty this triggers kitten @ get-text on every poll tick, causing
the APC response bytes to race with pi's stdin reader and appear as
typed characters in the main session's input field.

The agent_end handler now writes the .exit sidecar before calling
ctx.shutdown(). pollForExit's fast path (file check, no screen read)
fires on the next tick, so a normal auto-exit run produces zero
kitten @ get-text calls.
subagent_resume only propagated PI_CODING_AGENT_DIR to the resumed
process. Three other env vars were missing:

PI_SUBAGENT_AUTO_EXIT=1
  Without this the auto-exit listener in subagent-done.ts is never
  attached, so the resumed session does not shut down automatically
  when the agent finishes, and never writes the .exit sidecar.

PI_SUBAGENT_SESSION=<path>
  Without this subagent_done and caller_ping cannot find the session
  file path to write the .exit sidecar — their writeFileSync call is
  guarded by an existence check on this env var.

PI_SUBAGENT_NAME=<name>
  Without this the running-subagents widget shows no agent label for
  the resumed session.
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.

1 participant