Pi extension for Neovim integration via Unix socket. Pairs with pi-bridge.nvim.
┌─────────────┐ Unix Socket ┌─────────────────┐
│ pi (TUI) │◄──────────────────► │ pi-bridge.nvim │
│ + extension│ JSON msgs │ (Lua) │
└─────────────┘ └─────────────────┘
This extension opens a Unix socket on session start, listens for incoming messages from Neovim, and calls pi.sendUserMessage() to inject them into the session. It also pushes agent_start and agent_end events back to Neovim.
Requires pi v1.0.4+.
Pin to a release (recommended):
pi install git:github.com/junkfactory/pi-bridge.ext@v1.3.0Update to a new version with:
pi install git:github.com/junkfactory/pi-bridge.ext@<version>Or track main (may encounter instability):
pi install git:github.com/junkfactory/pi-bridge.extClone the repo and install from the local path:
git clone https://github.com/junkfactory/pi-bridge.ext.git /path/to/pi-bridge.ext
cd /path/to/pi-bridge.ext
npm install # install dependencies
pi install . # install from local directoryEdits under src/ require a rebuild before pi picks them up:
npm run build # or: npx tscAlternatively, install from a sibling checkout next to pi-bridge.nvim:
pi install /path/to/pi-bridge.ext- On
session_start, the extension computessha256(process.cwd())and creates a socket at~/.pi/agent/pi-bridge/sockets/<sha256>.sock - If the socket already exists, it attempts to connect — success means another pi instance owns it (noop), failure means stale (remove and recreate)
- On
session_shutdown, the socket is cleaned up
~/.pi/agent/pi-bridge/sockets/<sha256>.sock
The hash is SHA256 of the absolute cwd, hex-encoded and truncated to 16 characters. This gives each project directory its own socket with no collisions.
JSON over Unix socket, bidirectional:
Neovim → pi (prompt with context):
{
"type": "prompt",
"text": "fix this function",
"context": {
"file": "/home/user/project/src/main.lua",
"cwd": "/home/user/project",
"mode": "normal",
"filetype": "lua",
"buffer_state": "saved"
}
}Context is metadata only — file path, cwd, current mode, and filetype. Buffer/selection content is not sent over the socket; the Neovim side handles content injection locally via placeholder substitution (@this, @selection, @diagnostics, etc.) before sending the prompt text.
String indicating the buffer's save state. When present, the handler uses it to emit tailored hints instead of file links. Valid values: "nameless", "scratch", "unsaved", "modified", "saved".
| State | What pi sees |
|---|---|
saved |
Clickable file link |
modified |
Hint that the file may have unsaved changes |
unsaved / nameless |
Hint that the buffer is unsaved (no file path) |
scratch |
Hint that the path is an ephemeral scratch copy |
When buffer_state is absent (e.g. from an older nvim plugin), the handler falls back to checking whether the file exists on disk via existsSync.
Prompts sent while the agent is mid-run are delivered with deliverAs: "steer", so pi redirects the current run instead of erroring; when idle the message starts a normal turn.
Compact line-range string identifying where a @this / @selection snippet came from in the buffer. The extension appends it to the File: link label as [basename:range](path) whenever it renders a File: link (i.e. the saved and existsSync branches); hint branches ignore it.
Format: a single start line ("25"), a span ("12-200"), or a comma-joined list of these ("25,40-45"). Line numbers are 1-indexed. Examples:
{ "range": "25" } // single line
{ "range": "12-200" } // span
{ "range": "25,40-45" } // two spans (multiple ranged placeholders in one message)Invalid values (non-string, malformed) and absence are both treated as "no range" — older senders that don't send the field see no behavioral change. Unknown context fields are also dropped, so a future sender adding more fields will not break older versions of this extension.
pi → Neovim (events):
{
"type": "agent_end",
"message": "done"
}The gate intercepts edit / write tool calls but only for turns that originated from Neovim. Turns typed directly into pi auto-allow (no prompt, no stall). The gate’s origin flag is set by the inbound prompt message handler, cleared on agent_end, and dropped on every session boundary (session_start / session_before_switch / session_shutdown) for leak-proofing.
tool_call(edit|write)
├─ nvim-originated turn? ── no ─► allow (return undefined)
├─ gate: file already approved ("a")? ── yes ─► allow
├─ compute unified diff (generateUnifiedPatch, disk file vs event.input)
├─ broadcast approval_request {id, path, tool, diff}
├─ race two surfaces:
│ ├─ nvim: user answers in Neovim's picker → approval_response {id, decision}
│ └─ pi: focused y/a/n prompt replaces the input box (ctx.ui.custom, no overlay)
│ y → "yes" a → "all" n → "no" Esc → "cancelled"
├─ first answer wins:
│ ├─ yes ─► allow
│ ├─ all ─► add path to per-file approved set, allow
│ ├─ no ─► block "User rejected edit to <path>"
│ └─ cancelled (Esc) ─► block "Edit approval cancelled"
└─ broadcast approval_resolved {id} exactly once
The diff is rendered by pi’s built-in edit/write preview in the transcript; we don’t duplicate it. The pi prompt replaces the input box (no overlay) so pi restores the editor when done() is called.
Disconnect semantics:
- nvim disconnects mid-prompt → the pi prompt stays open awaiting the user’s answer. The gate does not resolve the pending request. The user’s local answer is the only way forward.
- pi disconnects mid-prompt → nvim dismisses its picker with a
pi disconnectedmessage (covered by pi-bridge.nvim).
Exits from the prompt: y / a / n, Esc (= cancel this edit), or Ctrl+C (= abort the agent turn — handled via ctx.signal). No timeouts.
Per-file "all" memory is remembered for the duration of the session; a fresh session re-prompts even for previously-approved files. The memory is cleared on session_start, session_before_switch, and session_shutdown.
Disabling the gate: set PI_BRIDGE_EDIT_APPROVAL=0 before launching pi. The extension then allows every edit / write call without prompting (the original behavior).
Bash bypass: the gate only sees edit / write tool calls — shell mutations (sed -i, redirections, …) are invisible to it. To steer the agent toward the gated tools, the extension appends a standing file-editing instruction to every turn's system prompt (EDIT_TOOL_GUARD in src/index.ts) while the gate is enabled; disabling the gate removes the instruction too.
Headless / RPC modes: when ctx.hasUI === false (e.g. pi -p or JSON output), the extension auto-approves without opening a prompt — non-interactive workflows aren’t blocked. In RPC mode ctx.ui.custom returns undefined; the gate treats this as cancelled (block) to preserve the fail-safe semantics.
Neovim → pi (after the gate prompts):
{ "type": "approval_ack", "id": "<uuid>" }
{ "type": "approval_response", "id": "<uuid>", "decision": "yes" | "all" | "no" }approval_ack is accepted for older-pi compat but is no longer required — there is no ack window. The gate waits indefinitely (bounded by Ctrl+C / agent abort / session reset) for approval_response. Either surface (nvim or pi) can answer first; first-wins.
pi → Neovim (gate events):
{ "type": "approval_request", "id": "<uuid>", "tool": "edit" | "write", "path": "<abs>", "diff": "<unified patch>" }
{ "type": "approval_resolved", "id": "<uuid>" }approval_resolved is broadcast exactly once on every resolution path (yes / all / no / cancelled / disconnect-while-prompt-open) so Neovim can dismiss its picker even when the user answered in pi.
This is a socket protocol change. Both repos must be tagged at the same version when shipping approval support. See Releasing — Cross-repo pairing below.
The mirror intercepts ctx.ui.select, ctx.ui.confirm, and ctx.ui.custom for any pi extension during Neovim-originated turns. The prompt is mirrored to Neovim (a vim.ui.select picker for select/confirm, a passive "pi needs your input" notice for custom ctx.ui.custom components such as pi-permission-system's permission dialog); both surfaces race the same underlying decision, first answer wins.
Turns typed directly into pi auto-pass-through (no mirror, no stall) — the mirror is origin-scoped exactly like the edit gate. The origin flag is set by the inbound prompt handler, cleared on agent_end, and dropped on every session boundary (session_start / session_before_switch / session_shutdown).
Wrappers are installed on the shared extension ui object at session_start (pi builds a fresh ui object per session bind; a non-enumerable marker symbol guards against double-install on the same instance). Each wrapper gates on active() = env kill switch off AND mirrorReady AND nvim-originated turn; otherwise the original implementation is called untouched (zero overhead for pi-typed turns).
ctx.ui.select / ctx.ui.confirm / ctx.ui.custom
├─ env kill switch set? ── yes ─► original untouched
├─ mirrorReady (nvim sent mirror_ready)? ── no ──► original untouched
├─ nvim-originated turn? ── no ─► original untouched
├─ broadcast ui_prompt_request {id, kind, title|lines}
├─ race two surfaces:
│ ├─ nvim: vim.ui.select (select/confirm) or passive notice (custom)
│ └─ pi: ctx.ui.custom dismissable component (select/confirm) or the
│ real terminal dialog (custom) — answers reach the component's
│ own done() / handleInput()
├─ first answer wins:
│ ├─ select: chosen label returned; cancelled → undefined
│ ├─ confirm: "Yes" → true; "No" / cancelled → false
│ └─ custom: component's done() fires; wrapper broadcasts resolved
└─ broadcast ui_prompt_resolved {id} exactly once
What is NOT mirrored:
- pi's core dialogs (model switcher, session picker, themes) — they use the TUI directly, not the extension ui context
- pi's
@autocomplete — wired into pi's internalCustomEditor, never throughctx.ui ctx.ui.input/ctx.ui.editor— pass through to pi's TUI (answered there)
Disconnect semantics:
- nvim disconnects mid-prompt → the pi-side surface stays open awaiting the user's answer; the mirror does not resolve pending requests
- pi disconnects mid-mirror → nvim dismisses its picker/float with a
pi disconnectedmessage (covered by pi-bridge.nvim)
Handshake:
- nvim sends
mirror_readyonce on connect; only then does the ext side install its wrappers ontoctx.ui - the ready flag is cleared on socket disconnect (and a new
mirror_readyis required on reconnect) - on
session_before_switch/session_shutdown, in-flight structured prompts are settled as cancelled and custom component refs are dropped (custom dialogs are torn down by pi's own close paths; we only drop our refs)
Aborted select (opts.signal.aborted): the wrapper skips mirroring and calls the original, which resolves to undefined — no surface opens.
Components without render or handleInput: the custom wrapper feature-detects before committing and passes the component through untouched (no broadcast, no wrapping).
Neovim → pi (hello + answer):
{ "type": "mirror_ready" }
{ "type": "ui_prompt_response", "id": "<uuid>",
"value": "<option label>" } // picker answer (select / confirm)
{ "type": "ui_prompt_response", "id": "<uuid>",
"cancelled": true } // picker was dismissed (Esc / close)
{ "type": "ui_prompt_response", "id": "<uuid>",
"key": "<raw bytes>" } // custom-mirror Esc abort (nvim Esc →
// component's own Esc handler)ui_prompt_response carries exactly one of value / cancelled / key; the protocol layer rejects messages that set none or more than one.
pi → Neovim (request + resolved):
{ "type": "ui_prompt_request", "id": "<uuid>",
"kind": "select" | "confirm",
"title": "<string>",
"options": ["<label>", ...] }
{ "type": "ui_prompt_request", "id": "<uuid>",
"kind": "custom",
"lines": ["<rendered line>", ...] }
{ "type": "ui_prompt_resolved", "id": "<uuid>" }title and options are absent on custom requests; lines is absent on select / confirm. ANSI may be present in lines (kept in the payload for protocol completeness; the nvim side renders a fixed message-only notice, not the lines). ui_prompt_resolved is broadcast exactly once on every resolution path (nvim answer, pi answer, remote dismissal, session reset) so nvim can close its surface even when the user answered in pi.
Custom prompts are display-only on the nvim side: pi's dialog is the only interactive surface. The nvim notice is dismissed by ui_prompt_resolved, or — if the user presses <Esc> on the notice — the ext injects a raw Esc byte into the pi-side component (handleInput("\x1b")), letting the component's own Esc handler decide what abort means (e.g. deny for pi-permission-system). No other key is ever forwarded.
This is a socket protocol change. Both repos must be tagged at the same version when shipping mirror support. See Releasing — Cross-repo pairing below.
pi.sendUserMessage()— inject prompt as if typed in TUIpi.on("session_start", ...)— open socket; clears per-file "all" memory + origin flagpi.on("session_shutdown", ...)— close socket; clears origin flagpi.on("session_before_switch", ...)— clear per-file "all" memory + origin flagpi.on("agent_start/end", ...)— push events to Neovim;agent_endclears the origin flagpi.on("tool_call", ...)— interceptedit/writefor the approval gate (only when origin = nvim)generateUnifiedPatch(path, old, new)— diff computation (no extra dep)ctx.ui.custom(factory)— replace the input box with the focused y/a/n prompt (non-overlay; pi restores the editor whendone()is called)ctx.signal— agent abort signal; abort cancels the pending requestctx.hasUI— gate for headless modes (auto-approve when false)
Logs to ~/.pi/agent/pi-bridge.log:
- Socket creation / shutdown
- Messages received from Neovim
sendUserMessage()calls- Events pushed to Neovim
Set PI_BRIDGE_LOG_LEVEL to control verbosity:
# Default: info
pi -e ./src/index.ts
# Debug: log every message received
PI_BRIDGE_LOG_LEVEL=debug pi -e ./src/index.tsLevels: trace, debug, info, warn, error.
PI_BRIDGE_LOG_LEVEL— minimum log level (above)PI_BRIDGE_EDIT_APPROVAL=0— disable the edit-approval gate entirely (everyedit/writeis allowed without prompting). The runtime kill-switch avoids a rebuild when the gate is in the way; flip back to1(or unset) to re-enable.PI_BRIDGE_UI_PROMPT_MIRROR=0— disable the UI prompt mirror entirely; the extension never installs wrappers onctx.uiand every extension's prompts go straight to pi's TUI. The runtime kill-switch avoids a rebuild when the mirror is in the way; flip back to1(or unset) to re-enable.PI_BRIDGE_LOG_FILE— override the log destination (tests use this)
The log file is append-only and not rotated automatically. To rotate manually:
# Truncate (keeps file handle valid)
: > ~/.pi/agent/pi-bridge.log
# Or remove and let the extension recreate it on next message
rm ~/.pi/agent/pi-bridge.lognpm install # install dependencies
npx vitest run # run all tests
npx @biomejs/biome check . # lint + format check (CI runs this too)CI fails on lint errors — run npx @biomejs/biome check --write . before committing.
Releases are triggered by tagging. The tag.sh script handles validation, build checks, tagging, and pushing:
./.github/ci/tag.sh 0.1.2 # no 'v' prefix — script adds itThis runs npm ci, Biome lint, and the Vitest suite, creates a v0.1.2 tag on main, and pushes. The push triggers a CI job that creates the GitHub release with auto-generated notes.
Both repos release independently. The exception is a socket protocol change — both repos are then tagged at the same version. After both releases exist, a daily CI job appends a pairing line (e.g. "Requires pi-bridge.nvim v0.1.2") to each release's notes.
The edit-approval gate (see Edit Approval Gate) introduces four new message types (approval_request, approval_resolved, approval_ack, approval_response). The UI prompt mirror (see UI Prompt Mirror) introduces four more (mirror_ready, ui_prompt_request, ui_prompt_response, ui_prompt_resolved). Each protocol change must ship paired with the matching pi-bridge.nvim version.
DRY_RUN=1 ./.github/ci/tag.sh 0.1.2Runs checks and prints the tag/push commands without mutating anything.
- pi-bridge.nvim — Neovim plugin side