@tkreuziger/pi-events
A minimal Pi event bridge: relays Pi runtime/session events over a WebSocket to connected clients in real time.
Package details
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:
- Defaults —
127.0.0.1:8765, all event types, no debug logging. pi-events.jsonconfig 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/trustor--approveto trust a project) Project values override global values.
- Global:
- 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>:5173by default); non-browser clients keep getting426 Upgrade Requiredwith 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_updateper token) are best-effort; the bridge never blocks or crashes on them. tool_execution_end.isErroris 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
@tkreuzigerbecause the unscoped namepi-eventsis rejected by the registry as too similar to the existingp-eventpackage. - The published package contains both
dist/(plain-Node consumers) andsrc/(pi loads the extension source directly via thepimanifest).
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.