# pi-collision-guard

Fail-closed local path leases that stop participating PI processes from running
built-in `edit` or `write` against the same canonical file path at the same
time.

This is a narrow coordination guard, not a promise to prevent every file
conflict. It adds no model calls and makes no network requests.

- [中文快速上手](docs/quickstart-zh.md)
- [Java 开发者架构说明](docs/architecture-zh.md)

## Scope

The guard coordinates:

- PI processes on the **same machine**
- running as the **same OS user**
- that have this extension loaded
- before PI's built-in `edit` and `write` tools execute

Paths are resolved from the PI session working directory. Existing targets and
their existing parent directories are resolved through `realpath`, so common
relative-path and symlink aliases converge on one canonical path.

It does **not** cover:

- agent-issued `bash` commands or user-issued shell commands
- custom tools, or custom tools that override built-in `edit`/`write`
- Git operations
- IDEs, editors, formatters, build tools, or any external process
- PI processes that did not load this extension
- different OS users or different machines
- NFS or other network filesystems
- hardlink aliases
- time-of-check/time-of-use (TOCTOU) changes after preflight
- a later extension `tool_call` handler that rewrites `event.input.path`

PI identifies tool events by tool name, so this extension cannot prove that a
tool named `edit` or `write` is still the built-in implementation. If another
extension mutates the path, load collision guard after that handler where
possible.

## Install

Requirements:

- Node.js 22 or later
- PI with package support

PI extensions execute with your user account's full filesystem permissions.
Review the package before installing it.

Install for the current OS user:

```bash
pi install npm:pi-collision-guard
```

For a project-local PI setting instead:

```bash
pi install npm:pi-collision-guard -l
```

## Try once without installing

Load the npm package only for the current PI run:

```bash
pi -e npm:pi-collision-guard
```

## What happens during a run

1. On the first built-in `edit` or `write` for a path, the extension
   canonicalizes the path and tries to create a per-path lease.
2. If another participating PI runtime has an active lease, the tool call is
   blocked before the built-in tool executes.
3. The runtime refreshes its held leases every **5 seconds**.
4. Normally, a claim lives from that first protected tool call until PI emits
   `agent_settled`. All claims owned by that runtime are then released.
5. `session_shutdown` stops the heartbeat and performs an idempotent fallback
   release.

`agent_settled` means PI has no automatic retry, compaction retry, or queued
continuation left. A later agent run can acquire new claims again.

### Stale and damaged state

The default stale threshold is **30 seconds**:

- A lease whose PID is known to be alive remains active, even if its heartbeat
  is old.
- A lease whose PID is known to be dead is reclaimable on the next acquire.
- If process state cannot be determined, the lease is reclaimable only when
  its heartbeat is more than 30 seconds old.

A malformed or unreadable claim fails closed for a **30-second corrupt grace
period**, measured from the claim file modification time. After that grace
period, the next acquire may atomically replace it.

Recovery is demand-driven: there is no background janitor deleting stale
claims.

## Conflict example

PI A and PI B run as the same user and both load this extension:

```text
PI A: built-in edit  src/main/java/example/App.java
      -> lease acquired; edit proceeds

PI B: built-in write src/main/java/example/App.java
      -> blocked: path is held by PI A

PI A: agent_settled
      -> PI A's claims are released

PI B: retry write
      -> lease acquired; write proceeds
```

Different canonical paths remain independent.

## Commands

### `/collision status`

Lists visible claims, including:

- `[self]` or `[other]`
- a path relative to the current working directory when possible
- a shortened session ID
- claim age
- active/stale status
- damaged claim IDs and whether their grace period has expired

`/collision` with no arguments is also treated as `status`.

### `/collision force-release <path>`

Deletes the claim for one canonical path.

This is dangerous: another PI process may still be writing the file. The
command is allowed only when PI reports that UI is available and the user
accepts the confirmation prompt. It is refused in non-UI contexts, and the
extension does not expose force release as an agent-callable tool.

Use it only after independently confirming that no active writer owns the
path.

## Failure behavior

Protected `edit` and `write` calls fail closed. If canonicalization, claim
storage, operation locking, or lease acquisition fails, the tool call is
blocked rather than allowed through. A fresh damaged claim also blocks
takeover.

Heartbeat, release, status, and force-release errors are reported in PI's UI
when UI is available. A failed release can leave a claim for stale recovery.

## State files and permissions

Default state root:

```text
~/.pi/agent/collision-guard/
├── claims/
└── locks/
```

- New state directories and operation-lock directories use mode `0700`.
- New claim files and lock owner-token files use mode `0600`.
- Claim filenames are `SHA-256(canonicalPath).json`; the original canonical
  path is stored inside the JSON record.
- Records also contain the PI session ID, a random runtime owner token, PID,
  claim time, heartbeat time, and schema version.

Existing directories are not re-permissioned. Filesystem and OS semantics
still apply.

## Recovery procedure

1. Run `/collision status`.
2. Let the owning agent reach `agent_settled`, or cleanly exit the owning PI
   process.
3. Retry the protected operation. A dead owner is reclaimed on acquire; an
   unknown owner needs a heartbeat older than 30 seconds.
4. For a damaged claim, wait until the 30-second corrupt grace period has
   elapsed, then retry.
5. Only if you have verified that no writer is active, use
   `/collision force-release <path>` and approve the UI prompt.

## Uninstall and state cleanup

First let participating agent runs settle and close every PI process still
using the extension. Then uninstall:

```bash
pi remove npm:pi-collision-guard
```

PI package removal does not delete collision-guard state. After confirming no
loaded PI process remains, remove it explicitly:

```bash
rm -rf ~/.pi/agent/collision-guard
```

Deleting that directory while a participating PI process is active disables
coordination until state is recreated and can allow conflicting writes.

## Performance and cost

Each protected tool call performs path canonicalization plus local filesystem
locking and claim I/O. Heartbeat work runs every 5 seconds only while the
runtime holds paths; it scans visible claim files and rewrites this runtime's
claims. Status and release operations also scan the claim directory.

The extension itself uses:

- no model tokens
- no model requests
- no network requests
- no remote service

Cost therefore scales with the number of local claim files, not repository
size or prompt size.

## Security and privacy

Coordination data stays in the current user's local PI state directory. The
extension sends no telemetry and does not transmit paths, session IDs, or
claim data. Canonical paths are hidden from claim filenames but remain
plaintext inside user-readable claim JSON.

This is cooperative coordination, not a security boundary. A process with the
same user permissions can modify or delete the state, and unprotected tools
can write the target file directly.

## Local development

From a checkout:

```bash
npm install --ignore-scripts
npm run typecheck
npm test
npm run test:local
npm run build
npm exec -- pi -e .
```

`npm run test:local` needs no model or network access. It runs the automated
suite, then starts separate Node processes against a real temporary claim
store to verify acquire, conflict, release, and retry behavior. Loading PI and
having an agent invoke built-in `edit` / `write` is an optional final
end-to-end check that does require a configured model.

To run that real PI/model check:

```bash
PI_E2E_MODEL=provider/model npm run test:pi-e2e
```

The scenario starts three real PI processes. PI A edits and holds a path, PI B
must receive a collision error for that path, and PI C must edit it after PI A
settles and releases the claim.

The final command loads the built `dist/extensions/collision-guard.js` entry
declared by the package's `pi.extensions` manifest. Exit PI normally to
exercise `session_shutdown`.

Useful validation:

```bash
npm run format:check
npm pack --dry-run
```

The test suite contains 22 tests covering lease policy, path
canonicalization, concurrent acquisition, stale/corrupt recovery, PI event
wiring, commands, heartbeat, release, and fail-closed behavior.

## Architecture

The public core is split into `domain`, `application`, and `infrastructure`;
the PI integration is a thin adapter under `src/extension`, loaded through
`extensions/collision-guard.ts`. See
[docs/architecture-zh.md](docs/architecture-zh.md) for a Java-oriented mapping
and a tool-call-to-release trace.

## License

MIT
