# pi-ide-context Protocol v0.2

Single source of truth for the editor ↔ pi state exchange. The pi extension, the
Neovim client, and any future editor clients (VS Code, Obsidian) all follow this
document.

## State file

Each running editor writes one JSON file:

```
<state_dir>/<pid>.json
```

- `state_dir` = `$XDG_RUNTIME_DIR/pi-ide` when `XDG_RUNTIME_DIR` is set, else `/tmp/pi-ide`.
- The directory **must be created 0700** (owner-only). The file is inside that
  directory, so `0600` semantics are implied. Editors that support it should
  also `chmod` the file to `0600`.
- Writes are atomic: write to `<pid>.json.tmp`, then rename.
- Clients must write on buffer/window switch, cursor move, text change, mode
  change (selection), and **directory change** (`:cd` / `:tcd` — without that the
  file keeps a stale `cwd` until the next cursor event). Non-urgent writes may be
  debounced; the Neovim client uses 200ms.
- The editor deletes its file on clean exit (`VimLeavePre` etc.). Stale files
  from killed editors are tolerated: the pi side checks process liveness via
  `kill(pid, 0)` and ignores dead ones.

## Freshness model

Two different clocks, do not confuse them:

| Field | Meaning | Used for |
|---|---|---|
| `timestamp` | Last time the editor wrote the file (info only) | debugging, last-write insight |
| `selection.selected_at` | When the user last *made* a selection (unix seconds) | freshness: pi injects the selection text only while fresh |

`selected_at` is **not** the write time. The Neovim client sets it to the moment
the user left visual mode (or `now` while still inside visual mode), and keeps
reporting the old marks afterwards. This is what lets the pi side tell "just
selected, meant for this prompt" apart from "selected ten minutes ago, stale
marks" — and never guess from registers.

The pi side defines one window, `SELECTION_FRESH_SECONDS = 60`:

- selection `now - selected_at <= 60s` → **fresh**: injected in full; the pi-side pill shows the selection in its bubble
- selection older → **stale**: treated as no selection, text is not injected

## Fields

```json
{
  "pid": 12345,
  "cwd": "/Users/andy/my-project",
  "timestamp": 1700000000,
  "app": "nvim",
  "active_buffer": {
    "file": "/Users/andy/my-project/src/main.ts",
    "name": "main.ts",
    "language": "typescript",
    "cursor": { "line": 42, "column": 10 },
    "selection": null,
    "modified": false,
    "lines_total": 200
  }
}
```

| Field | Type | Notes |
|---|---|---|
| `pid` | number | Editor process id; pi checks liveness with `kill(pid, 0)` |
| `cwd` | string | The editor process's working directory (not the directory of the active file). For Obsidian it is the vault root — the base a relative `active_buffer.file` resolves against. pi uses it — together with `active_buffer.file` and `argv` — to decide which project the editor belongs to, see below |
| `timestamp` | number | Unix seconds of last write (informational) |
| `app` | string | Editor kind: `"nvim"`, `"vscode"`, `"obsidian"`, `"jetbrains"` (all JetBrains IDEs report this one value) |
| `product` | string (optional) | The specific product behind `app`, e.g. `"WebStorm"` / `"PyCharm"` / `"DataGrip"`. pi picks the status glyph and the `/ide` display name from it; without it every JetBrains IDE would read `jetbrains`. Unknown products fall back to the `app` mark |
| `argv` | string[] (optional) | Launch args without argv[0], e.g. `["pi-ide-context/"]` from `nvim pi-ide-context/`. Stable per instance — disambiguates same-cwd editors. Entries may be flags, plain file names or non-existent paths; pi only counts one as project evidence when it resolves to an existing directory |
| `active_buffer.file` | string \| null | Absolute path; relative (vault/workspace-relative) when the host has no absolute path — Obsidian writes vault-relative paths like `Notes/foo.md`. pi resolves a relative path against `cwd` **before injecting**, so the block the model sees always carries a readable absolute path. `null` for unnamed buffers |
| `active_buffer.name` | string | Display name (basename for files) |
| `active_buffer.language` | string \| null | `filetype` (nvim) / language id (vscode) |
| `active_buffer.cursor` | `{line, column}` | **1-based** line and **1-based byte** column |
| `active_buffer.selection` | object \| null | See below; `null` when the user has no recent selection |
| `active_buffer.modified` | boolean | Buffer has unsaved changes |
| `active_buffer.lines_total` | number | Line count of the buffer |
| `database` | object (optional) | Table view context, sent only by JetBrains clients running the Database plugin (DataGrip, IDEA Ultimate). See below |

`cursor.line`/`cursor.column` may be `null` for hosts without a cursor concept;
clients that always have one (nvim, vscode) write real values.

## The `database` block (optional)

A table view is not a text buffer: there is no file, no cursor, no selection text.
The JetBrains client therefore sends a separate summary, and only when the
Database plugin is present:

```json
"database": {
  "table": "rooms",
  "data_source": "mwac@localhost",
  "schema": "public",
  "primary_key": ["id"],
  "columns": [{ "name": "id", "type": "int4" }],
  "columns_total": 42,
  "foreign_keys": [{
    "columns": ["building_code"],
    "ref_table": "public.buildings",
    "ref_columns": ["code"],
    "on_delete": "CASCADE"
  }],
  "filter": "attributes ? 'office_description'",
  "row_count": 1200,
  "cell": { "row": 4, "column": "attributes", "type": "jsonb", "value": "{\"a\":1}" }
}
```

Rules, because this block goes straight into a prompt:

- Every list is capped by the client (40 columns, 10 foreign keys). `columns`
  carries the first N and `columns_total` the real count, so the reader knows what
  it is missing.
- `cell.value` is text, may be `null` (SQL NULL), and is clipped. Binary data is
  reported as `<N bytes>` instead of being dumped.
- Nothing here needs a database round trip: it comes from the model the IDE has
  already introspected, so the schema half is absent for a table reached only
  through a result set.
- pi renders it as a compact summary (table, primary key, columns, foreign keys,
  filter, selected cell) and drops the `File` line, which would read `[No Name]`.

### selection object

```json
"selection": {
  "start": { "line": 40, "column": 1 },
  "end":   { "line": 45, "column": 20 },
  "text": "selected code...",
  "selected_at": 1700000000
}
```

- `start`/`end` are normalized (start ≤ end, line-wise then column-wise), **1-based inclusive**.
- `text` is the selected lines, joined with `\n`.
- `selected_at`: unix seconds the user last made this selection; `null` if unknown
  (e.g. marks restored from a session, no timestamp available — pi then treats
  it as stale).
- Clients should report the exact column range for single-line selections and
  full lines for multi-line ones. Multi-line `start.column`/`end.column` refer
  to the first/last line edges only.
- Neovim visual-block selections are reported as full lines with approximate
  columns (byte columns of the two anchors).

## Injection semantics (pi side)

On every user prompt, while connected to a live editor with a real file:

- First check global `<agent-dir>/ide-context.ignore` and project
  `<pi-cwd>/.pi/ide-context.local.ignore` using gitignore syntax, in that order.
  Project rules can add exclusions or negate global ones. Both are relative to
  Pi's cwd. Outside Pi's cwd, only global rules apply, relative to the editor's
  cwd (filesystem root if the file is outside that too); unresolved relative
  paths use global rules against the reported path.
- An excluded file contributes **no context block**, selection quote, or selection
  metadata entry. The connection and editor-side state writing are unchanged.
  Missing rules files mean no rules; other read failures skip injection with a
  warning. Rules are re-read for each prompt.
- Selection quotes and entries use the snapshot accepted for injection, not a
  later buffer state. Changing buffers between injection and message events
  cannot attach a different selection.

- Always inject the lightweight block: file, language, cursor, buffer size/modified.
  `File` is always an absolute path: a relative `active_buffer.file` (Obsidian) is
  resolved against `cwd` first — handing the model a bare relative path makes it
  resolve against pi's cwd, which is a different iCloud container for an Obsidian
  vault and silently writes a shadow tree on `write`/`edit`.
- Inject the selection text **only when fresh** (see freshness model).
- Stale or absent selection → no selection section at all (matches Claude Code's
  chip disappearing).

## Status text (pi side)

Two Nerd Font glyphs and nothing else: the pill is permanent, so everything that
changes width lives in the bubble instead.

| State | Status |
|---|---|
| Connected, buffer has a file | editor glyph + language glyph (`U+E6AE U+E8CA` for Neovim + TypeScript) |
| Connected, unnamed buffer | editor glyph only |
| Editor dead or disconnected | generic editor glyph (`U+EAE3`) |

The glyph is chosen pi-side from `app` (`nvim`, `vscode`, `obsidian`, `jetbrains`) and
`active_buffer.language`, so the wire format is unchanged: clients keep sending
the same fields. Language ids are what the clients already report — nvim's
`filetype`, VS Code's `languageId`, Obsidian's `markdown` — with the filename
extension as a fallback.

## Which project is this editor in? (pi side)

Matching `cwd` for equality is the strongest signal but not the only one:
`cd ~/Projects && nvim my-project/` leaves nvim's `getcwd()` in the parent even
though the user is working inside `my-project`. So the pi side scores every live
editor and keeps only the best non-empty tier:

| Tier | Condition |
|---|---|
| 0 | `cwd` equals pi's cwd |
| 1 | `cwd` is inside pi's cwd, **or** `active_buffer.file` is inside pi's cwd (absolute paths only — Obsidian's is vault-relative), **or** an `argv` entry resolves to an existing directory equal to / inside pi's cwd |
| none | everything else |

Tiers never mix: when one editor matches exactly, a weaker match is not offered.
`nvim ~/Projects` on a file from another project does *not* match `~/Projects/x`
— an ancestor `cwd` alone is not evidence, so a shared parent directory cannot
claim every project under it.

## Multi-editor notes

- One editor instance per pid; several editors may write at once. Pi auto-connects
  only when exactly one live editor is in the best non-empty tier; otherwise it
  waits for `/ide`.
- **Same-cwd instances** (e.g. `nvim pi-ide-context/` and `nvim pi/` both launched
  from the repo root): `cwd`, git root and explorer root all coincide, so they
  cannot be told apart automatically. The `/ide` picker shows each instance's
  `argv` (launch args — a stable fingerprint) plus how long ago it was active
  (`timestamp`); touch the target editor, then pick the top entry.
- VS Code and Obsidian clients write this same JSON (see `editors/vscode/` and
  `editors/obsidian/`). The pi extension is editor-agnostic — it reads whatever
  editor wrote the file and never asks which one.
- **JetBrains IDEs are the exception to one-file-per-process**: a single IDE
  process hosts every open project window, so all windows share one `pid`. The
  JetBrains client (`editors/jetbrains/`) therefore writes one file for the last
  window the user touched, and sends `product` (`"WebStorm"`) plus
  `argv: ["WebStorm 2026.2.3", "<project>"]` as the instance fingerprint.
  Tracking several windows at once needs a multi-instance
  state file name, which is not in v0.2.

## Changelog

- **v0.2** — `app` field; `argv` field (launch args for same-cwd disambiguation);
  `selection.selected_at` + freshness model; columns normalized to 1-based; no
  more register/recency guessing on the client.
- v0.1 — initial file/cursor/selection state (columns 0-based for cursor,
  1-based for selection — inconsistent, fixed in v0.2).
