AGENTS.md@packages/chat-gateway/src/server · git:20260918.2f0b7e4 · 2026-09-18 · sha256 8c5c80b125046c74
AGENTS.md@packages/chat-gateway/src/server git:20260918.2f0b7e4A
Immutable. This exact content is served forever at /api/v1/blob/8c5c80b125046c74.
# DOX — packages/chat-gateway/src/server
Files in this directory. One row per source file. See change: add-chat-gateway.
| File | Purpose |
|------|---------|
| `index.ts` | Plugin server entry. INERT when no token (task 1.3): nothing is constructed — no adapter, no socket, no timers — and the heavy `discord.js` import sits AFTER that check so an unconfigured install pays nothing. Fails LOUD when the host lacks `subscribeSession` (sending-but-never-receiving would look healthy). `chatGatewayStateDir()`/`bindingsFilePath()` = `~/.pi/dashboard/chat-gateway/bindings.json`. Wires `ctx.onShutdown` → `gateway.stop()`. Calls `adapter.initialize()` (creates the client + logs in) BEFORE `gateway.start()` — `start()` only wires handlers, so skipping it silently kills the plugin. Passes `allowedChannels: groupChannels` so the adapter itself drops non-opted-in guild channels. After start, registers `GET /api/chat-gateway/bindings` (networkGuard) → `{bindings, status}` for the settings panel; that route exists only when configured and never returns the pairing code (logged once at startup). |
| `gateway.ts` | The orchestrator (`createChatGateway`). Inbound: DM pairing redemption (L1, DM-only) → L1/L4 `authorize` → **L2 admin-only bind** (`ensureBinding` refuses a NEW binding from a non-admin) → `sendPrompt` with `delivery` from `shouldSteer`. Binding transitions: attach (single in-range live session) · spawn (fixedMap/default, token-correlated) · **resume(continue)** when the bound session ENDED and carries a `sessionFile`; a live-but-disconnected session stays the in-channel 502 error. `spawnCorrelated` FAILS CLOSED when `toolPolicy` is set without `guardExtension` (pi treats an unresolvable `-e` as non-fatal, so refusing is the only way to avoid an ungated session). Outbound: `message_update` assistant text → per-channel `EditThrottle` → ONE message edited in place (F7), new message past `DISCORD_MESSAGE_LIMIT` (F6); `prompt_request` → native controls (with `multiselect`/`batch` driven as an ordered sub-prompt SEQUENCE that submits ONE JSON root `prompt_response`, 7.2), and every `onInteractiveResponse` is RE-AUTHORIZED (L1/L4) — a click is an actor action, so a non-allowlisted clicker is refused and does not consume the prompt; `prompt_dismiss`/`prompt_cancel` → `cleanupInteractive` (F2), and a root dismiss drops the whole sequence without a response. A spawn-source bind is NOT persisted here — the id is unknown until `onSessionResolved` writes it (X8), and that write uses the correlator's EXACT `{channelId, threadId}` (a thread spawn must not land on the parent — F3). `pendingSpawns` gates a second message during the spawn window (F7); `start()` re-subscribes every persisted binding so a restart still streams (F4); non-prefix assistant text starts a fresh message sequence (F8); only the tail chunk is edited (F10); `group_channel_not_opted_in` is dropped SILENTLY (no reply, no noise). Every other refusal is a reasoned in-channel reply and never reaches a session. |
| `seam.ts` | `HostSeam` — the mockable surface over `ServerPluginContext`. `sendPrompt`/`sendPromptResponse` ride `ctx.sendExtensionMessage` (the raw control lane the browser's `send_prompt`/`prompt_response` already use); `subscribe` → `ctx.subscribeSession`; `spawn` → `ctx.spawnSession` (incl. `resume: {sessionFile}` and the L3 `scope.extensions` block); `getSession` → `ctx.sessionManager.getSession` (active OR ended — the resume input); `persistAllowlist` → `ctx.updatePluginConfig({allowlist})` after a pairing redemption. Keeps every orchestration path testable with a fake host. |
| `binding.ts` | `isWithinAllowedRoots(candidateCwd, allowedRoots)` — real-path (symlink) resolved with nearest-existing-ancestor fallback, path-segment-aware containment (`/repos/proj-2` is NOT inside `/repos/proj`), empty whitelist ⇒ false (fail closed). `resolveCwd` precedence persisted > fixedMap > default, where the FIRST candidate that exists is judged and a failing one is REFUSED rather than silently skipped (falling through would widen the boundary); it returns the CANONICAL (symlink-resolved) path, so the cwd spawned is exactly the cwd validated. `resolveInteractiveCwd` gates attach/spawn candidates. |
| `routing.ts` | `createBindingStore({filePath})` — sticky `(platform, channelId, threadId?)` bindings, tolerant load (missing/corrupt ⇒ empty, never throws), atomic write (tmp-in-same-dir + rename, dir 0700, file 0600), exposed `load/get/set/remove/all/persist`. `createSpawnCorrelator()` — token-keyed pending spawns whose meta carries `{channelKey, channelId, threadId, cwd, by}` so resolution rebinds the EXACT identity (a thread spawn lands on the thread key); `resolve` on an unknown token returns false so the caller can NEVER fall back to cwd+recency matching. |
| `auth.ts` | `authorize({config, userId, action, channelId, isDM})` decision table: `talk` needs allowlist membership (admin alone is NOT enough); `bind` needs admin AND allowlist (strictly narrower). L4 is the outermost gate — a non-opted-in guild channel is refused even for an allowlisted user, and an opted-in PARENT opts in its threads (`parentChannelId`). Distinct reason per refusal. `createPairing() : Pairing` — 15-minute TTL, 10-attempt lockout, code consumed on success, expiry invalidates; the 6-digit code comes from `crypto.randomInt` (not `Math.random` — a predictable code is guessable). WIRED into the gateway inbound path (DM-only redemption persists the user onto the allowlist). `PairingState` internal. |
| `stream.ts` | `createEditThrottle` — at most one edit per `minIntervalMs`, always converging on the LATEST text (C8/P1); injectable `now`/`schedule`/`cancel`, optional `onFire` sink for the trailing edge. `shouldSteer`/`stripSteerPrefix` — C7 (`!`-prefix forces `steer`; an empty prefix never steers; exactly one occurrence stripped). |
| `prompts.ts` | `toPromptControl(request)` — TOTAL mapping of a PromptBus request to a surface-neutral control; reads only `type`/`options`/`metadata`, ignores React `component`/`props`, unknown/non-object ⇒ `kind:"unsupported"` never throws. `multiselectToSequence`/`batchToSequence` (F3/F4) build the sub-prompt sequences; `composeMultiselectAnswer`/`composeBatchAnswers` encode the final JSON answer (`values[]` / `answers[]`, matching the web encoder); `composeBatchAnswer` reads one index-aligned slot. |