Skip to content

feat(dashboard): Telegram & Discord channel toggles with install-state UX - #161

Merged
aterrylu merged 3 commits into
mainfrom
terry/channel-mvp
Apr 24, 2026
Merged

feat(dashboard): Telegram & Discord channel toggles with install-state UX#161
aterrylu merged 3 commits into
mainfrom
terry/channel-mvp

Conversation

@aterrylu

Copy link
Copy Markdown
Owner

Summary

Extends the dashboard Settings → Channels section from a single hardcoded server:autonomos toggle into a dynamic, installation-aware list that supports Telegram and Discord plugin channels (and any future channel plugin). Implements Option B from docs/research/channel-integration.md — lean on Claude Code's native channel protocol, no new transport code.

Empirically validated before building (claude -p --channels plugin:telegram@... plugin:discord@... --dangerously-load-development-channels server:autonomos → all three load; --channels totally-malformed → hard error at startup; --channels plugin:nonexistent@... → silent no-op). These behaviors directly shape the UX choices below.

Problem

AVAILABLE_CHANNELS in SettingsStatusBarItem.tsx was hardcoded to a single entry — server:autonomos. Users could not enable the plugin:telegram@claude-plugins-official or plugin:discord@claude-plugins-official channels from the dashboard, even though the provider's flag-splitting logic (packages/server/src/providers/claude-code.ts:136-146) was already wired for them and settings persistence was already channel-aware. The "90% built" claim in the research doc was accurate — the 10% gap was UI + a save-time guard.

A further problem surfaced during review: CC validates --channels tag syntax at spawn, so a typo saved to ~/.autonomos/settings.json would crash every subsequent session spawn. Silent no-ops on uninstalled plugin identifiers would tell the user "I toggled Telegram on, why isn't my bot responding?" without any UI feedback.

Solution (high level)

Three-state model per channel — ok / disabled / not-installed / unknown — derived from claude plugin list --json output. The dashboard renders this state into the toggle; the PUT handler enforces it.

```mermaid
flowchart TD
A[User clicks channel toggle] --> B{Plugin status}
B -->|ok| C[Toggle flips
pendingChannels updated]
B -->|not-installed
or disabled| D[Toggle locked
tooltip shows fix command]
C --> E[User clicks Save]
E --> F[PUT /api/settings]
F --> G{isValidChannelId
tag syntax check}
G -->|fail| H[400: Invalid identifier]
G -->|pass| I[readInstalledPlugins]
I --> J{channelStatus for
each requested id}
J -->|not-installed
or disabled| K[400: Refusing to save
would silently no-op]
J -->|ok or unknown
fail-open| L[writeFileSync settings.json]
L --> M[200: settings saved]
M --> N[Next session spawn
reads settings fresh
gets --channels flag]
```

What's in scope

New code:

  • `packages/server/src/channels.ts` — pure validator (`CHANNEL_ID_RE`, `isValidChannelId`) + tri-state `channelStatus()` function. Unit-testable without subprocesses.
  • `packages/server/src/routes/channels.ts` — `GET /api/channels/status` endpoint. Shells `claude plugin list --json` with 30s TTL cache + in-flight request deduplication. Rich error logging on failure. Exports `readInstalledPlugins()` for reuse by the settings guard.
  • `docs/setup/channels.md` — end-to-end bot-setup walkthrough.
  • Three new test files locking in the contract.

Modified code:

  • `packages/dashboard/src/plugins/settings/SettingsStatusBarItem.tsx` — dynamic channel list from the new endpoint; `ChannelToggle` extended with `status` + `fix` props; lock behavior + status label always visible; post-save-error re-fetch.
  • `packages/dashboard/src/components/Codicon.tsx` — added `send` + `lock` icons.
  • `packages/server/src/routes/settings.ts` — new guard block in the channels branch: tag-syntax validation → `readInstalledPlugins()` → `channelStatus()` per-id → 400 for `not-installed` or `disabled` entries. Fail-open on `unknown`.
  • `packages/server/src/settings.ts` — sanitize `channels` on read (filter non-strings + malformed tags with warning). Also switched file path to lazy `getConfigDir()` so tests can override the config directory.
  • `packages/server/src/index.ts` — mount `/api/channels` route.
  • `README.md` — link to new setup doc.

Empirical validation that shaped the design

Command Result How it shaped the UX
`--channels plugin:telegram@... plugin:discord@...` ✅ Both loaded Multi-channel works — the UI supports N channels, not one
`--channels totally-malformed` ❌ Hard error at spawn Must validate tag syntax client+server before save
`--channels plugin:nonexistent@...` Silent pass, plugin not loaded Must detect installed set and block save
`--dangerously-load-development-channels server:autonomos --channels plugin:telegram@...` ✅ All loaded Both flag types in one spawn; provider's split-by-prefix is correct

Testing

308 tests pass (307 + 1 new regression for fail-open behavior). `biome` + `tsc` clean.

Automated coverage

  • `channels.test.ts` — table-driven tests for `isValidChannelId` (valid + invalid tag syntaxes) and `channelStatus` (ok / disabled / not-installed / unknown + server: short-circuit).
  • `provider-channels.test.ts` — regression test proving the provider emits the correct args for the exact triple-channel case (`server:autonomos` + Telegram + Discord). Directly guards against the thing the research doc promised would work.
  • `channels-api.test.ts` — API round-trip tests for `GET /api/channels/status` (all 3 states + fix commands) and `PUT /api/settings` channel guard (malformed rejection, not-installed rejection, disabled rejection, server:autonomos acceptance, mixed acceptance, empty array acceptance, and the fail-open-on-unknown regression test).

Test plan (manual — for Terry)

The Telegram/Discord pairing loop requires a real bot token + chat, so Terry will QA these before merge:

  • With `telegram@claude-plugins-official` installed: toggle on in dashboard → save → spawn new session → verify `--channels plugin:telegram@claude-plugins-official` in args
  • DM the bot → pairing code appears in session terminal → `/telegram:access pair ` → subsequent DMs reach the agent as `` events
  • Restart `autonomos-server` → resumed sessions still receive Telegram events (resume continuity)
  • Uninstall plugin → toggle greys out with "Not installed" + exact install command in tooltip
  • Disable plugin (`claude plugin disable telegram@claude-plugins-official`) → toggle greys out with "Disabled" + enable command
  • Discord parity (trusted by symmetry — both are plugin identifiers through the same code path)

Risks

  1. CC `--channels` is a research preview. Anthropic notes syntax may change. The entire flag plumbing is isolated to one function (`providers/claude-code.ts:136-146`) so a migration is a one-line change.
  2. `channelsEnabled` managed setting may be set to `false` on Team/Enterprise plans — admins must flip it. Documented in `docs/setup/channels.md` troubleshooting.
  3. Pre-existing risk surfaced by review but OUT OF SCOPE here: `settings.ts:106` uses non-atomic `writeFileSync` — a crash / disk-full between open and flush can corrupt `~/.autonomos/settings.json` and silently reset all credentials on next read. Flagged during silent-failure review; recommend a separate PR (atomic write via temp file + `renameSync`) rather than bundling durability fixes into a feature PR.

Out of scope (deferred per research Phase 2/3)

  • Proactive outbound via gateway adapters (`telegram://chat_id` URIs)
  • Per-template channel allowlists
  • Schedule-level `delivery.channel` routing
  • Permission relay surfaced in dashboard observability

Follow-ups worth filing

  • Atomic `settings.json` writes (addresses the pre-existing durability risk)
  • Test for "pre-existing malformed channels value in settings.json gets sanitized on read" (not in today's regression set)

🤖 Generated with Claude Code

…e UX

Extends the channel settings panel from the single hardcoded
`server:autonomos` entry to a dynamic list fed by `claude plugin list
--json`. Users can toggle Telegram and Discord channels on from the
dashboard; a toggle is visibly locked when the underlying plugin is
uninstalled or disabled, with an in-place tooltip showing the exact
fix command.

Server side, a new `GET /api/channels/status` endpoint derives a tri-state
per channel (ok / disabled / not-installed / unknown-when-detection-fails)
backed by a 30s TTL cache with in-flight request deduplication. The
`PUT /api/settings` handler now refuses to save channel entries that are
malformed by CC's own tag syntax or that would silently no-op at spawn
(not-installed or explicitly disabled plugins). Detection failure is
tolerated (fail-open) so a flaky `claude plugin list` doesn't block
legitimate saves.

`server:*` channels short-circuit to "ok" in the status function — they
are autonomOS-owned MCP subprocesses, not plugins that `claude plugin
list` would ever report.

`settings.ts` now sanitizes `channels` on read (filters non-strings and
malformed tags with a warning) so bad values written out-of-band don't
silently re-persist through `updateSettings()`'s merge, and so non-string
entries can't crash the spawn path's `.startsWith()` filter.

Refs docs/research/channel-integration.md (Option B).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aterrylu
aterrylu marked this pull request as ready for review April 23, 2026 09:53

@nox-0x nox-0x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Clean implementation — fail-open design is correct, the subprocess dedup + 30s cache prevents runaway processes, and the client-side lock UX prevents config drift. Sanitization on settings read is a good defensive touch.

`/plugin install <id>` is the universal fix — it installs fresh if
missing and re-enables disabled plugins in the same step. Using one
command across both states avoids the `enable` vs `install` confusion
(Terry tripped on this during first-run QA).

- Tooltip + server fix field now always returns `/plugin install X`
- Docs updated to use slash-command form consistently
- Test for disabled-state fix updated

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@@ -568,7 +638,17 @@ export function SettingsPanel({
body: JSON.stringify(body),
});
if (!res.ok) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning

Problem: When the server rejects a channel save with a validation error (e.g. plugin not installed), calls and , but stays in the user-toggled (rejected) state. The toggle UI continues to show the channel as pending-ON while the error banner says "Refusing to save channels that would silently no-op" — the toggles don'''t reflect the failure.

Why it matters: The error message is misleading: the banner says the save was refused, but the toggle still shows the channel as enabled. If the user dismisses the error and re-opens settings, is immediately overwritten by the fresh load, so it'''s a one-session desync — but the UX is confusing in that window.

Suggested fix:
"Failed to save (HTTP ${res.status})"

@nox-0x nox-0x left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good PR overall. The channel install-state UX is well-designed — three-state model, lock UX, fail-open on unknown, and server-side validation before persisting are all correct. Left one inline comment about a minor pendingChannels state-desync on save failure.

Telegram and Discord plugins each enforce a single-poller lock (per
~/.claude/plugins/cache/claude-plugins-official/telegram/0.0.6/server.ts
lines 56-69: PID file + SIGTERM eviction). With autonomOS resuming ~10
sessions in parallel at startup, every plugin:* channel would cause
those sessions to race for the lock, producing nondeterministic
"whichever-won-last" routing for inbound DMs.

This change introduces `settings.inboxAgent` (default "Dispatcher")
and filters plugin:* channels at buildArgs() time:

- The session whose agentName matches inboxAgent → gets --channels
  plugin:* AND --dangerously-load-development-channels server:*
- All other sessions → get --dangerously-load-development-channels
  server:* only, keeping the autonomOS gateway available for
  inter-agent communication

Dashboard settings panel gets an "Inbox Agent" text field below the
Channels section, populated with the server-side default.

Two new regression tests cover:
  - plugin channels withheld from non-inbox agents
  - custom inboxAgent setting honored (non-default agent names work)

Updated docs/setup/channels.md with a new "Inbox agent" subsection
explaining the mechanism and which agent gets which flags.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@aterrylu
aterrylu merged commit 2166450 into main Apr 24, 2026
1 check passed
@aterrylu
aterrylu deleted the terry/channel-mvp branch April 24, 2026 09:26
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.

2 participants