- TypeScript 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .dev | ||
| experiments | ||
| src | ||
| test | ||
| .env.example | ||
| .gitignore | ||
| AGENTS.md | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| tsconfig.json | ||
| vite.config.ts | ||
Pi Posthorn
Posthorn is a local bridge between live Pi conversations and Discord. A foreground broker owns one stable Discord bot, the Discord Gateway connection, one configured guild text channel, topic authorization, and restart-persistent local delivery state. Thin Pi extensions connect through a private Unix socket.
Development status: this is a technical-foundation prototype, not an approved interaction design. The broker, protocol, authorization, routing, persistence, and Discord transport are intended as reusable infrastructure. The current tool shape, topic behavior, automatic-return rules, wake marker, Discord presentation, and TUI behavior are scaffolding for exercising that infrastructure and are expected to be substantially redesigned. See
experiments/03-technical-foundation-and-discord-message-surface.md.
Each Pi session receives exactly one model tool. It can start a meaningful topic, deliberately continue a broker-known topic, upload bounded inline images, reply through a broker-issued message reference, attach bounded interaction controls, or update an expected reaction:
send_message({ topic: "Concise topic", message: "..." })
send_message({ topic_ref: "pt_opaque-reference", message: "...", reply_to: "pm_opaque-reference" })
send_message({
topic_ref: "pt_opaque-reference",
message: "Screenshot",
images: [{ data: "<base64>", media_type: "image/png", filename: "status.png" }]
})
send_message({
topic_ref: "pt_opaque-reference",
message: "Choose a release lane",
controls: {
type: "buttons",
question: "Which release lane?",
options: [{ label: "Stable", value: "stable" }, { label: "Canary", value: "canary" }],
other: { style: "short" }
},
expected_reactions: ["✅"]
})
send_message({
topic_ref: "pt_opaque-reference",
reaction: { message_ref: "pm_opaque-reference", emoji: "✅", action: "add" }
})
A new-topic call posts a starter in the configured channel and creates a public thread named for the supplied topic. When that call includes controls or expected reactions, the interactive payload is posted as the first thread reply so its authority is natively thread-bound. A reply call accepts only the opaque reference issued by Posthorn; raw Discord channel, thread, message, and component custom IDs are not tool inputs. Every successful send returns a broker-issued message_ref. The broker persists bounded temporary mappings for Pi-authored messages and bounded durable mappings for authorized Discord-authored messages so native replies can target only a known message in the same topic and Pi session. The tool never accepts raw Discord message IDs. Reaction operations remain restricted to messages that declared the expected reaction.
Controls support up to five buttons (four when Other… is present) or one 1–25-option string select with explicit single/multi selection bounds. Either form may include Other…, which opens one required short or paragraph text modal. The first button, valid select, or Other modal submission wins after its serialized state write succeeds; later submissions receive ephemeral stale feedback. Interactions are immediately answered with the modal or ephemerally deferred, successful completion best-effort disables the controls, and accepted/stale/unauthorized/failure feedback does not clutter the public thread.
Expected Unicode reactions are declared when the message is sent. The returned message_ref can then authorize bot add/remove operations. An add and a remove by the configured Discord user each become a causal Pi action at most once; other emoji, users, and messages remain ordinary Discord decoration.
The starter embed is an editable top-of-thread session card. Its compact three-column layout shows the workspace, usage/cost, and technical runtime values beneath the current Pi session name; tmux, Git branch, and stable session ID sit in the footer. Values use line breaks instead of middot-separated text. The Discord thread title already carries the topic. Pi refreshes the card after relevant session, model, compaction, and settled-run events. The raw cwd, runtime UUID, provider label, and socket path remain hidden transport metadata.
Authorized Discord replies in a known thread are inserted into their originating Pi session with the Discord author, meaningful topic, opaque topic/message references, and any broker-known native reply provenance visible to both the model and terminal user. Supported Discord image attachments become real Pi multimodal image blocks, including image-only messages and up to four images. Other attachments are represented only by bounded filename, declared type, size, and description metadata; Posthorn never downloads them or exposes a path. Discord edits and deletes become explicit durable correction/deletion events with the original message/topic/session provenance and previous bounded content. They never claim to rewrite context Pi already consumed. A normal reply wakes an idle session or queues as a follow-up behind busy work. After an exclusively Discord-owned run fully settles, Posthorn sends only its final assistant text back to the same thread. Reasoning and tool internals are not sent. An explicit send_message reply to that topic suppresses the duplicate automatic return. Any terminal/RPC user or unrelated custom message actually delivered into the owned run contaminates its causal ownership and suppresses automatic return rather than risking disclosure; raw input handled before it enters the message lifecycle does not cause a false suppression. Terminal-originated answers remain terminal-only unless the model calls send_message.
Trimmed Discord messages beginning or ending with literal 🤫 or :shushing_face: are still inserted, but do not start a turn and do not cause an automatic return by themselves.
Requirements
- Node.js 22.19 or newer and Vite+
- A Discord bot token with the Message Content gateway intent enabled (Posthorn also enables the non-privileged guild message-reaction intent)
- A guild text channel where the bot can view the channel, read message history, send messages and native attachments, create public threads, send messages in threads, and add/remove its own reactions
- The Discord user ID authorized to send replies into Pi
- Local Pi 0.84.2 or a compatible release
Development setup
Install project dependencies without installing the extension into Pi's global package settings:
vp install
cp .env.example .env
Fill in the local .env file:
POSTHORN_DISCORD_TOKEN=your-bot-token
POSTHORN_DISCORD_CHANNEL_ID=your-channel-id
POSTHORN_DISCORD_USER_ID=your-user-id
.env and its variants are ignored by version control. The broker script loads .env through Node's native environment-file support; no shell export or per-terminal token setup is needed.
Run the broker
Start the foreground development broker:
vp run broker
Start or reload Pi sessions after the broker is listening. Concurrent live Pi sessions share this one local broker. The extension registers on each session_start, updates identity on session_info_changed, and closes on session_shutdown. BrokerClient reconnects automatically while that Pi runtime remains live, so a restarted broker can resume queued inbound delivery without restarting Pi. If the broker is absent, send_message reports a clear tool error while background reconnect attempts continue.
The broker presence is idle when no Pi session is running and summarizes the number of active sessions otherwise. A Discord-owned run also maintains typing only in its causal thread; terminal-owned runs affect aggregate presence without making unrelated threads type.
During development, load the working-tree extension directly instead of installing it:
pi -e ./src/extension.ts
From another project directory, use the absolute extension path:
pi -e /home/dikka/projs/pi-posthorn/src/extension.ts
A permanent pi install is intentionally deferred until Posthorn is ready to be used as an installed package.
The socket is posthorn/broker.sock below XDG_RUNTIME_DIR. If that directory is unavailable, Posthorn uses /tmp/posthorn-<uid>/broker.sock. The containing directory is mode 0700, the socket is mode 0600, and a dead broker's stale socket is removed at the next start.
Broker state is stored as broker.sock.state.json beside the socket with mode 0600. Inbound image bytes awaiting ACK live in a sibling mode-0700 spool with mode-0600 files; state stores only opaque spool references. ACK first removes the durable queue reference and then deletes its bytes, while broker startup removes unreferenced leftovers and validates every live file's existence, ownership, mode, size, and byte signature before listening. Mutations are serialized and replace the file through a temporary-file rename; this protects process-level consistency but does not claim database transactions or power-loss durability because the prototype does not fsync the file and directory. It retains broker-issued topics, only temporarily actionable message/control/reaction mappings, completed transition state, authorized inbound actions and messages awaiting acknowledgement, a bounded recent Discord event/message-ID dedupe window, and short outbound capacity reservations across broker restarts. Expired/completed control mappings, expired reaction mappings, and abandoned reservations are pruned during outbound preflight, except that any authority still targeted by an unacknowledged queued reply is retained through preflight/restart until ACK. Existing v1/v2/v3 state is migrated in memory and written in the current v4 shape on its next mutation. A state that already declares v4 must contain the complete strict v4 root and pass cross-direction ID, spool-reference/quota, exact bounded reply-provenance, and event-semantic relations; missing fields are not treated as an older shape. Inbound reply excerpts are authorized by bounded SHA-256 digests so accepted historical excerpts remain verifiable across later edits without duplicating their text.
Before any message is posted, Posthorn persists bounded capacity for its reply-authority mapping (plus topic/control capacity where needed). A known capacity or state-write failure therefore stops the Discord post. Discord and the local state file still cannot participate in one atomic cross-system transaction: if Discord accepts a post and the subsequent state commit fails, Posthorn returns a tool error and the visible orphan has no broker authority. The reservation is released or later expires. This is a safe failure, not an exactly-once outbound guarantee.
Messages, images, corrections, deletions, and actions received while their originating Pi session is disconnected are replayed when that stable session ID reconnects. New Pi delivery receipts are small delivery-ID markers; the persisted custom message contains the multimodal content once, while its details omit image base64. A marker without its custom message is a crash-window marker, not acceptance: the extension waits for the broker's still-queued replay, injects that payload, and acknowledges it only after the attributed custom-message entry is durable. Legacy full-payload receipts remain readable through the same strict routed-delivery parser.
Delivery semantics
Inbound delivery is at least once at the broker/session-file level, not exactly once and not power-loss durable. The extension appends a small receipt marker before handing input to Pi, but it acknowledges only delivery IDs whose strictly parsed attributed custom-message entry is present in an established persistent session. Quiet context appends synchronously; waking input may remain in Pi's in-memory follow-up queue while a run is busy and therefore remains unacknowledged until a later lifecycle event observes its persisted custom entry. A marker alone is never acknowledged. Pi lazily creates a brand-new session file only after its first assistant message, so fresh sessions also wait for that first assistant flush. Ephemeral/no-session Pi runtimes cannot create a durable receipt, so their broker messages remain queued and are not acknowledged. A crash after the model has observed an in-memory message but before the corresponding Pi custom-message entry is durable can recover it from the broker queue and expose a duplicate after resume. If the receipt itself was not yet durable, the broker likewise retains and replays its queued copy. Very old Discord redeliveries can also pass again after the bounded dedupe window rolls over.
Component, modal, and expected-reaction actions preserve the Discord author, topic and topic_ref, broker message_ref, originating question, and response in both model-visible and user-visible Pi content. They always wake Pi and otherwise use the same receipt-before-injection, ACK/replay, automatic-return, and mixed-input contamination rules as ordinary Discord messages.
Automatic assistant answers are split into Discord-safe chunks rather than silently truncated. Explicit tool images are native Discord uploads; only canonical base64 PNG/JPEG/GIF/WebP payloads (four images, 10 MiB each, 20 MiB total) are accepted. Paths and outbound non-image files are not inputs. Inbound downloads require a supported gateway-declared type and bounded positive declared size for eligibility, Discord CDN HTTPS hosts, at most three redirects, and the same count/byte ceilings. Downloaded size is enforced independently because Discord may report the size of an advisory transformed representation while its attachment URL returns different canonical bytes. The downloaded supported byte signature is canonical for delivery; Discord and HTTP media labels are advisory and filenames are never trusted for type inference. A message's first four supported images download in bounded parallel under one five-second deadline. At most four image-bearing messages download concurrently with 32 pending by default; saturation preserves later input immediately with metadata-only unavailable-image notices, so image work does not block following text. A reply waits only for in-flight work on its referenced message (plus its own message history), then snapshots that provenance before starting its own attachment work. The immutable snapshot survives target edits/deletion while the reply downloads and preserves provenance when the target image succeeds, times out, or degrades at capacity; unrelated text still bypasses it. The durable live spool is capped at 128 MiB transactionally; quota saturation also preserves metadata and ACK reclaims capacity. Every Discord send, edit, and interaction reply disables mention parsing; native replies also explicitly disable replied-user mentions.
Test and check
No Discord token or network access is used by the tests; they inject a fake Discord adapter.
vp run test
vp run check
PI_OFFLINE=1 pi -e ./src/extension.ts --list-models
The fake-Discord test suite and Pi extension-load smoke test do not require .env or network access. Guided live-review checklists for each implementation part are under .dev/manual-tests/.
Current limits
Only Pi can create a Posthorn topic; Discord-side topic discovery is not implemented. Discord can reply or act only inside broker-known threads, and only the configured Discord user is authorized. Controls and expected reaction authority expire after 24 hours. This slice supports traditional buttons, one string select, one Other text input, and one-grapheme Unicode reactions; it rejects Discord custom emoji in mention, raw-snowflake, and name:snowflake forms. It does not support link buttons, multiple select menus, Discord user/role/channel selectors, or arbitrary model-provided component IDs.
Persisted state is local to the broker runtime directory and is simple JSON rather than a multi-process database. For bounded parsing and storage it accepts at most 10,000 topic mappings, 10,000 temporary Pi-authored message mappings, 10,000 authorized Discord message mappings, 10,000 active control mappings, 10,000 queued inbound messages, and 2,000 recent dedupe IDs. Reservations count against those limits. Expired/inert authority is pruned before capacity is reserved, and ordinary sends consume temporary message-mapping capacity so their broker-issued references can authorize replies. Reaching a live hard limit surfaces a broker error before posting instead of silently discarding state.
Posthorn does not download or send non-image attachments, accept arbitrary filesystem upload paths, expose webhooks, run as a background service, install autostart units, support model-selected destinations, or provide public hosting. It is a local foreground broker, not a public service. Do not expose its Unix socket, credentials, or an equivalent TCP endpoint.