Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
fcce8c1
chore(slack-adapter): bump chat SDK to 4.29 + add slack-plan
May 25, 2026
dc03fc0
feat(slack-adapter): phase 1 — multi-transport refactor
May 25, 2026
cccf240
fix(slack-adapter): phase 1 review fixes
May 25, 2026
adf539e
feat(slack-adapter): phase 2 — Slack TransportAdapter (socket mode)
May 25, 2026
eb0211f
fix(slack-adapter): phase 2 review fixes
May 25, 2026
141d094
feat(slack-adapter): phase 3 — roundhouse setup --slack
May 25, 2026
158ebce
fix(slack-adapter): phase 3 review fixes
May 25, 2026
4b9e4ae
feat(slack-adapter): phase 4 — additional test coverage + setup polish
May 25, 2026
56106fc
fix(slack-adapter): phase 4 review fixes
May 25, 2026
9f636fb
docs(slack-adapter): phase 5 — README, architecture, CLAUDE, CHANGELOG
May 25, 2026
09b4344
fix(slack-adapter): phase 5 review fixes
May 25, 2026
afc5a82
fix(slack-adapter): remove sensitive test tokens, use placeholders
May 25, 2026
ab90922
fix(slack-adapter): per-transport botUsername + pin SDK dependency
royosherove May 26, 2026
5e52f61
fix(slack-adapter): pre-turn botUsername matching + type-safe overrid…
royosherove May 26, 2026
129e413
fix(slack-adapter): preserve env keys + support mixed notify ID types
royosherove May 26, 2026
fcbed99
fix(slack-adapter): route pairing dispatch to owning transport only
royosherove May 26, 2026
6063396
fix(adapters): add defensive ownsThread checks to handlePairing (all …
royosherove May 26, 2026
6e5a7c3
fix(pr#151): address Codex CLI review findings (2 issues)
royosherove May 26, 2026
733c1c6
fix(pr#151): cleanup dead matcher code + align setup.ts with setup/st…
royosherove May 26, 2026
8f59f55
fix(pr#151): remove stale _botUsername global (residual dead code)
royosherove May 26, 2026
309c7fe
fix(pr#151): restore buildMatchers + fix ownsThread tests
royosherove May 26, 2026
79c6465
fix(pr#151): preserve existing BOT_USERNAME in Slack setup
royosherove May 26, 2026
8d14020
fix(pr#151): merge ALLOWED_USERS instead of replacing on Slack setup
royosherove May 26, 2026
62931ea
fix(pr#151): unquote ALLOWED_USERS before merge and split
royosherove May 26, 2026
5d032de
fix(pr#151): derive per-transport boot session id
royosherove May 26, 2026
7402a30
fix(pr#151): preserve psst vault values + align Telegram boot session
royosherove May 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ Thumbs.db
# Pi sessions (persisted locally, not in repo)
gateway-sessions/
.codex

# Claude Code local state (cron lock, transcripts, etc.)
.claude/
73 changes: 73 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,79 @@

All notable changes to `@inceptionstack/roundhouse` are documented here.

## [0.6.0] — 2026-05-26

> **Migration note for out-of-tree TransportAdapter implementations.** The
> interface gained five new methods (`ownsChatId`, `encodeParentThreadId`,
> `formatNotifySession`, optional `shouldIgnoreMessage`, `stream`) and three
> existing methods changed shape: `enrichPrompt(thread, text)` now takes
> `thread`; `createThread(string | number)` accepts string ids; `notify`
> accepts `(string | number)[]`; `registerCommands()` no longer takes a
> token (each adapter self-sources its env-var creds). External adapters
> need to add the new methods and update those signatures. Adapters that
> only ship `TelegramAdapter` / `SlackAdapter` (the included ones) need no
> action — gateway config files keep working.

### Added
- **Slack transport** (socket mode, single workspace). `roundhouse setup
--slack` walks you through it: prints the bundled Slack app manifest
(`src/transports/slack/manifest.yaml`), validates `xoxb-`/`xapp-`
tokens via `auth.test`, writes `~/.roundhouse/slack-pairing.json`
with `status="pending"`, and the gateway completes pairing on the
first `message.im` or `assistant_thread_started` event from an
allowed user.
- **Multi-transport composition.** Telegram and Slack can run in the
same gateway instance. New `CompositeTransportAdapter`
(`src/transports/composite.ts`) routes per-thread methods by
`ownsThread`, partitions `notify(chatIds, …)` by `ownsChatId`, and
tracks `pairingComplete` per-transport so paired-then-pair-other
flows work cleanly.
- **Card-model menus.** `RichMenu` now maps to the Chat SDK's
transport-agnostic `CardElement`. The Slack adapter renders to Block
Kit; Telegram renders to inline keyboards. Single converter
(`richMenuToCard` in `src/transports/rich-helpers.ts`) replaces what
would have been per-platform Block Kit translators.
- **Slack streaming** with post-then-edit fallback (rate-limit-aware
throttling, abort-signal honoring, init-failure backoff with hard
cap and final flush). Native AI Assistant streaming deferred to v2.
- **`@chat-adapter/slack@^4.29.0`** added to dependencies.

### Changed
- **Chat SDK bump** to `^4.29.0` for `chat`, `@chat-adapter/telegram`,
and `@chat-adapter/state-memory`. Backwards-compatible per the SDK's
release notes; no API impact on roundhouse code.
- **`TransportAdapter` interface widened**: new methods `ownsChatId`,
`encodeParentThreadId`, `formatNotifySession`, `shouldIgnoreMessage`
(optional), `stream`. Signature changes: `enrichPrompt(thread, text)`,
`createThread(string | number)`, `notify((string | number)[])`,
`registerCommands()` (adapter self-sources its env-var creds — no
more passing tokens through the gateway).
- **`allowedUserIds` and `notifyChatIds`** widened to
`(string | number)[]` to support Slack's `Uxxx`/`Cxxx` identifiers
alongside Telegram's numerics. `isAllowed` now does dual-lookup
against the heterogeneous union; both forms match against entries
of either type.
- **`ChatThread.post`** widened to `string | { markdown: string } | { card: unknown; fallbackText?: string }`.
- **`gateway/streaming.ts`** dispatches per-turn streaming via
`transport.stream(thread, iter, signal)` — no more hardcoded
`isTelegramThread` branch.
- **Telegram `/start <nonce>` filter** moved from the gateway into
`TelegramAdapter.shouldIgnoreMessage` so it can't accidentally drop
Slack messages.
- **`fireBootTurn`** partitions `notifyChatIds` by transport and fires
one boot turn per transport that owns at least one configured chat
id (was a single global `chatIds[0]` which silently favored
whichever transport happened to be listed first).

### Fixed
- **Number()-coercion sweep.** Several call sites silently dropped
Slack-shaped chat ids by coercing through `Number()` or matching
against `/^-?\d+$/`. Audited and fixed in `gateway.ts:113`,
`gateway.ts:329-333` (cron notifyFn), `gateway.ts:352` (sub-agent
routing), `gateway.ts:978-1001` (notifyStartup session label),
`cli/subagent-command.ts:30-38` (parent thread id encoding), and
`ipc/handler.ts:17-30` (notify session sentinel).

## [0.5.41] — 2026-05-20

### Fixed
Expand Down
25 changes: 22 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,14 @@ Roundhouse is a chat-gateway between chat platforms (via Vercel Chat SDK) and a

**One gateway = one agent target.** Selection happens at startup from `config.agent.type` via `src/agents/registry.ts`. The `AgentRouter` seam (`src/router.ts`) currently always returns that one agent (`SingleAgentRouter`), but the interface is there so per-thread/multi-agent routing can be added without touching `gateway.ts` or the adapters.

**Streaming path vs. fallback path.** `Gateway.handleStreaming` (src/gateway.ts) prefers `agent.promptStream()` if the adapter implements it. It turns pi's event stream into Chat SDK calls:
**Streaming path vs. fallback path.** `Gateway.handleStreaming` (`src/gateway/gateway.ts`) prefers `agent.promptStream()` if the adapter implements it. It turns pi's event stream into Chat SDK calls:
- `text_delta` → buffered into a per-turn `AsyncIterable<string>` passed to `thread.handleStream()` (which does post+edit with rate limiting)
- `tool_start` → a compact status message via `thread.post()` (separate bubble)
- `custom_message` → flush the current streaming turn, post as its own bubble (used by pi extensions like pi-lgtm code review)
- `turn_end` / `agent_end` → flush so the next turn starts a fresh message
If `promptStream` is missing, `agent.prompt()` is called and the full text is split/posted via `postWithFallback` (markdown-first, plaintext fallback).

**Pi adapter nuance — private event queue drain.** `src/agents/pi.ts` awaits `session._agentEventQueue` (a private field of `@earendil-works/pi-coding-agent`'s `AgentSession`) after every `prompt()` / `agent.continue()`. Without this drain, extension `agent_end` handlers (e.g. pi-lgtm's review) haven't finished and their `followUp` messages or `custom_message` events race against the `unsubscribe()` in the `finally` block, causing lost review bubbles. The field access is wrapped in `if (queue)` — if upstream renames it, behavior silently reverts to the pre-fix race. See the big comment in `drainSessionEvents()` for context before touching it.
**Pi adapter nuance — private event queue drain.** `src/agents/pi/pi-adapter.ts` awaits `session._agentEventQueue` (a private field of `@earendil-works/pi-coding-agent`'s `AgentSession`) after every `prompt()` / `agent.continue()`. Without this drain, extension `agent_end` handlers (e.g. pi-lgtm's review) haven't finished and their `followUp` messages or `custom_message` events race against the `unsubscribe()` in the `finally` block, causing lost review bubbles. The field access is wrapped in `if (queue)` — if upstream renames it, behavior silently reverts to the pre-fix race. See the big comment in `drainSessionEvents()` for context before touching it.

**Pi adapter nuance — subscribers must outlive extension-triggered runs.** `runPromptAndFollowUps` loops on *both* `session.isStreaming` (awaits `agent.waitForIdle()`) and `agent.hasQueuedMessages()` (awaits `agent.continue()`). When an extension calls `pi.sendMessage(..., { triggerTurn: true, deliverAs: "followUp" })` inside its `agent_end` handler, pi's `sendCustomMessage` picks one of two branches depending on `isStreaming` at that moment: if still streaming it queues via `agent.followUp()` (caught by `hasQueuedMessages`); if already idle it bypasses the queue and calls `agent.prompt(appMessage)` directly as fire-and-forget, kicking off a new run that is *only* visible via `isStreaming`. The pi CLI is immune because its subscriber stays attached across runs; our per-prompt subscriber must loop on both conditions or we unsubscribe mid-run and Telegram sees the review bubble followed by silence. Don't collapse the loop to a single condition.

Expand All @@ -44,7 +44,26 @@ If `promptStream` is missing, `agent.prompt()` is called and the full text is sp

**Adding a new agent backend:** implement `AgentAdapter` from `src/types.ts` in `src/agents/<name>.ts`, register in `src/agents/registry.ts`, set `"agent": { "type": "<name>" }` in config. If you want streaming, implement `promptStream` yielding `AgentStreamEvent`s; otherwise `prompt` alone is enough and the gateway will fall back.

**Adding a new chat platform:** install the `@chat-adapter/<platform>` package, lazy-import it in `buildChatAdapters` inside `src/gateway.ts`, and add a corresponding entry to `GatewayConfig["chat"]["adapters"]` in `src/types.ts`. The unified `handle()` in `gateway.ts` already covers all platforms.
**Adding a new chat platform:** install the `@chat-adapter/<platform>` package, register it in `chatAdapterFactories` (`src/transports/chat-adapters.ts`), implement a `TransportAdapter` (`src/transports/<name>/<name>-adapter.ts`), add it to `buildTransportDelegates` in `src/gateway/gateway.ts`, and add a corresponding entry to `GatewayConfig["chat"]["adapters"]` in `src/types.ts`. The composite transport routes all per-thread methods automatically by `ownsThread`/`ownsChatId`.

**Multi-transport composition.** A single gateway runs all configured transports simultaneously through `CompositeTransportAdapter` (`src/transports/composite.ts`). The composite owns a `delegates: TransportAdapter[]` and routes:
- per-thread methods (`postMessage`, `postRich`, `progress`, `stream`, `enrichPrompt`) by the first delegate where `ownsThread(thread)` is true;
- `notify(chatIds, ...)` by partitioning chat ids by `ownsChatId` then fanning out;
- `handlePairing` to the first delegate that returns non-null, decorated with `transport: <delegateName>` so the gateway tracks `pairingComplete` per-transport (a `Map<string, boolean>` — a single boolean would silently block the second transport's pairing once the first paired);
- `registerCommands` and `dispose` to all delegates.
The gateway never branches on platform; everything reads `this.transport.foo()` against the composite.

**Slack adapter nuances.**
- Thread ids are `slack:CHANNEL:THREAD_TS`. Always use `sdk.encodeThreadId({ channel, threadTs })` and `sdk.decodeThreadId(id)` — never split manually. Top-level posts use `threadTs: ""` (sentinel); the `progress.ts` and `streaming.ts` helpers check for `"" | "main"` and elide `thread_ts` when posting.
- `AdapterPostableMessage` is `string | { raw } | { markdown } | { ast } | { card } | CardElement` (`chat@4.29.0` `chat-D9UYaaNO.d.ts:1549`). **There is no `blocks` field.** Menus go through `{ card, fallbackText }` and the SDK's `cardToBlockKit` does the conversion internally. Same model works for telegram via `extractCard`. The transport-agnostic `richMenuToCard` lives in `src/transports/rich-helpers.ts` and is shared.
- **Streaming + Block Kit can't coexist.** Slack's stream API doesn't take blocks. Decision: streaming turns are agent text only; menu turns are command results that don't stream. If a future feature needs both, finalize the stream first then post a separate menu message.
- **`SlackAdapter.attach(slackSdk)` lifecycle.** Must be called *after* `chat.initialize()` because the Chat SDK Slack adapter populates `_botUserId` and starts socket-mode during initialize. The gateway does this for you (`gateway.ts` post-`chat.initialize()` block); don't call `attach()` earlier or webClient calls will throw `AuthenticationError`.
- **Bot self-loop filtering.** Slack delivers the bot's own messages back through `message.channels` / `message.groups` if those scopes are enabled. The Chat SDK does central isMe filtering; verified that the SDK's `initialize()` already calls `auth.test` and populates `_botUserId` before subscriptions activate (`@chat-adapter/slack@4.29.0` `index.js:868-885`), so the filter is armed by the time events flow.
- **Pairing chicken-and-egg.** Slack's `message.im` only fires for *existing* DM channels. To support users who haven't DM'd the bot yet, the gateway also registers `bot.onAssistantThreadStarted` and synthesizes an IncomingMessage from the event (resolving the user via `slackSdk.getUser(userId)` so the allowlist's userName check has a value to match). Both paths flow through the same `transport.handlePairing` → composite seam.
- **Per-transport boot turn.** `fireBootTurn` partitions `notifyChatIds` by `ownsChatId` and fires one boot turn against the first chatId owned by each transport — not the global `chatIds[0]`. Otherwise multi-transport installs would silently favor whichever transport happened to be listed first.
- `createSlackAdapter` env-var fallback only fires when called with NO config (`zeroConfig = !config`). The factory in `src/transports/chat-adapters.ts` therefore explicitly forwards `process.env.SLACK_BOT_TOKEN` / `SLACK_APP_TOKEN` / `SLACK_SIGNING_SECRET` so they're populated regardless of whether other config keys are set. Verified against `dist/index.js:4233-4243`. Skipping this is a silent `AuthenticationError: No bot token available` at runtime.

**Type widening for multi-transport.** `allowedUserIds` and `notifyChatIds` are `(string | number)[]` (Telegram numeric, Slack string). `ChatThread.post` accepts `string | { markdown } | { card, fallbackText? }`. `IncomingMessage.chatId` is `string | number`. `isAllowed` does dual lookup against the heterogeneous union by normalizing both sides to `String()`. Several legacy `Number()` coercion sites were caught in Phase 1 (`gateway.ts:113, 329, 352, 978-1001, 1017-1021`, `subagent-command.ts`, `ipc/handler.ts`) — preserve string IDs end-to-end; don't reintroduce `parseInt` or `Number()` casts.

## Debugging

Expand Down
Loading
Loading