Files
wehub-resource-sync e30e75b5d4
Changesets / Create Version PR (push) Has been cancelled
Deploy Shadcn Registry / Deploy Production (push) Has been cancelled
Template Metrics / LOC + Bundle Size (push) Has been cancelled
Code Quality / Oxlint + Oxfmt (push) Has been cancelled
Code Quality / Template Sync (push) Has been cancelled
Code Quality / Build Changed Packages (push) Has been cancelled
Code Quality / Test Changed Packages (push) Has been cancelled
Deploy Expo Example / Deploy Production (push) Has been cancelled
Deploy Ink Example / Deploy Production (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-stream, 3.12) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.10) (push) Has been cancelled
Python Tests / pytest (assistant-ui-sync-server-api, 3.12) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:40:13 +08:00

13 KiB

@assistant-ui/react-pi

Pi coding-agent runtime adapter for assistant-ui.

This package lets assistant-ui render and drive Pi-backed threads: streaming assistant/reasoning output, tool calls with live streaming results, mid-run steering and follow-up, per-thread model/thinking controls, the blocking extension UI (Pi's entire human-in-the-loop/approval surface), and a multi-thread thread list.

Package boundary

The package has two entry points:

  • @assistant-ui/react-pibrowser-safe. The runtime hook, the pure event reducer, the message projection, and the HTTP PiClient. This entry never imports @earendil-works/pi-*; it speaks an RPC-isomorphic, JSON-safe contract (PiClient) over an arbitrary transport.
  • @assistant-ui/react-pi/nodenode-only. createPiNodeClient, which drives Pi's AgentSession SDK in-process behind a process-singleton PiThreadSupervisor. Only this entry pulls in Pi's Node packages.

HTTP is one implementation of PiClient, not the contract — the runtime hook never bakes in a transport. You can serve the node client over HTTP/SSE (below), hand it straight to usePiRuntime in a colocated Electron/main-process setup, or write any other transport that satisfies PiClient.

Install

npm install @assistant-ui/react-pi @assistant-ui/react
# the node entry drives the Pi SDK on the server:
npm install @earendil-works/pi-coding-agent

@earendil-works/pi-coding-agent is an optional peer dependency — browser-only consumers (those importing a remote PiClient) never pull it in. The ./node entry needs it.

Quickstart (HTTP/SSE transport)

The two halves of one PiClient contract:

browser                         server (Node)
─────────                       ─────────────
usePiRuntime                    route layer (GET/POST /api/pi/**)
  └ createPiHttpClient  ──HTTP──▶ createPiNodeClient(...)
       (fetch + SSE)               └ PiThreadSupervisor → Pi SDK

1. Server: the node client

createPiNodeClient returns a PiClient. It owns a process-singleton supervisor pinned to globalThis, so it survives Next.js dev HMR — create it in a module-level server file, never per request.

// lib/pi-server.ts  (server-only — imported only from route handlers)
import { createPiNodeClient } from "@assistant-ui/react-pi/node";

export const piClient = createPiNodeClient({
  workspacePath: process.env.PI_WORKSPACE_PATH ?? process.cwd(),
  // agentDir?: override Pi's config dir (default ~/.pi/agent)
  // model?:    a resolved Pi `Model` to seed new sessions (see Model controls)
});

2. Server: the route layer

Expose the PiClient methods over HTTP, and subscribe over SSE. The wire contract createPiHttpClient expects (relative to baseUrl, default /api/pi):

GET    /threads                 → PiThreadMetadata[]
POST   /threads                 → PiThreadSnapshot      (body: create input)
GET    /threads/:id             → PiThreadSnapshot      (read-only snapshot)
PATCH  /threads/:id             → 204                   (body: { title })
POST   /threads/:id/messages    → 204                   (body: { input })
POST   /threads/:id/cancel      → 204
GET    /models                  → PiModelInfo[]
POST   /threads/:id/model       → 204                   (body: { provider, modelId })
POST   /threads/:id/thinking    → 204                   (body: { level })
POST   /threads/:id/archive     → 204
POST   /threads/:id/unarchive   → 204
DELETE /threads/:id             → 204
POST   /threads/:id/host-ui     → 204                   (body: { response })
GET    /threads/:id/events      → SSE of PiClientEvent  (?snapshot=false skips initial snapshot)

The events route must stream piClient.subscribe(threadId, …) and unsubscribe on request abort. A browser disconnect must not abort the run — see Reconnect. See examples/with-pi/app/api/pi/** for a complete Next.js App Router implementation.

3. Browser: the runtime

"use client";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { createPiHttpClient, usePiRuntime } from "@assistant-ui/react-pi";
import { useMemo } from "react";

export function PiRuntimeProvider({ children }: { children: React.ReactNode }) {
  const client = useMemo(() => createPiHttpClient(), []); // baseUrl defaults to /api/pi
  const runtime = usePiRuntime({ client /*, workspacePath, includeArchived */ });
  return <AssistantRuntimeProvider runtime={runtime}>{children}</AssistantRuntimeProvider>;
}

usePiRuntime requires options.client — there is no implicit transport. Drop the provider above any assistant-ui thread UI (Thread, ThreadList, …).

Environment / model resolution

A Pi session needs a model and credentials. Resolution mirrors Pi's own createAgentSession: an explicit PI_PROVIDER + PI_MODEL_ID wins, otherwise Pi falls back to its configured default (settings.json's defaultProvider / defaultModel). A user who is authenticated with pi and has a default model picked needs no env at all.

Variable Purpose
PI_PROVIDER Override provider for new sessions (e.g. anthropic).
PI_MODEL_ID Override model id for new sessions.
PI_WORKSPACE_PATH Working directory the agent reads/writes/runs shell commands in. Point it at a scratch dir.
PI_CODING_AGENT_DIR Pi's agent config dir (~/.pi/agent by default).

The ./node entry takes a resolved Pi Model via createPiNodeClient({ model }) to seed new sessions. Use Pi's own ModelRegistry / SettingsManager / AuthStorage on the server to resolve it. See examples/with-pi/lib/pi-server.ts.

Model & thinking controls

Per-thread, surfaced through usePiRuntimeExtras():

import { usePiRuntimeExtras } from "@assistant-ui/react-pi";

const { readiness, contextUsage, setModel, setThinkingLevel } = usePiRuntimeExtras();
// readiness: "ready" | "missing-model" | "missing-credentials" | "unavailable-model"
// await setModel({ provider, modelId });
// await setThinkingLevel("off" | "minimal" | "low" | "medium" | "high" | "xhigh");

getAvailableModels() on the client returns the catalog (auth-configured models first, falling back to the full built-in list). setThinkingLevel is clamped by Pi to the model's supported levels; the UI reflects the effective level from the next snapshot/event. contextUsage ({ tokens, contextWindow, percent }) answers "am I about to auto-compact?" and is available even for cold threads.

readiness gates sending — block the composer and show the readiness.message (a concrete next step, e.g. "Authenticate with pi, then restart the server.") when it is not "ready".

Composer run semantics

Pi's defining interaction is mid-run steering, and a plain prompt() while streaming throws. The runtime exposes Pi's native queue to assistant-ui (capabilities.queue), so the standard composer keeps accepting input during a run and derives the right behavior:

State / action Behavior
idle + submit prompt()
running + Enter follow-up (streamingBehavior: "followUp") — queued
running + Cmd/Ctrl+Shift+Enter steer (streamingBehavior: "steer")

To force steer from your own composer, send with steer: true or set message.runConfig.custom.streamingBehavior = "steer". While running, an omitted behavior defaults to "followUp".

Queued messages live in Pi's queue, not the transcript — Pi appends the user message only when the queue flushes. The runtime mirrors them as queue items (s.composer.queue, renderable with ComposerPrimitive.Queue) and on the thread metadata's queuedMessages. Pi supports clearing the whole queue (clearQueue() on the client / usePiRuntimeExtras().clearQueue(), which resolves with the cleared text so it can be restored into the composer), but no per-item remove/promote (see Known limitations).

Host-UI requests (the approval surface)

Pi has no built-in permission system — the only human-in-the-loop mechanism is extensions/tools calling ctx.ui.confirm / select / input / editor. This package implements and binds the ExtensionUIContext on the server and routes the four blocking dialogs to the UI, split by causality:

  • Tool-associated (a dialog raised while exactly one tool is executing) → rendered as a native ToolCallMessagePart.approval (confirm) or .interrupt (select/input/editor), wired through the runtime's onRespondToToolApproval / onResumeToolCall.
  • Free-standing (extension commands, or any request raised while multiple tools are in flight) → a side channel:
import { usePiHostUiRequests } from "@assistant-ui/react-pi";

const { requests, respond } = usePiHostUiRequests();
// confirm:                 respond({ requestId, confirmed: boolean })
// select/input/editor:     respond({ requestId, value: string })
//                      or  respond({ requestId, dismissed: true })

Pi's UI requests carry no toolCallId, so causality is only inferred when a single tool is executing; otherwise requests stay on the side channel. Pending requests are tracked on the server record (not the SSE connection), so a reconnecting client still sees them. Unsupported ExtensionUIContext methods degrade rather than crash.

Reconnect semantics

  • The supervisor keeps the runtime alive across browser disconnects — only an explicit cancelRun or process exit stops a run. A dropped SSE never aborts.
  • Every (re)connect is snapshot-first: the server re-sends an authoritative snapshot event, then live events apply on top. There is no event replay in the MVP; the snapshot is authoritative.
  • Cold/historical threads load via a read-only session-file snapshot — opening a thread to read it does not spin up a live AgentSession. A live runtime is created only when you send, cancel, change model/thinking, answer host UI, or explicitly subscribe to live events.

Selector hooks

The runtime keeps high-frequency transcript state separate from low-frequency metadata, so metadata controls don't rerender on every token:

  • usePiRuntimeExtras()status, readiness, contextUsage, queue, compaction, retry, lastError, host-UI requests, and the cancel / refresh / clearQueue / setModel / setThinkingLevel / respondTo* / resumeToolCall actions.
  • usePiSession() — the current PiThreadMetadata (or null).
  • usePiThreadState(selector?) — the raw reducer state, optionally selected.
  • usePiHostUiRequests() — free-standing host-UI requests + a responder.

Known limitations (MVP)

  • No RPC-subprocess transport. The SDK-in-process node client assumes one long-lived Node process; it does not survive serverless/edge. The contract is RPC-isomorphic so a subprocess/remote transport can drop in later.
  • No durable event replay / backpressure / version negotiation. Recovery is snapshot-first only.
  • Idle threads are not auto-followed on view. Opening a thread is a cheap read-only snapshot; the runtime auto-subscribes to live events only when the loaded snapshot reports the thread as running (or when you send). A run that starts elsewhere after you opened an idle view shows up on the next refresh, not live. (controller.connect() exists for explicit always-live subscription.)
  • The node host's catalog is process-local and in-memory. It caches SessionManager.list() and tracks archive state for the running process; nothing is persisted across restarts (no last-selected workspace, drafts, labels, or search index). Workspace is just a workspacePath string on the client — the UI for choosing one (text field, directory picker, …) is the consuming app's concern, not this package's.
  • No fork/clone/navigate or per-item queue editing. The reducer/projection preserve the underlying data — tree linkage (parentSessionPath), queued messages (queuedMessages), and compaction state — so a consumer can build those surfaces, but the client exposes no methods to fork a session, navigate the tree, or reorder the queue. Queue items mirror Pi's server-side queue: enqueueing works (that's how mid-run follow-up/steer is sent) and clearing all works (clearQueue, mirroring Pi's only queue mutation), but per-item steer/remove affordances are no-ops — Pi has no such API.
  • Attachments are image-passthrough only. Non-image input parts aren't converted into Pi user content.
  • Model/thinking are the only runtime-config actions. There are no provider/auth/credential or default-model/settings methods on the client — manage those with Pi's own SDK on the server. Skills/extensions/slash-commands aren't surfaced.