@nicknisi/pi-btw

/btw — side-channel LLM chat in a floating window, never touching main agent context

Packages

Package details

extension

Install @nicknisi/pi-btw from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@nicknisi/pi-btw
Package
@nicknisi/pi-btw
Version
0.2.3
Published
Sep 12, 2026
Downloads
681/mo · 238/wk
Author
nicknisi
License
MIT
Types
extension
Size
70.7 KB
Dependencies
1 dependency · 3 peers
Pi manifest JSON
{
  "extensions": [
    "./index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

@nicknisi/pi-btw

Side-channel LLM chat in a floating window. /btw <question> opens an overlay backed by a configurable one-off streamSimple thread that sees the current branch's conversation context plus your questions. Answers stream live into the window, follow-ups can be typed in place, and the thread never touches the main agent's context no matter how deep it goes. On close, the thread persists as a custom session entry that renders in the transcript but never enters LLM context or triggers a turn.

Use it for the "quick question while the agent works" case: explanations, alternatives, sanity checks — without polluting the main conversation or spending a steer/follow-up turn on it.

What it adds

Surface Name Notes
Slash command /btw <question> Uses the configured model override or the current session model
Overlay window BtwWindow via ctx.ui.custom() Streaming markdown thread + follow-up editor
Custom entry type btw-answer Persisted via pi.appendEntry(), rendered by pi.registerEntryRenderer()
Legacy custom message type btw-answer Old sessions stored answers as custom messages; still rendered and filtered out of context
Event hook context Filters legacy btw-answer custom messages out of the LLM context
Env var PI_BTW_SPLIT h or v to force tmux split direction on fork

No keybindings are registered globally; all keys below are handled by the overlay itself.

Usage

/btw why is this test flaky?

The window opens immediately and streams the answer. While idle (not streaming):

Key Action
enter Send a follow-up question
esc / ctrl+c Close the window; thread persists as a transcript card
ctrl+p Promote: close and hand the thread to the main agent
ctrl+f Fork: write branch + thread to a new session file and open it
up / down Scroll (when the editor is empty)

While streaming:

Key Action
esc / ctrl+c Cancel the in-flight answer; the question is restored into the editor for retry
up / down Scroll

Promote (ctrl+p)

Closes the window and sends the thread to the main agent as a user message:

FYI — I had this side conversation with <model> (via /btw). Factor it into what you're doing where relevant:

Q: ...
A: ...

If the agent is mid-turn it is delivered as a steer message (deliverAs: "steer"); otherwise as a normal follow-up.

Fork (ctrl+f)

Writes the current branch plus the btw thread (as real user/assistant messages) to a new session file in the session directory, leaving the live session untouched. The forked session is named btw: <first question> and records parentSession pointing at the live session file. It is then opened on the best available surface:

  1. tmux split — if $TMUX is set. Direction is -h when the pane is ≥ 160 columns wide, otherwise -v; override with PI_BTW_SPLIT=h|v.
  2. New Ghostty window (macOS) — via open -na Ghostty --args --working-directory=<cwd> -e /bin/zsh -ilc 'exec pi --session ...'. Runs through a login+interactive zsh because GUI apps launched by open only inherit launchd's minimal PATH, which typically lacks pi.
  3. Clipboard + printed commandpi --session <file> copied via pbcopy (macOS only) and shown in a notification.

Fork happens before the transcript card is persisted, so the forked session does not contain the card entry.

How it works

  • On /btw, the current branch (ctx.sessionManager.getBranch()) is converted to LLM messages with convertToLlm(). Assistant tool calls with no matching toolResult are dropped first, because /btw can run mid-turn when tool calls are still unanswered, and Anthropic rejects tool_use blocks without a matching tool_result.
  • The side thread starts from that snapshot. Each question is sent to the configured model override, or the current session model if no override is configured or it cannot be resolved, with a fixed system prompt (concise, no tool suggestions) via getModelProvider(ctx, model).streamSimple(...), with apiKey/headers from ctx.modelRegistry.getApiKeyAndHeaders() and the current thinking level (off maps to undefined). Completed Q&A pairs are appended to the side thread, so follow-ups have full side-thread history plus the original branch snapshot.
  • The overlay is a Component/Focusable from @earendil-works/pi-tui, rendering a bordered box (max width 100, body capped at 30 rows or terminal.rows - 14) with a spinner while streaming and markdown-rendered answers.
  • On close with at least one completed turn, the thread is persisted via pi.appendEntry<BtwEntryData>("btw-answer", { model, turns }).

Why custom entries instead of pi.sendMessage()

Custom messages participate in LLM context and, when sent while the agent is streaming, are delivered as steer messages — which continues the agent loop with an extra LLM call. The extension's own context filter would then strip the btw message, leaving the conversation ending on an assistant message, which models that reject assistant prefill 400 on. Custom entries render in the transcript but never enter context or trigger a turn by design. The context event hook and registerMessageRenderer exist only to support legacy sessions that predate the entry-based approach.

Configuration

Optional global config: ~/.pi/agent/configs/btw.json. Changes take effect on the next session or after /reload.

{
  "model": "fireworks/glm-latest"
}

model is an optional provider/model-id override registered in Pi with working authentication. Model IDs may contain additional slashes; only the first slash separates the provider. If it is omitted or cannot be resolved, /btw uses the current session model. Invalid config emits a warning. See btw.example.json.

Variable Values Default
PI_BTW_SPLIT h, v unset — split direction chosen from pane width (≥ 160 cols → horizontal)

$TMUX (set by tmux itself) is checked to decide the fork-open surface.

Dependencies

Runtime (workspace):

  • @nicknisi/pi-sharedgetModelProvider(ctx, model), which resolves the composed runtime provider from ctx.modelRegistry.getProvider() (honoring models.json overrides and extension-registered providers).

Peer (pi APIs):

  • @earendil-works/pi-aiMessage, AssistantMessage, ThinkingLevel types.
  • @earendil-works/pi-coding-agentExtensionAPI, SessionEntry, Theme, convertToLlm, CURRENT_SESSION_VERSION, getMarkdownTheme; extension surface used: pi.registerCommand, pi.on("context"), pi.registerMessageRenderer, pi.registerEntryRenderer, pi.appendEntry, pi.sendUserMessage, pi.getThinkingLevel, ctx.ui.custom, ctx.ui.notify, ctx.sessionManager, ctx.modelRegistry, ctx.model, ctx.isIdle().
  • @earendil-works/pi-tuiBox, Component, Editor, Focusable, Key, Markdown, Text, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi.

Node builtins: child_process (tmux/Ghostty/pbcopy), crypto (entry/session ids), fs, path (fork session file).

Caveats

  • Fork mirrors SessionManager internals. forkSessionWithThread() reimplements what SessionManager.createBranchedSession does (minus the switch-in-place): it writes the jsonl session format directly, pinned to CURRENT_SESSION_VERSION. A session-format change in pi will break forks until this is updated.
  • Label entries are dropped in forked sessions, because labels may parent other entries and would break the re-chained parentId links.
  • Theme keys relied on: accent, dim, muted, warning, success, error, border, borderAccent, customMessageBg. A theme missing these will degrade rendering.
  • Terminal size heuristic: body height is min(rows - 14, 30); on very small terminals the window may still crowd the screen.
  • macOS-only fallbacks: Ghostty window opening and pbcopy are darwin-only; on other platforms fork falls back to printing the pi --session command.
  • Ghostty PATH workaround depends on pi being on the PATH of a login+interactive zsh (zsh -ilc). If your shell init doesn't put pi on PATH, the Ghostty fork window will fail.
  • Mid-turn tool calls: context snapshot silently drops unanswered tool calls, so the side assistant doesn't see in-flight tool invocations.
  • Entry ids in forked sessions are 4-byte random hex; collision-checked against existing ids.

Install

pi install /Users/nicknisi/Developer/pi-extensions/packages/btw