# discord-remote

Bidirectional Discord remote control for a local Pi coding-agent session. Your
Pi instance keeps running on the laptop; you talk to it from Discord.

- **Session threads**: every Pi session gets a Discord thread named with the Pi
  session ID (for example `pi-<session-id>`), and all session traffic is routed
  to that thread.
- **Outbound**: every assistant message streams to the active Discord thread as
  the tokens arrive. Tool calls and tool results render as concise Discord embeds.
- **Inbound**: messages you type in the allowlisted channel or active session
  thread inject into Pi via `pi.sendUserMessage(...)`. Mid-turn messages
  **steer** the running turn; idle-state messages queue as a normal user turn.
- **Discord commands**: authorized Discord users can run Discord-side Pi commands
  such as `/commands`, `/session`, `/compact`, `/abort`, and `/new`. The bot can
  optionally register these bridge controls as guild-scoped Discord app slash
  commands.
- **`/new` works automatically**: the extension starts a local HTTP session-launcher
  server on `127.0.0.1:8765`. When `/new` is invoked from Discord, it either
  replaces the session in-process (if `/discord-arm` was run in the Pi TUI) or
  spawns a fresh Pi process (configured via `PI_DISCORD_SPAWN_COMMAND`). The
  HTTP server can also be reached by external callers via nginx (see
  `nginx.conf.sample`).

## ⚠️ Security

Allowlisted Discord users get effectively the same access to your machine as
sitting at the keyboard. Pi will gladly run `bash`, edit files, and call any
configured provider's APIs. Treat your bot token and user allowlist with the
same care as an SSH key:

- Use a **fresh, private Discord application** dedicated to this. Do not reuse
  a bot that's in any public server.
- Put the bot in a **private guild** with only you (and trusted users) in it.
- Set `PI_DISCORD_USER_IDS` to the Discord user IDs that may drive Pi. The
  extension refuses to start with an empty allowlist.
- Never commit `.env`. The repo's `.gitignore` already excludes it.
- The extension also refuses to start without a bot token, channel allowlist,
  and user allowlist. Prefer `PI_DISCORD_BOT_TOKEN`, `PI_DISCORD_CHANNEL_IDS`,
  and `PI_DISCORD_USER_IDS`; legacy `DISCORD_*` fallback names are disabled
  unless `PI_DISCORD_ALLOW_LEGACY_ENV=true`.

## Setup

### 1. Create a Discord bot

1. Go to <https://discord.com/developers/applications>, click **New
   Application**, name it `pi-remote` (or whatever).
2. In the sidebar, open **Bot**. Click **Reset Token** and copy the token — this is
   `PI_DISCORD_BOT_TOKEN`.
3. Still on the **Bot** page, scroll to **Privileged Gateway Intents** and
   enable **Message Content Intent**. Save. (Reactions and typing use the
   default non-privileged intents — no extra toggle required.)
4. In the sidebar, open **OAuth2 → URL Generator**. Check `bot` and
   `applications.commands` under scopes; under bot permissions, check
   `View Channels`, `Send Messages`, `Read Message History`, `Add Reactions`,
   `Embed Links`, `Create Public Threads`, `Send Messages in Threads`, and
   `Manage Threads` if you want the bot to unarchive old session threads. Copy
   the generated URL, open it in a browser, and invite the bot to your private
   guild.

### 2. Find IDs

Enable Discord's **Developer Mode** (Settings → Advanced → Developer Mode).
Then:

- Right-click the target channel → **Copy Channel ID** → `PI_DISCORD_CHANNEL_IDS`.
- Right-click your own avatar → **Copy User ID** → `PI_DISCORD_USER_IDS`.

### 3. Configure `.env`

Copy `.env.sample` to `.env` (project root) and fill in:

```bash
PI_DISCORD_ENABLED=true
PI_DISCORD_BOT_TOKEN=<your bot token>
PI_DISCORD_CHANNEL_IDS=<channel id>
PI_DISCORD_USER_IDS=<your discord user id>
# Optional: enabled by default; creates one Discord thread per Pi session.
PI_DISCORD_CREATE_THREAD_PER_SESSION=true
PI_DISCORD_THREAD_NAME_PREFIX=pi
# Optional: disabled by default. Set one or more guild IDs to register
# Discord bridge controls as guild-scoped app slash commands.
PI_DISCORD_REGISTER_SLASH_COMMANDS=false
PI_DISCORD_SLASH_COMMAND_GUILD_IDS=<guild id>
```

`bin/pi-safe.sh` (and friends) load `.env` at launch, so these vars become
visible to the extension. The extension also loads `.env` from Pi's current
working directory by default; override with `PI_DISCORD_ENV_FILE=/path/to/.env`
if needed.

In this repo, the extension also accepts newer Pi-prefixed app keys as
fallbacks:

- `PI_DISCORD_BOT_CHANNEL_ID` → channel allowlist and default primary channel
- `PI_DISCORD_USER_ID` → user allowlist

Legacy OpenClaw `DISCORD_*` names (`DISCORD_BOT_TOKEN`,
`DISCORD_BOT_CHANNEL_ID`, `DISCORD_USER_ID`) are disabled by default to avoid
silently selecting the wrong bot or channel. Set
`PI_DISCORD_ALLOW_LEGACY_ENV=true` only when you intentionally want those
fallbacks. Any fallback still requires `PI_DISCORD_ENABLED=true`.

### 4. Install from npm

```bash
pi install npm:@mporenta/pi-discord-remote
```

For local development from this repo instead:

```bash
cd extensions/discord-remote
npm install
cd ../..
```

This populates `extensions/discord-remote/node_modules/discord.js` and uses the
committed `package-lock.json`. The Pi packages stay shared at the repo root
(declared as `peerDependencies` here).

### 5. Run Pi with the extension

```bash
pi -e extensions/discord-remote/index.ts
```

Or combine with the team launcher:

```bash
npm run pi:safe -- -e extensions/discord-remote/index.ts
```

The extension is **opt-in** — it's not in `bin/pi-safe.sh` by default because
it requires the env vars above.

## Publishing

The npm package is `@mporenta/pi-discord-remote`. CI publishes from
`extensions/discord-remote` when changes land on `main` and the package version
is not already on npm. Configure the GitHub repository secret
`NPM_ACCESS_TOKEN` with an npm automation token that can publish to the
`@mporenta` scope.

To release a new version:

```bash
cd extensions/discord-remote
npm version patch --no-git-tag-version
```

Commit the package files and push to `main`; GitHub Actions handles
`npm publish --access public`.

## Slash commands (inside Pi)

- `/discord-arm` — arms in-process session replacement for Discord `/new`. This
  is now **optional**: when not armed, `/new` falls back to spawning a new Pi
  process via `PI_DISCORD_SPAWN_COMMAND`. Run it if you prefer in-process
  replacement (faster, no new TUI window). Re-run after local `/new`, `/resume`,
  `/fork`, or `/reload`; Discord-created sessions re-arm themselves.
- `/discord-status` — token set?, gateway ready?, mute state, channel
  allowlist, user allowlist, active session thread, and render flags.
- `/discord-test [message]` — post a one-off message to the active session
  thread (or primary channel before a thread exists). Use to confirm the bot is
  connected before you start a session.
- `/discord-reconnect` — destroy and recreate the client. Useful if the
  gateway disconnected and didn't auto-recover.
- `/discord-launcher-status` — show the launcher HTTP server status (port, running, armed, spawn config).
- `/discord-mute` — suppress further Pi output to Discord without disconnecting
  the gateway. Inbound Discord messages still drive Pi. Pair with `/discord-unmute`
  to resume.
- `/discord-unmute` — resume Pi output to Discord after `/discord-mute`.

## Inbound commands (from Discord)

- Plain text — forwarded as `<steerLabel> <text>` via `pi.sendUserMessage`.
  When Pi is idle the message queues as a normal user turn (✅ react). When Pi
  is mid-turn the message steers the running turn (📥 react). Only messages
  posted in an allowlisted channel or in the **current** session's thread are
  forwarded; posts in stale session threads are ignored so they can't
  cross-steer the live session.
- React 🛑 — react with 🛑 on **any bot message** in an allowlisted channel
  or the **current** session's thread to abort the running Pi turn. Faster
  than typing `/abort` when a long-running tool needs cancelling. Reactions
  in stale session threads are ignored for the same scoping reason as
  inbound text.
- `/commands` or `/help` — lists Discord bridge controls plus Pi's currently
  registered session commands (extension, prompt, and skill metadata from
  `pi.getCommands()`). Pi command metadata is visibility-only because the public
  extension API does not expose a safe background dispatcher for arbitrary Pi
  command handlers. When `PI_DISCORD_REGISTER_SLASH_COMMANDS=true` and
  `PI_DISCORD_SLASH_COMMAND_GUILD_IDS` is set, the explicit Discord bridge
  controls are registered as **native Discord slash commands** via the
  `@discordjs/rest` module (re-exported by discord.js) and the explicit
  `Routes.applicationGuildCommands(applicationId, guildId)` route, so
  registration is scoped to the private guild(s) and never escapes to global
  application commands. Each command is defined with `SlashCommandBuilder` and
  the bot listens for `interactionCreate` events over the gateway.
- `/session` — shows the active Pi session ID and Discord thread binding.
- `/compact` — calls `ctx.compact()` for the active session.
- `/abort` or `!abort` — calls `ctx.abort()` to cancel an in-flight turn. 🛑
  react confirms. (Reacting 🛑 on a bot message does the same thing.)
- `/mute` — suppress all further Pi output (streaming assistant edits, tool
  embeds, raw posts, and typing indicator) without disconnecting the gateway.
  Any pending stream-buffer flush is cancelled so an in-flight turn goes quiet
  immediately. Inbound messages still drive Pi. 🔇 react confirms.
- `/unmute` — resume Pi output. 🔈 react confirms.
- `/new [message]` — creates a new Pi session and a new Discord session thread,
  then optionally sends `message` as the first prompt. Works without
  `/discord-arm`: if the bridge is not armed the extension spawns a fresh Pi
  process (configured by `PI_DISCORD_SPAWN_COMMAND` / `PI_DISCORD_SPAWN_ARGS`).
  The spawned process inherits all `PI_DISCORD_*` env vars, connects to Discord,
  and creates its own session thread. Arm the bridge for faster in-process
  replacement instead.

## Session launcher HTTP server

The extension automatically starts a local HTTP server on `127.0.0.1:PI_DISCORD_LAUNCHER_PORT` (default `8765`) when Pi starts. It provides two endpoints:

| Method | Path | Description |
| ------ | ---- | ----------- |
| `GET`  | `/health` | JSON: `{ ok, ready, armed }` |
| `POST` | `/new-session` | JSON body: `{ "secret": "…", "args": "…" }` — creates a new Pi session |

When `/new-session` is called:
1. If the bridge is **armed** (via `/discord-arm`): replaces the current session in-process using `ctx.newSession()`. The `args` string becomes the first user message.
2. If **not armed**: spawns `PI_DISCORD_SPAWN_COMMAND` with `PI_DISCORD_SPAWN_ARGS`. The child process inherits all `PI_DISCORD_*` vars, connects to Discord, and creates its own thread. The launcher sets `PI_DISCORD_LAUNCHER_ENABLED=false` in the child to prevent port conflicts.

If the port is already in use (e.g. a second Pi instance), the server silently skips binding — no error, the Discord `/new` command still works via the first instance.

### Authentication

Set `PI_DISCORD_LAUNCHER_SECRET` in `.env` to a random string. The server returns `401` when the secret is present but wrong. Without a secret the endpoint is unauthenticated — only expose it externally behind nginx and HTTPS (see `nginx.conf.sample`).

### External access via nginx

See `nginx.conf.sample` for a ready-to-adapt nginx `location` block that proxies `https://<YOUR_DOMAIN>/pi-launcher/` to the local server with TLS termination. Example curl:

```bash
curl -X POST https://your.host/pi-launcher/new-session \
  -H "Content-Type: application/json" \
  -d '{"secret":"mysecret","args":"Summarise open PRs"}'
```

## Reaction shortcuts

| React | Where                                                                     | Effect                                              |
| ----- | ------------------------------------------------------------------------- | --------------------------------------------------- |
| 🛑    | Any bot message in an allowlisted channel or the current session's thread | Abort the running Pi turn (equivalent to `/abort`). |

The bot also reacts on your inbound messages to confirm delivery: ✅ (queued
while idle), 📥 (steered into a running turn), 🔇 / 🔈 (mute / unmute applied),
🛑 (abort issued), 🧹 (compact), 🆕 / 🚫 (new session created / cancelled).

## Live feedback

While Pi is generating an assistant message, the bot shows a Discord
**typing indicator** in the active session thread. The indicator refreshes
every 8 seconds and stops when the turn ends, so the channel always reflects
whether Pi is actively working.

Tool-result embeds also include a **Duration** field showing how long the tool
took to run (e.g. `850ms`, `12.3s`, `1m 42s`), so you can spot slow tools
without scrolling the local TUI.

Pi's public extension API currently executes extension slash commands only from
Pi prompt handling and deliberately skips command expansion for
`pi.sendUserMessage(...)`. The Discord bridge therefore implements the safe
Discord-side built-ins above and lists all available Pi slash commands for
visibility; additional custom command execution should be added as explicit
Discord handlers when the command requires command-context-only methods. The
bridge validates that the armed command context still belongs to the active
session before honoring Discord `/new`, and asks you to re-arm if a local session
change or reload made the context stale.

## Configuration reference

| Var                                      | Default                                                                             | Effect                                                                   |
| ---------------------------------------- | ----------------------------------------------------------------------------------- | ------------------------------------------------------------------------ |
| `PI_DISCORD_ENABLED`                     | `false`                                                                             | Master toggle. Default off so a stray `.env` doesn't auto-start the bot. |
| `PI_DISCORD_ALLOW_LEGACY_ENV`            | `false`                                                                             | Enable legacy `DISCORD_*` fallbacks.                                     |
| `PI_DISCORD_BOT_TOKEN`                   | `DISCORD_BOT_TOKEN` only when legacy fallback is enabled                            | Bot token. **Required.**                                                 |
| `PI_DISCORD_CHANNEL_IDS`                 | `PI_DISCORD_BOT_CHANNEL_ID`, then legacy `DISCORD_BOT_CHANNEL_ID` only when enabled | Comma-separated channel ID allowlist. **Required.**                      |
| `PI_DISCORD_USER_IDS`                    | `PI_DISCORD_USER_ID`, then legacy `DISCORD_USER_ID` only when enabled               | Comma-separated Discord user ID allowlist. **Required.**                 |
| `PI_DISCORD_PRIMARY_CHANNEL_ID`          | first allowlisted channel                                                           | Parent channel where session threads are created.                        |
| `PI_DISCORD_CREATE_THREAD_PER_SESSION`   | `true`                                                                              | Create a Discord thread for each Pi session and route traffic there.     |
| `PI_DISCORD_THREAD_NAME_PREFIX`          | `pi`                                                                                | Prefix for session thread names; the Pi session ID is appended.          |
| `PI_DISCORD_THREAD_AUTO_ARCHIVE_MINUTES` | `10080`                                                                             | Thread auto-archive duration (`60`, `1440`, `4320`, or `10080`).         |
| `PI_DISCORD_EDIT_INTERVAL_MS`            | `1100`                                                                              | Min ms between edits per streamed message.                               |
| `PI_DISCORD_CREATE_INTERVAL_MS`          | `600`                                                                               | Min ms between new-message creates per channel.                          |
| `PI_DISCORD_MAX_CHARS`                   | `1900`                                                                              | Soft cap per Discord message (Discord hard-limits at 2000).              |
| `PI_DISCORD_RENDER_USER_INPUT`           | `true`                                                                              | Mirror locally-typed user input to Discord.                              |
| `PI_DISCORD_RENDER_TOOL_CALLS`           | `true`                                                                              | Post readable tool-call embeds with action + key inputs.                 |
| `PI_DISCORD_RENDER_TOOL_RESULTS`         | `true`                                                                              | Update tool-call embeds with concise result summaries.                   |
| `PI_DISCORD_RENDER_REASONING`            | `false`                                                                             | Include `thinking` content blocks (italicized).                          |
| `PI_DISCORD_TOOL_ARGS_MAX_CHARS`         | `800`                                                                               | Truncate tool-call argument JSON to this length.                         |
| `PI_DISCORD_TOOL_RESULT_MAX_CHARS`       | `1000`                                                                              | Truncate tool-result text to this length.                                |
| `PI_DISCORD_PREFIX`                      | `""`                                                                                | Optional prefix on relayed messages (e.g. `🤖`).                         |
| `PI_DISCORD_STEER_LABEL`                 | `[discord]`                                                                         | Tag prepended to inbound text so local view shows source.                |
| `PI_DISCORD_REGISTER_SLASH_COMMANDS`     | `false`                                                                             | Register explicit Discord bridge controls as app slash commands.         |
| `PI_DISCORD_SLASH_COMMAND_GUILD_IDS`     | `""`                                                                                | Required comma-separated guild IDs for slash-command registration.       |

## Rendering behavior

Tool calls and tool results are rendered as Discord embeds instead of raw JSON.
The embed shows the tool action, selected non-secret inputs, and a concise result
summary. Secret-like argument keys (`token`, `secret`, `password`, `api_key`,
`webhook`, etc.), common token formats, and sensitive environment variable
values are redacted before rendering. Discord mentions are disabled on relayed
messages to avoid accidental pings.

## Known limitations

- Discord supports explicit bridge commands (`/commands`, `/session`,
  `/compact`, `/abort`, and `/new`) and lists Pi slash command metadata. Pi's
  public API does not expose a general command dispatcher to background Discord
  message events, so arbitrary custom slash commands need explicit Discord-side
  handlers if they require command-context-only methods.
- Discord `/new` now works without `/discord-arm` by spawning a new Pi process.
  Set `PI_DISCORD_SPAWN_COMMAND` and `PI_DISCORD_SPAWN_ARGS` to the launcher
  you want (e.g. `bin/pi-safe.sh -e extensions/discord-remote/index.ts`).
  In-process session replacement via arm is faster but requires running
  `/discord-arm` after each local session change.
- Discord rate limits: edits to a single message ≈ 5/5s; messages per channel
  ≈ 1/0.5s. The defaults stay under both. Pathologically fast streams will
  briefly buffer.
- 2000-char hard cap → long assistant messages span multiple Discord posts.
  Once page 2 exists, page 1 is locked; late retroactive shrinkage of early
  content does not re-flow pages.
- No image / file attachment relay (inbound or outbound) in v1. Inbound
  attachments are ignored; only `msg.content` is forwarded.
- One active Discord output target at a time. By default this is the current
  session thread; if thread creation is disabled or fails, the primary channel
  is used. Multi-channel inbound is allowed via the allowlist.
- The `tool_call`/`tool_result` pairing relies on Pi running tool calls in
  order; if parallel tool execution interleaves rendering, results still
  attach to the right call (lookup is by `toolCallId`).

## Troubleshooting

- **`/discord-status` shows `ready=false`** — check `PI_DISCORD_BOT_TOKEN`,
  confirm **Message Content Intent** is enabled in the developer portal, and
  that your network allows outbound WSS to `gateway.discord.gg`.
- **`refusing to start`** on stderr — one of the required env vars is empty.
- **Messages in channel ignored** — you're not in `PI_DISCORD_USER_IDS`, or
  the channel isn't in `PI_DISCORD_CHANNEL_IDS`.
- **`Missing Access` or send fails** — the bot is in the guild but cannot see
  the target text channel. Grant the bot role (or the bot user) `View Channel`,
  `Send Messages`, `Send Messages in Threads`, `Create Public Threads`,
  `Read Message History`, `Add Reactions`, and `Embed Links` on the
  channel/category, then rerun `/discord-test`.
- **Bot stays online after Pi quits** — `session_shutdown` should destroy the
  client; if it didn't (e.g. you killed Pi with `kill -9`), Discord still
  shows the bot online for ~30s of heartbeat timeout.
