Skip to content

feat: subscribe to existing Slack channels (one session per thread)#16

Open
spacedragon wants to merge 24 commits into
Open-ACP:mainfrom
sentioxyz:feat/channel-subscription
Open

feat: subscribe to existing Slack channels (one session per thread)#16
spacedragon wants to merge 24 commits into
Open-ACP:mainfrom
sentioxyz:feat/channel-subscription

Conversation

@spacedragon

Copy link
Copy Markdown

Summary

Lets the Slack adapter subscribe to pre-existing Slack channels and run an AI agent against their messages — entirely within the adapter, no OpenACP core changes.

Model: one session per thread.

  • An @mention (or, per-channel config, any top-level message) starts a new session bound to that message's thread; the agent works and replies inside that thread.
  • A reply in an existing bot thread continues that session (Claude --resume, full context) — including after a restart.
  • Tool-permission requests appear as buttons in the thread (built-in PermissionGate); a click resumes the agent. Free-text human replies in the thread continue the turn. No new HITL code.

Config (backward-compatible, defaults to []):

"subscribedChannels": [{ "channelId": "C0123ABCD", "trigger": "mention" }]

trigger: "mention" (default) or "all". Invite the bot to each channel.

How it works

  • New pure module src/subscription-router.ts: classifySubscription (ignore / start / continue) + resolveThreadSession (binds a thread to a session, reusing the existing handleNewSession({ createThread: false }) primitive — no new Slack channel is created).
  • event-router.ts runs the classifier before the unchanged legacy routing (owned-channel / DM / notification paths are byte-for-byte preserved).
  • Output is threaded: all session-scoped posts add thread_ts when the session is subscription-bound; notification-channel posts are not threaded.

Safety

  • Never archives a subscribed channel (deleteSessionThread + /openacp-archive guard on meta.threadTs).
  • Per-thread permission cleanup so sibling threads sharing a channel aren't cross-wiped.
  • Loop prevention: the bot's own messages are filtered before any routing.
  • Restart-safe resume: thread→session detection consults the persisted store (getRecordByThread), not just live sessions.

Test Plan

  • npm test — 139 passed, 1 skipped (new: subscription-router.test.ts 13 tests, plus event-router / text-buffer / activity-tracker / permission-handler additions).
  • Manual: invite the bot to a test channel; @OpenACP ... at top level → a thread opens and the agent replies in-thread.
  • Reply in the thread → agent continues with context.
  • Trigger a tool needing permission → buttons appear in-thread; Allow → agent continues.
  • Confirm the channel is never archived and sibling threads are unaffected.

Notes

  • Design spec and implementation plan included under docs/superpowers/.
  • A follow-up worth a separate ticket: a persisted record in error/cancelled state can dead-end a subscription thread (resume returns the "use /new" message, which isn't the subscription entry point). Pre-existing resume-design behavior, surfaced here.

🤖 Generated with Claude Code

spacedragon and others added 24 commits June 3, 2026 15:16
Spec for subscribing the Slack adapter to pre-existing channels with a
one-session-per-thread model and built-in human-in-the-loop, implemented
entirely within the adapter (no OpenACP core changes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…anup per thread

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- resolveThreadSession no longer passes userId into the agentName slot (broke spawn)
- detect resumable threads via persisted getRecordByThread, not live-only getSessionByThread
- thread /command responses in subscription threads

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
OpenACP removed `InstallContext.legacyConfig` in the 2026-04-05
config-legacy-removal. The install() hook still destructured and read
it, so the plugin failed to build against current @openacp/cli
(>=2026.415) with TS2339: Property 'legacyConfig' does not exist on
type 'InstallContext'.

The legacy-config migration path is dead code now (OpenACP no longer
surfaces legacy config to plugins), so remove it. Fresh installs go
straight to the interactive setup wizard, unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
setupSlack() always ran the interactive clack wizard, which cancels on
non-TTY stdin (CI, scripted `npm run setup`, automation). When
SLACK_BOT_TOKEN, SLACK_APP_TOKEN and SLACK_SIGNING_SECRET are all present
in the environment, persist them via settings.setAll() and return early,
skipping the wizard. Optional SLACK_ALLOWED_USER_IDS, SLACK_CHANNEL_PREFIX
and SLACK_NOTIFICATION_CHANNEL_ID are honored too. The interactive flow is
unchanged when the env vars are absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a `mentionAnyChannel` setting. When true, classifySubscription synthesizes
a mention-only subscription for any channel not explicitly listed in
subscribedChannels, so the bot responds to @mentions in every channel it has
been invited to without requiring a channel ID. Explicit subscribedChannels
entries still take precedence (e.g. for trigger:"all").

- types.ts: add mentionAnyChannel boolean (default false) to the settings schema
- subscription-router.ts: SubscriptionContext.mentionAnyChannel (optional);
  synthesize {channelId, trigger:"mention"} when set and channel not listed
- event-router.ts: pass config.mentionAnyChannel into the SubscriptionContext

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Change DM behavior to match subscribed channels: a top-level DM is now
treated as a trigger:"all" subscription, so each top-level DM starts its
own threaded session and the bot replies in that thread (instead of one
persistent session replying inline at the DM top level).

- classifySubscription: DM channels (D…) resolve to trigger:"all", gated
  by respondToDms; thread replies continue a known session.
- Remove the bespoke DM path: resolveDmSession, handleDmSession,
  onDmSession callback, _dmResolveQueue, and the legacy DM block.
- renameSessionThread: skip thread-bound sessions — the channel is shared
  and Slack rejects conversations.rename on a DM (not_authorized). Fixes a
  spurious warning observed in the live test.

Build clean; 149 passed / 1 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant