Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

64 Commits

Folders and files

Repository files navigation

pi-bridge.ext

Pi extension for Neovim integration via Unix socket. Pairs with pi-bridge.nvim.

Architecture

┌─────────────┐     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.

Install

Requires pi v1.0.4+.

Production

Pin to a release (recommended):

pi install git:github.com/junkfactory/pi-bridge.ext@v1.3.0

Update 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.ext

Development

Clone 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 directory

Edits under src/ require a rebuild before pi picks them up:

npm run build        # or: npx tsc

Alternatively, install from a sibling checkout next to pi-bridge.nvim:

pi install /path/to/pi-bridge.ext

How It Works

Socket Lifecycle

  1. On session_start, the extension computes sha256(process.cwd()) and creates a socket at ~/.pi/agent/pi-bridge/sockets/<sha256>.sock
  2. If the socket already exists, it attempts to connect — success means another pi instance owns it (noop), failure means stale (remove and recreate)
  3. On session_shutdown, the socket is cleaned up

Socket Path

~/.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.

Message Protocol

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.

buffer_state (optional)

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.

context.range (optional)

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"
}

Edit Approval Gate

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 disconnected message (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.

Approval protocol (NDJSON, additive)

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.

Version pairing

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.

UI Prompt Mirror

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 internal CustomEditor, never through ctx.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 disconnected message (covered by pi-bridge.nvim)

Handshake:

  • nvim sends mirror_ready once on connect; only then does the ext side install its wrappers onto ctx.ui
  • the ready flag is cleared on socket disconnect (and a new mirror_ready is 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).

Mirror protocol (NDJSON, additive)

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.

Mirror version pairing

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.

Key APIs Used

  • pi.sendUserMessage() — inject prompt as if typed in TUI
  • pi.on("session_start", ...) — open socket; clears per-file "all" memory + origin flag
  • pi.on("session_shutdown", ...) — close socket; clears origin flag
  • pi.on("session_before_switch", ...) — clear per-file "all" memory + origin flag
  • pi.on("agent_start/end", ...) — push events to Neovim; agent_end clears the origin flag
  • pi.on("tool_call", ...) — intercept edit / write for 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 when done() is called)
  • ctx.signal — agent abort signal; abort cancels the pending request
  • ctx.hasUI — gate for headless modes (auto-approve when false)

Logging

Logs to ~/.pi/agent/pi-bridge.log:

  • Socket creation / shutdown
  • Messages received from Neovim
  • sendUserMessage() calls
  • Events pushed to Neovim

Log Level

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.ts

Levels: trace, debug, info, warn, error.

Kill Switches

  • PI_BRIDGE_LOG_LEVEL — minimum log level (above)
  • PI_BRIDGE_EDIT_APPROVAL=0 — disable the edit-approval gate entirely (every edit/write is allowed without prompting). The runtime kill-switch avoids a rebuild when the gate is in the way; flip back to 1 (or unset) to re-enable.
  • PI_BRIDGE_UI_PROMPT_MIRROR=0 — disable the UI prompt mirror entirely; the extension never installs wrappers on ctx.ui and 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 to 1 (or unset) to re-enable.
  • PI_BRIDGE_LOG_FILE — override the log destination (tests use this)

Log Rotation

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.log

Running Tests and Lint

npm 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.

Releasing

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 it

This 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.

Cross-repo pairing

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

DRY_RUN=1 ./.github/ci/tag.sh 0.1.2

Runs checks and prints the tag/push commands without mutating anything.

Related

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages