# pi-subagents-bridge

[![npm version](https://img.shields.io/npm/v/%40alexeiled%2Fpi-subagents-bridge?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@alexeiled/pi-subagents-bridge)
[![CI](https://img.shields.io/github/actions/workflow/status/alexei-led/pi-subagents-bridge/ci.yml?branch=main&style=flat-square&label=ci)](https://github.com/alexei-led/pi-subagents-bridge/actions/workflows/ci.yml?query=branch%3Amain)
[![node](https://img.shields.io/badge/node-%3E%3D22.19.0-5fa04e?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org/)
[![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](./LICENSE)

`pi-subagents-bridge` is a protocol adapter between two Pi extensions:

- [`@tintinweb/pi-tasks`](https://www.npmjs.com/package/@tintinweb/pi-tasks)
- [`pi-subagents`](https://www.npmjs.com/package/pi-subagents)

It makes `TaskExecute` work with `pi-subagents` by translating the `pi-tasks` subagent RPC into the RPC and completion events that `pi-subagents` exposes. It also exposes a separate, generic plan-exec RPC for direct execution clients.

## What it does

`@tintinweb/pi-tasks` expects a v2 subagent protocol.
`pi-subagents` exposes a v1 RPC plus async completion events.
This bridge sits between them and handles the mismatch.

Specifically, it:

- answers the `pi-tasks` v2 ping handshake
- forwards `spawn` and `stop` requests to `pi-subagents`
- translates `pi-subagents` completion events back into `pi-tasks` task updates
- falls back to polling `pi-subagents` run `status` if an async completion event is missed
- keeps the generic plan-exec RPC separate from all `pi-tasks` channels

### Request and completion flow

```mermaid
sequenceDiagram
  autonumber
  participant Tasks as @tintinweb/pi-tasks
  participant Bridge as pi-subagents-bridge
  participant Subs as pi-subagents

  Tasks->>Bridge: ping (v2)
  Bridge-->>Tasks: version 2

  Tasks->>Bridge: spawn(type, prompt, options)
  Bridge->>Subs: spawn(script, async: true)
  Subs-->>Bridge: runId
  Bridge-->>Tasks: id = runId

  Subs-->>Bridge: subagent:async-complete
  Bridge-->>Tasks: subagents:completed / subagents:failed

  alt async completion event is missed
    Bridge->>Subs: status(runId)
    Subs-->>Bridge: state + result path
    Bridge-->>Tasks: subagents:completed / subagents:failed
  end
```

## Install

Install the two extensions being bridged, then install the bridge:

```bash
pi install npm:@tintinweb/pi-tasks
pi install npm:pi-subagents
pi install npm:@alexeiled/pi-subagents-bridge
```

Requirements:

- Node `>= 22.19.0`
- Pi `^1.0.2` with extension loading enabled; tested with Pi `1.0.4`
- `pi-subagents >= 0.76.0 < 0.77.0`; tested with `0.76.1` for RPC `script` input and native terminal proofs

The npm peer requirement does not verify which Pi extension is active. If a
closed workflow status has no native proof field, the bridge reports an error
to check the active runtime and durable async status/proof artifacts instead of
claiming that the worker exited.

The bridge uses Pi's public extension event API. Future Pi releases still need
validation if that API or the upstream subagent protocol changes.

The operation journal migrates schemas 1–6 to schema 7, preserving identity,
bindings, cancellation and native fields. Migration never creates rejection or
stop-delivery evidence for old rows. Bridge 0.5.4 and earlier cannot reopen
schema 7. Stop journal owners and back up the journal before upgrading; keep a
compatible Bridge installed afterward. Unknown versions are rejected before database changes.

## Usage

Create tasks with `TaskCreate`, then execute them with `TaskExecute`.

```text
TaskCreate(subject="Write a short plan", agentType="general-purpose", ...)
TaskExecute(task_ids=["1"])
```

### Agent type mapping

The bridge preserves task agent names except for the compatibility aliases that `pi-tasks` examples commonly use.

| `TaskCreate(..., agentType=...)` | `pi-subagents` agent     |
| -------------------------------- | ------------------------ |
| `general-purpose`                | `delegate`               |
| `Explore`                        | `scout`                  |
| `explore`                        | `scout`                  |
| anything else                    | passed through unchanged |

Use an execution-capable agent for tasks that need shell commands, builds, or tests.
A read-only agent can still be useful for read-only tasks such as review or metadata inspection.

## Bridge behavior

### Request flow

| `pi-tasks` input      | Bridge behavior                                   |
| --------------------- | ------------------------------------------------- |
| `subagents:rpc:ping`  | replies locally with protocol version `2`         |
| `subagents:rpc:spawn` | sends a `pi-subagents` v1 `spawn` request         |
| `subagents:rpc:stop`  | sends a best-effort `pi-subagents` `stop` request |

Spawned runs are always forwarded as:

- one-child workflow execution through RPC `script`
- `async: true`
- `context: "fresh"`
- `control: { enabled: false }` on the outer workflow and its child

The bridge does not send the removed public `workflowScript` or `clarify` fields.
The plan-exec capability name `workflowScriptSpawn` is unchanged; it is not an RPC input field.

The bridge returns the spawned run ID back to `pi-tasks` and tracks that run as bridge-owned state. It runs at most **two** bridge-owned tasks at once. A task without an explicit `maxTurns` receives a **12-turn** budget.

### Completion flow

For runs the bridge spawned itself, it converts `pi-subagents` outcomes into `pi-tasks` events:

- success → `subagents:completed`
- failure → `subagents:failed`
- stopped or paused run → `subagents:failed` with `status: "stopped"`

If `subagent:async-complete` does not arrive, the bridge polls `pi-subagents` `status`, reads the result file path from that status output, and emits the same completion event that `pi-tasks` expects. If a terminal status arrives before its result file is readable, the bridge retries for up to five seconds instead of silently dropping the result.

## Generic plan-exec RPC

This protocol is independent of `pi-tasks`. Version 1 remains available on `plan-exec:bridge:v1:request` with replies on `plan-exec:bridge:v1:reply:<requestId>`.

Version 2 uses `plan-exec:bridge:v2:request` and `plan-exec:bridge:v2:reply:<requestId>`. It supports `ping`, `spawn`, `operation`, `status`, `result`, `stop`, `adopt`, `cancelOperation`, and optional `diagnoseOperation` with durable launch identity and native process-terminal proof.

- `ping` verifies the live `pi-subagents` RPC before advertising `workflowScriptSpawn`, `durableOperationLookup`, and `processTerminalProof` capabilities.
- `spawn` requires `operationId`, `cwd` when needed, `params.agent`, `params.task`, and an owner `{ kind: "pi-plan-exec", runId, key, requestDigest }`. The digest is SHA-256 over canonical `{ cwd, params }`. The bridge rejects mismatches before dispatch.
- The durable journal, including the exact native RPC request UUID, is written before the native spawn is emitted. A bound operation survives a full Pi restart. Lost replies can be reconciled by the released native runtime's exact `rpc-spawn-<request UUID>` tool-call lookup; the echoed native identity must match before binding. The bridge never redispatches the same operation.
- `operation` never starts work. Version 2 returns `operationId`, `requestDigest`, and `absent`, `pending`, `found`, `not_started`, or `unknown` binding state. Lookup is observational and never retries dispatch.
- `status` and `adopt` include a validated native `processTerminal` value when `pi-subagents` returns one. Only an `observed` proof with the matching run ID proves process termination.
- `result` uses the native status RPC because `pi-subagents` has no separate result RPC. `stop` delegates to the native stop RPC.
- `adopt` is observational. It does not silently transfer session ownership. Status and stop use native run IDs; the native runtime still enforces its session restrictions.

Explicit execution lifetimes require v2. Set `params.executionLifetime` to
`{ mode: "unbounded" }` or `{ mode: "bounded", timeoutMs: 1800000 }`.
Do not combine this field with legacy `timeout` or `timeoutMs`. Both paths wrap
the agent and task in a one-child async workflow sent through RPC `script`.
A bounded lifetime becomes `timeoutMs`; an unbounded request forwards no timeout.
The lifetime is included in the caller digest and echoed as
`effectiveExecutionLifetime`; this is bridge intent, not native attestation.
Explicit lifetimes do not accept caller workflow scripts.

`ping` advertises lifetime support when native async spawn and stop are available.
Ownership is bridge-supervised with best-effort escaped-descendant handling,
not kernel containment. The bridge journal owns operation identity and replay
protection; the released upstream RPC has no operation-ID lookup. A bound run
can be recovered from the journal. New unbound entries retain the native RPC UUID,
so lookup can reattach the same child after a lost reply or restart **while the
native tool-call lookup mapping is retained**. Bridge also persists exact
`toolCallId`/run bindings from native completion events while loaded; these events
are not themselves terminal proof. Native terminal cleanup and result delivery
can remove the lookup mapping. If Bridge never received or recovered a binding
before its removal, lookup stays `unknown` even when other native artifacts remain.
An already-bound journal row does not depend on that alias. Failed, ambiguous or
mismatched lookup never proves absence. Historical unbound entries without a
native request UUID also remain `unknown`.

`cancelOperation` records cancellation intent before native lookup or stop.
The durable local fence does not require a native ping. It reports `neverStarted: true`
when its atomic journal transaction creates a new fence before any dispatch
record exists, or the existing operation has durable correlated rejection evidence.
Other native-correlated unbound operations return `unknown` and
`neverStarted: false`, including repeat cancellation of an old fence.
Uncorrelated legacy rows without rejection evidence reject cancellation instead.
Neither response establishes no-start.
It is not safe to infer non-start from a missing run ID or a timeout.
With `cancellationDelivery: true` advertised by v2 ping, cancellation replies
distinguish `cancellationDelivery: "pending"` (intent persisted, delivery unconfirmed)
from `"delivered"` (an exact whole-run native stop receipt persisted).
Exact never-started fences omit this field. Controllers must retry pending
delivery with the same operation, digest and owner; generic success is not a
delivered stop. Retry reconciles late native bindings, and failed stops remain
retryable. Concurrent calls coalesce within one registration. Persisted delivery
receipts prevent another stop on replay or restart; neither receipts nor
`nativeState` prove retirement. A lost receipt or failed persistence can cause
redelivery to the same immutable run. Bridge does not scan all journals or run
a background cancellation loop; the caller owns retry cadence.
Controllers that explicitly quarantine an execution target can use the identity-bound
`cancellationRequested: true` acknowledgement as a no-redispatch fence, never as
old-worker termination. If native stop fails after the fence commits, the reply
keeps that acknowledgement with `state: "unknown"`, `neverStarted: false`, and
an error diagnostic with `upstreamCode` when available. An unknown old worker
may still run in its old target.

In pi-subagents 0.76.1, RPC stop still rejects paused and queued runs with
`invalid_state`, despite the model-facing paused-stop fix. Cross-host workflow
stop also needs the original live controller and active session. Bridge reports
these refusals as pending delivery; it does not call a different protocol,
resume the worker, or manufacture terminal proof. Preserve ownership until the
owning controller obtains native terminal evidence.

Reconcile native `processTerminalProof` (also exposed as `processTerminal`)
before starting replacement work. A workflow uses native `workflowTerminalProof`:
dispatch must be closed and each child observed or explicitly not-started.
Pending, unknown, malformed, or absent proofs do not establish completion.
The workflow's hosting Pi process can remain alive.

### Advisory activity

V2 ping advertises `advisoryObservation: { version: 1 }`. Status, result and
adopt may return the following separate, display-only field:

```typescript
advisoryObservation: {
  version: 1;
  source: 'pi-subagents.async-status-snapshot';
  runId: string;
  generatedAt: number;
  activity?: {
    state?: string;
    currentTool?: string;
    lastActivityAt?: number;
    currentToolStartedAt?: number;
    turnCount?: number;
    toolCount?: number;
  };
  omitted: { runs: number; children: number; byteLimitExceeded: boolean };
}
```

Bridge accepts only one exact root-run match in native snapshot v1 (at most
20 roots). Native RPC filters its snapshot to the active session; the snapshot
itself has no session ID. Its generation time must fall within the status
request/receipt window and be no more than 30 seconds old. Counters and timestamps
must be nonnegative safe integers, activity times cannot exceed generation time,
and text is limited to 160 characters without control/format characters.
Missing, stale, foreign, ambiguous or malformed observations stay absent.
Omission flags are preserved; missing data is not zero activity.

Workflow step IDs may be display keys rather than child run IDs, so Bridge does
not infer child activity from their position, name or key. No usage, cost,
failure or expiry fields are synthesized. Keep this advisory field separate
from verified progress, ownership, retirement and execution-lifetime decisions.

### Diagnostic guidance

When native `diagnosticGuidance` advertises durable, idempotent `follow_up`
guidance for confirmed tool failures, `diagnoseOperation` accepts
`{ operationId, owner, params: { diagnosticId, toolCallId, message } }`.
The native runtime checks the referenced failed tool and queues guidance into the
existing live session. Reuse the same diagnostic ID and payload after an uncertain
reply: durable native receipts prevent a second enqueue. Replies bind the caller
operation, request digest, diagnostic ID, and tool-call ID, with
`guidanceOnly: true` and `queued`, `pending`, `cancelled`, or `rejected` state.
An enqueue receipt does not confirm a repair. Cancellation fences late guidance;
this method does not start or revive a worker.

The Vitest suite checks the released RPC validator, not only capability mocks.
An isolated Pi integration test completes both Bridge paths against real
pi-subagents workflows and child runtimes, using only a local fixture HTTP model.
It verifies lookup, replay identity, native workflow proof, and task completion.

### Upgrade and unresolved launches

Bridge 0.5.3 advertises `prelaunchRejection: { version: 1 }`. A newly observed,
correlated `spawn` reply with `invalid_params` is persisted before lookup reports
`state: "not_started"`, `neverStarted: true` and `replaySafe: false`.
The `launchRejection` object contains `version: 1`, `source: "subagents-rpc"`,
the native `requestId`, `method: "spawn"`, `code: "invalid_params"`, `message`,
`operationId`, `requestDigest`, and `ownerRunId`. The released upstream
validator emits this code before invoking the executor. Post-dispatch failures,
malformed replies, missing method, and lost replies do not prove no-start.

A compatible controller must verify this evidence, call `cancelOperation`,
and persist the returned cancellation fence before allocating a new operation.
Replaying the rejected identity never dispatches, including after restart.
Spawn failures retain `error.code: "upstream_error"` and add `upstreamCode`
when a structured upstream code is available.

Install pi-subagents 0.76.x before updating Bridge, then restart Pi after updating
already-loaded packages. A `/reload` is enough for local Bridge source edits, not
for mixing newly installed upstream packages with modules already loaded in Pi.
Back up the journal while its owners are stopped before the schema upgrade;
do not downgrade over schema 7. The fix applies to newly captured rejection
evidence only. **An old unresolved launch is not repaired by this update.**
An old error string, missing run ID, dead controller PID, clean worktree, or
elapsed time does not prove that dispatch never happened.

For an old `dispatching`/`unknown` row without proof, preserve the run, worktree,
and journal and use the owning controller's read-only status. Repeated
`/exec resume` cannot establish absence. Recovery needs an authoritative
operation-bound rejection or a verified binding to the original child and its
terminal proof. The released upstream has no operation-ID lookup, and Bridge
has no historical evidence importer or force-clear API. If that evidence is
unavailable, the launch remains unresolved. Do not delete/reset the journal,
cancel to manufacture proof, recreate the run, or launch a replacement worker.

The journal defaults to `~/.pi/pi-subagents-bridge/plan-exec-operations.sqlite`. SQLite transactions provide crash recovery and cross-process serialization without a stale application lock. Version 1 clients retain their existing response shape and also benefit from durable bound-operation lookup. Operation identity rows are retained as idempotency records; automatic pruning could make an old operation ID dispatch again. Remove the database only after all referenced plan runs are permanently retired and duplicate-launch protection is no longer needed. Existing v1 accepted-run rows migrate fail-closed with no session identity; they require explicit manual recovery rather than unsafe cross-session delivery.

Failures use `{ success: false, error: { code, message } }`. `operation_capacity` means the in-process bridge has 128 unresolved active operation IDs and will not evict one to accept another spawn.

## TaskExecute-specific safeguards

`TaskExecute` is queue-style orchestration, not direct subagent supervision.
Because of that, the bridge also applies two execution defaults to bridge-spawned runs:

- disables `pi-subagents` acceptance gating
- disables `pi-subagents` live control nudges

This avoids false pauses on missing acceptance reports and avoids misleading background `needs attention` notices for normal task runs. Repeated copies of one live request are coalesced only after their session and request digest match. The request ID is also journaled before native dispatch, so replay after the in-memory reply cache expires returns the existing run or fails closed as unknown instead of dispatching again. Transient persistence failures for known accepted runs and launch bindings are retried while the bridge process remains active.

Accepted run IDs are tied to the originating Pi session ID and a leased process owner. A foreign Pi session cannot claim or delete them. The same resumed session can reclaim them after the owner exits. An expired heartbeat alone cannot prove exit; a reused or still-live PID leaves ownership unresolved. The owner renews its fence before emitting completion, and failed reconciliation is retried periodically. If another session wins ownership, the bridge stops polling and emits `subagents:warning` with code `accepted_run_ownership_lost` instead of silently dropping the run.

The legacy `pi-tasks` spawn request does not contain task ID, list ID, or attempt generation. Therefore the bridge cannot recover a task binding after a crash that occurs after native dispatch but before the run ID is received. The durable request becomes unknown and is not launched again automatically. The bridge does not use prompt matching.

If the native run ID is known but local accepted-run persistence fails, the bridge acknowledges that known run instead of returning an error that could trigger a duplicate launch. It keeps completion ownership for the current process and logs the durability loss; a subsequent process restart then requires manual completion recovery.

## Scope and limits

This package is intentionally narrow.

It does:

- bridge `TaskExecute` task launches to `pi-subagents`
- track only runs spawned through this bridge
- ignore unrelated `pi-subagents` runs

It does not:

- replace `pi-tasks` task orchestration
- act as a generic adapter for `pi-subagents` methods beyond the documented plan-exec protocol
- support loading `@tintinweb/pi-subagents` alongside this bridge

Do not load `@tintinweb/pi-subagents` at the same time as this package.

## More detail

- [`docs/design.md`](./docs/design.md) — bridge behavior and maintenance rules
- [`docs/protocol-research.md`](./docs/protocol-research.md) — upstream protocol notes
- [`DEVELOPMENT.md`](./DEVELOPMENT.md) — local validation and release workflow
- [`CHANGELOG.md`](./CHANGELOG.md) — release history and RPC compatibility changes
