@tkreuziger/pi-events

A minimal Pi event bridge: relays Pi runtime/session events over a WebSocket to connected clients in real time.

Packages

Package details

extension

Install @tkreuziger/pi-events from npm and Pi will load the resources declared by the package manifest.

$ pi install npm:@tkreuziger/pi-events
Package
@tkreuziger/pi-events
Version
0.6.0
Published
Aug 27, 2026
Downloads
111/mo · 111/wk
Author
tkreuziger
License
MIT
Types
extension
Size
140.7 KB
Dependencies
1 dependency · 1 peer
Pi manifest JSON
{
  "extensions": [
    "./src/index.ts"
  ]
}

Security note

Pi packages can execute code and influence agent behavior. Review the source before installing third-party packages.

README

pi-events

A minimal Pi event bridge: exposes Pi runtime/session events over a WebSocket so external clients (browser, terminal, automation scripts) can observe and react to agent activity in real time — tool execution, message streaming, and agent/turn lifecycles.

Built as a Pi extension package: it uses Pi's own extension event hooks (pi.on(...)) for capture, so there is no parallel event mechanism. The WebSocket transport is process-lifetime — it survives session replacement (/new, /resume, /fork, reload) and multiple pi processes share one bridge (see Multiple instances).

It is also a regular npm package: dist/ is built from the same TypeScript source, so the bridge API can be used directly from plain Node (see Use the API directly).

Pi event source ──► normalizer ──► leader/peer transport ──► broadcast to clients

Multiple instances

One bridge per host/port, many pi processes share it. The first pi process to start becomes the leader and binds the WebSocket port; every other pi-events instance on the same port connects as a peer and forwards its envelopes to the leader for relay. If the leader exits, peers re-race and one takes over. This makes parallel setups work out of the box — tmux panes, terminal windows, remote-pi mesh agents, and subagent implementations that spawn separate pi processes — each contributing its own event stream.

Every envelope carries the emitting process's identity:

Field Meaning
instanceId Process-scoped id (pi-<pid>-<rand>), stable for the process lifetime

Consumers additionally receive instance-lifecycle envelopes so they can tell processes apart and track joins/leaves:

Event data highlights
roster Snapshot on reader connect: { instances: [{ instanceId, cwd, pid }] } (leader + all peers)
instance_joined { instanceId, cwd, pid } — a peer process just registered
instance_left { instanceId } — a peer process disconnected

One entry per OS process. Instances are keyed by pid: when the same pi process instantiates the extension twice (the CLI wrapper and the session runtime both load it), the duplicate registration is folded into the first — a single roster entry, no spurious second avatar, events relayed under the canonical instanceId.

Install globally (pi install -l) so every pi process — including subagent children spawned elsewhere — joins the same bridge.

Install

Two ways to consume the package, depending on what you need:

1. As a pi extension (streams your agent sessions over WebSocket):

# project-local (add -l for global user settings)
pi install ./path/to/pi-events

# …or from git/npm
pi install git:github.com/tkreuziger/pi-events
pi install npm:@tkreuziger/pi-events

# Or load without installing, for a single run
pi -e ./src/index.ts

For local development in this repo, .pi/extensions/pi-events.ts auto-loads the bridge whenever pi runs with this directory as its cwd (no install needed).

2. As a plain npm package (use the bridge API from your own Node programs):

npm install @tkreuziger/pi-events

Use the API directly (plain Node)

Any Node ≥ 20 program can use the bridge transport and config resolution without pi:

import { WsBridge, resolveConfig } from "@tkreuziger/pi-events";

const bridge = new WsBridge(resolveConfig());
await bridge.start();

bridge.broadcast({
  type: "agent_start",
  ts: Date.now(),
  seq: 0,
  data: {},
});

await bridge.stop();

Also exported: normalizeEvent, readConfig, readFileConfig, resolveConfig with ConfigResolutionOptions, toJSONSafe, and types Envelope, BridgeConfig, FileConfig. All of the config file + environment layering described in Configuration applies here too.

Run

Just start pi normally — the bridge comes up at session start:

pi
[pi-events] WebSocket bridge listening on ws://127.0.0.1:8765

Connect a client

Any WebSocket client works. Out of the box:

npm run live                     # debug loop: prints every event, reconnects forever
node examples/client.mjs        # prints every event, retries until bridge is up
node examples/client.mjs 9000   # custom port

One-liner check:

node -e 'const w=new WebSocket("ws://127.0.0.1:8765");w.onmessage=m=>console.log(m.data)' --input-type=module

(Needs --experimental-websocket only on Node < 22; Node 22+ has a global WebSocket.)

Every message is a stable envelope:

{
  "type": "tool_execution_end",
  "sessionId": "01a02341-…",
  "ts": 1724220000000,
  "seq": 42,
  "data": {
    "toolCallId": "call_00_…",
    "toolName": "bash",
    "isError": false,
    "result": { "content": [{ "type": "text", "text": "…" }] }
  }
}
Field Meaning
type Event type (see below)
sessionId Active Pi session id
instanceId Process identity (pi-<pid>-<rand>) — which pi process emitted this
ts Epoch milliseconds
seq Monotonic sequence number per process
data Normalized payload (sanitized, JSON-safe)

Relayed events

Event data highlights
roster Instance snapshot on reader connect: { instances: [{ instanceId, cwd }] }
instance_joined / instance_left Peer process joins/leaves: { instanceId, cwd? }
agent_start / agent_end messageCount (end)
turn_start / turn_end turnIndex, toolResultCount (end)
message_start / message_update / message_end id, role; assistantMessageEvent with text_delta/toolcall_delta deltas and delta text
tool_execution_start toolCallId, toolName, args
tool_execution_update toolCallId, toolName, partialResult
tool_execution_end toolCallId, toolName, isError, result

Payloads are deep-sanitized before broadcast: functions/symbols/undefined are dropped, cycles collapse, and Map/Set/Date/Error/BigInt/buffers are represented as plain JSON — nothing unserializable ever reaches a client.

Configuration

Configuration is resolved from three sources — last one wins:

  1. Defaults127.0.0.1:8765, all event types, no debug logging.
  2. pi-events.json config files, looked up in two places:
    • Global: ~/.pi/agent/pi-events.json (or $PI_CODING_AGENT_DIR/pi-events.json)
    • Project: <project>/.pi/pi-events.json — only honored for trusted projects (project-local config follows pi's project-trust model; use /trust or --approve to trust a project) Project values override global values.
  3. Environment variables — override both config files when set.

Config file example (pi-events.json):

{
  "host": "0.0.0.0",
  "port": 9000,
  "allow": ["turn_start", "turn_end", "tool_execution_start", "tool_execution_end"],
  "name": "Bob",
  "debug": true
}

allow also accepts a comma-separated string (e.g. "turn_start, turn_end"); an empty array/string means "relay everything". Malformed or missing files are ignored (malformed files log a warning); the bridge still starts with defaults.

name is a purely visual display name for this instance: viewers such as pi-office render it on the avatar where they would otherwise show the cwd folder name. Leave it unset to keep the cwd-based default.

Environment variables

Env var Default Purpose
PI_EVENTS_HOST 127.0.0.1 Bind address (0.0.0.0 to expose the stream on the LAN)
PI_EVENTS_PORT 8765 Bind port
PI_EVENTS_ALLOW (all) Optional comma-separated allowlist of event types, e.g. tool_execution_start,tool_execution_end
PI_EVENTS_NAME (cwd name) Optional display name for this instance — overrides the cwd label in viewers (purely visual)
PI_EVENTS_DEBUG (off) Verbose connection logging (1/true)

Views on other machines connect to the machine's IP (e.g. ws://<ip>:8765); peers on the same machine always dial loopback, so 0.0.0.0 binds never break leader election. As with any unauthenticated stream, only expose it on trusted networks.

Behavior notes

  • Broadcast only — no persistence, replay, auth, or multi-user features. Clients receive events after connection.
  • The bridge port answers plain HTTP requests too: browsers get a short page explaining that this is a WebSocket endpoint (and where the pi-office page lives, http://<host>:5173 by default); non-browser clients keep getting 426 Upgrade Required with a JSON hint. WebSocket upgrades are unaffected. This exists so opening the bridge port in a browser is informative instead of a bare "Upgrade Required".
  • Zero connected clients: Pi event processing is unaffected (no-op broadcast).
  • A client dropping mid-stream is cleaned up; remaining clients keep working.
  • High-frequency updates (e.g. message_update per token) are best-effort; the bridge never blocks or crashes on them.
  • tool_execution_end.isError is forwarded as data, never treated as a bridge failure.
  • The transport is process-lifetime and automatically elects a leader per host/port; peers buffer up to 64 envelopes while reconnecting (best-effort, they forward every session event while connected).

Development

Node ≥ 22 is required for the repo tooling (tests run TypeScript sources directly via Node's built-in type stripping; the examples use Node's global WebSocket). The published dist/ runs on Node ≥ 20.

npm install
npm run typecheck   # tsc --noEmit (requires the pi package types as devDep)
npm test            # standalone transport + normalization tests (no pi needed)
npm run live        # debug loop, prints every event from the running bridge

End-to-end check (real pi + real client):

node examples/client.mjs 18765 &
PI_EVENTS_PORT=18765 pi -p "Use the bash tool to list files in /tmp."

Build & publish

npm run build   # tsc → dist/ (ESM JS + .d.ts + sourcemaps)
npm pack        # runs build via prepack, prints the tarball
npm publish     # publishes to the npm registry

npm pack and npm publish build automatically (prepack hook); prepare bakes the same dist/ into git installs. CI (.github/workflows/ci.yml) verifies typecheck, build, tests, and the packed tarball on Node 22 and 24 for every push/PR on main.

Publishing notes:

  • The package lives under the scope @tkreuziger because the unscoped name pi-events is rejected by the registry as too similar to the existing p-event package.
  • The published package contains both dist/ (plain-Node consumers) and src/ (pi loads the extension source directly via the pi manifest).

Package layout

src/ws-bridge.ts     transport + config: leader/peer server+client (WsBridge, PeerClient),
                     envelope, JSON sanitizer, file/env config resolution
src/normalizer.ts    Pi event → { type, data } normalization
src/index.ts         the Pi extension factory (capture + lifecycle)
dist/                built output (npm consumers): dist/index.js + .d.ts + maps
                     (generated by `npm run build`, gitignored)
examples/client.mjs  sample WebSocket client
test/bridge.test.mjs standalone self-test
test/live-debug.mjs  debug loop client
.pi/                local pi config + auto-discovery shim (dev-only, gitignored)
.github/workflows/   CI: typecheck, build, tests, pack check

The npm/git artifact is also a pi package via the pi manifest in package.json ("pi": { "extensions": ["./src/index.ts"] }), so pi install of the repo or tarball registers the extension directly. As an npm package, exports resolves . to dist/index.js with dist/index.d.ts types.