git:20260918.2f0b7e4 to git:20260919.9482906
6 added, 4 removed. Audit A to A.
# 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. |
+ | `gateway.ts` | `createChatGateway(deps)` — platform-agnostic gateway: binding/attach/spawn/resume routing, L1 pairing (DM-only; the success reply says session control happens in a workspace-bound channel, because team controls scope DMs OUT), the team-controls chokepoint (every action-bearing request resolves a `Grant`; a DM refused `unbound_channel` gets guidance while every OTHER reason still reports itself verbatim), `!disarm` (parsed in `handleInbound`, authorized through the SAME chokepoint as the verb `disarm`, then flips the latch; the `!` sigil is REQUIRED so an innocent prompt cannot halt the layer), provenance assignment, question gating, and the D9 mirror lane (`mirrorFrame` renders tool frames at the bound channel's level, passing a thread binding's persisted `parentChannelId`). `channelKeyFor(sessionId)` returns the FIRST matching binding. See change: add-chat-gateway-team-controls. |
+ | `index.ts` | Exports `shouldApplyDisarm(written, live)` — the disarm-write rule. Also exports `disarmFilePath()` (`disarm.json`). 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()` = `~/.pi/dashboard/chat-gateway/`; `bindingsFilePath()`/`channelsFilePath()`/`commandLogFilePath()` = `bindings.json`/`channels.json`/`command-log.json` there (`bindings.json` routes channel→session cwd; `channels.json` is the provisioning store — deliberately separate). Every store is LOADED on start (`channels.load()` / `store.load()` / `commandLog.load()`): the command log is append-only precisely so it SURVIVES a restart, so skipping its load would present an empty audit trail to the operator while the file sat full on disk (found by the L3 panel scenario F5, which rendered zero rows against a verifiably populated file). `commandLog.load()` sits ABOVE the inert early return so the token-less panel still shows the history it can read. Builds the TEAM LAYER: `validateTeamControls(rawConfig.teamControls)` from the RAW config (ResolvedConfig does not normalize it), falling back to `FAIL_CLOSED_TEAM_CONFIG` on rejection; `createProvisioningStore` + `createCommandLog` + `createProvisioner` (`channelBindings` fed to `createTeamController`, so authorization only ever sees a binding the layer owns). `reportLayerFailure` MERGES an error onto the loader's `/api/health.plugins[]` record for the plugin id rather than overwriting displayName/claims. Awaits the activation SWEEP before considering itself started, then reconciles in the background on `ctx.onWorkspacesChanged` (unsubscribed on shutdown). 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). Registers the TEAM-CONTROLS browser-handler lane (tasks 8.1-8.5) in BOTH modes: `registerSurfaceLane(lane)` is a hoisted declaration the INERT branch calls before any adapter exists, because everything the panel reads is local (policy validated in-memory; the provisioning store and command log are file READS) and an operator must be able to inspect and EDIT the policy with no token — which is also what makes the panel reachable in the browser harness. The policy is therefore validated BEFORE the inert check. Only the PLATFORM half is injected (`SurfaceLane`): inert supplies `unavailableForAll`, so the delegation reads `unavailable: the gateway is not connected` rather than an empty roster (an empty list would read as "nobody can assign this"), and `reconcile` is a no-op `{ok:true}` since an inert layer owns no channel — one write-lane shape, not two. The live lane `.catch`-es the adapter's own delegation read to `unavailable` too, because a missing Server Members intent would otherwise hang the panel. `TEAM_SURFACE_MESSAGE` answers with `buildTeamSurface(...)` and LOGS a failed build instead of leaving an unhandled rejection. `TEAM_CONFIG_MESSAGE` is the WRITE path — deliberately not the core `plugin_config_write`, which persists and returns without awaiting the platform. The write is `validateTeamControlsWrite(raw)` → `failWrite(reason, withSurface)` on refusal (the ONE failure shape) → else `applyWrite(candidate)`, which converges the PLATFORM via `lane.reconcile()` BEFORE `ctx.updatePluginConfig` and reverts in-memory + re-converges on EITHER failure, so a write is never reported as succeeded before the platform's overwrites match (D7); `lane.onDisarmChanged` fires only when `shouldApplyDisarm(written, live)` says so — the write must CARRIED `disarmed` (an omitted field defaults to `false` downstream, which against a live `true` reads as a re-arm; absence is a no-op) AND the written flag must differ from the LIVE latch, NOT from `teamConfig.disarmed`. A chat disarm flips the latch without writing config, so comparing against config made the dashboard's re-arm read as "no change" and left the layer disarmed with no way back. Registered in BOTH modes (inert supplies `unavailableForAll`). Known limitation: the latch is in-memory, so a server RESTART re-arms the layer (the controller initialises from config) — the spec wants it to stay disarmed until an OPERATOR re-arms, so this is tracked as task 11.3. |
+ | `gateway.ts` Resolves a spawn cwd through `resolveBindCwd` → `resolveCwdWithWorkspace` (D8), so the BOUND WORKSPACE's folders are a real source between a persisted binding and the fixed map (a thread inherits its parent channel's binding); inert folders are SKIPPED, so the source can only narrow the spawn boundary. `toInbound` (extracted from the `onMessage` closure) threads `bot`/`webhook`/`roleIds` to the edge, without which the chokepoint's non-human refusal and every role→tier mapping would be unreachable. | 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. Team-controls: sessions are driven ONLY via `dispatchToSession` (grant-required, task 3.14); a trusted-gated `assignSessionRef` no-op (D5) refuses the command and marks the layer unhealthy instead of driving the session. MIRROR LANE (D9, present only with the layer): `handleFrame` calls `mirrorFrame(key, frame)`; `mirrorEventFrom(frame)` maps `tool_execution_start`/`tool_execution_end` (via `toolCallMirrorEvent`/`toolResultMirrorEvent`, target basename via `toolTarget`, edit payload via `editDiff`) onto `MirrorEvent`, `renderMirror(event, team.mirrorLevel(channelId))` filters it, and the result is POSTED through a `createPacer` — it runs BEFORE the assistant-text early return, so a tool frame is not dropped as "not assistant text". Assistant prose keeps its own edit-in-place path (so structured posts do not disturb F6/F7/F10). Mirroring is never gated by disarm or by a principal's tier (X15); the level is read per event, so a raise is forward-only (E25), and `stop()` drains the pacer. | The interactive ATTACH path is confined to the bound workspace's folders, not `allowedRoots` (11.4) — attaching to the wrong session is not something a later `scope_violation` refusal repairs.
+ | `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; `assignSessionRef` → `ctx.assignSessionRef` (trusted-gated: `false` = untrusted host, see D5). Keeps every orchestration path testable with a fake host. |
+ | `dispatch.ts` | `dispatchToSession(seam, ctx, req)` — the ONE path that drives a session (task 3.14 / X11). Takes the chokepoint's `Grant` as a REQUIRED argument (`DispatchContext.grant`), so a new call site cannot skip authorization and compile; the runtime guard backs it up — with `teamControlled: true` and no valid grant it returns `{ok:false, reason:"missing_grant"}` and touches no session even when the type is bypassed with `as any`. `ok:false, reason:"unreachable"` is the separate no-bridge case (X1). |
| `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. |
+ | `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 (`SpawnMeta`) carries `{channelKey, channelId, threadId, parentChannelId, cwd, by}` so resolution rebinds the EXACT identity (a thread spawn lands on the thread key) AND persists the thread's parent, so authorization and the mirror lane can still resolve the channel the operator actually bound; `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. |