qwen-code/docs/developers/daemon/15-channel-adapters.md
OrbitZore 62e009a952
feat(channels): GitHub polling adapter with notification-as-wakeup architecture (#7632)
* feat(channels): add GitHub polling adapter with notification-as-wakeup architecture

Introduce a GitHub channel adapter that monitors notifications and
responds to @mentions on issues/PRs by posting comments. Uses
last_read_at as a per-thread watermark for comment enumeration,
replacing the unreliable latest_comment_url approach.

Foundation changes to ChannelBase:
- sendThreadMessage for thread-targeted delivery (IM adapters unchanged)
- Envelope.metadata appended to prompt after command parsing
- chat_thread session scope (channel:chatId:threadId) prevents
  cross-repo session collision
- polling-helpers: testBotMention/stripBotMention (separate detection
  from stripping, no whitespace collapsing), cursor persistence,
  abortableSleep

GitHub adapter design:
- Notifications as wake-up signals only (unread filtering)
- listComments enumeration with last_read_at watermark
- Bot self-comment filtering, case-insensitive mention regex
- In-memory recentlyProcessed set for mark-read failure dedup
- First-contact: new issue body @bot triggers processing
- Error comment + cursor advance on handleInbound failure
- pollInterval minimum 60s, exponential backoff 2s-30s

* refactor(channels): extract PollingChannelBase from polling-helpers

Replace the loose polling-helpers module with a PollingChannelBase<Cursor>
abstract class that encapsulates the poll loop, cursor persistence (JSON,
atomic write), exponential backoff, and start/stop lifecycle. Subclasses
implement only pollOnce() and createInitialCursor().

- Delete polling-helpers.ts (cursor fns + abortableSleep moved into base)
- Move mention utilities (testBotMention/stripBotMention) to github pkg
- GithubAdapter now extends PollingChannelBase<{ lastProcessedAt }>

* fix(channels): remove Gitea/GitLab mention from sendThreadMessage JSDoc

* fix(channels): match /pulls/N in notification subject URL

GitHub PR notifications use /repos/{owner}/{repo}/pulls/{N} in
subject.url, not /issues/{N}. The regex only matched /issues/,
causing PR notifications to be skipped and marked read.

Also sets threadId to 'pr:N' for PRs (was always 'issue:N').

* test(channels): add PR body first-contact unit test

Verify that PR notifications with @mention in the body (not a comment)
correctly trigger the first-contact path: extractFromSubjectUrl matches
/pulls/N, listComments returns empty, tryFirstContactBody fetches the
PR body and dispatches to handleInbound with threadId 'pr:N'.

* feat(channels): read pollInterval from channel config in PollingChannelBase

Move pollInterval config reading from GithubAdapter to the base class.
The user's configured pollInterval in settings.json is now respected
directly without a minimum enforcement. Defaults to 60000ms when not
configured.

* fix(channels): prepend metadata before prompt text

Agent sees issue/PR context (type, title, URL) before the user's
request, improving comprehension. Metadata is still appended after
slash-command parsing so commands are not affected.

* refactor(channels): route all ChannelBase delivery through sendThreadMessage

Replace all internal sendMessage calls with sendThreadMessage, passing
envelope.threadId (or target.threadId / undefined) so polling adapters
can deliver to the correct thread. IM adapters are unaffected — the
default sendThreadMessage falls through to sendMessage.

* docs(channels): document sendThreadMessage delivery architecture

* fix(channels): address review findings

- Cap recentlyProcessed Set at 10k entries to prevent unbounded growth
- Validate cursor JSON shape (non-null object) in loadCursorFromDisk
- sendThreadMessage falls through to sendMessage when threadId is
  undefined instead of silently dropping
- Remove duplicate pollInterval from GithubConfig (now in ChannelConfig)
- Fix chat_thread routing key trailing colon when threadId is undefined

* docs(channels): fix metadata JSDoc — prepended, not appended

* fix(channels): use recentlyProcessed dedup for first-contact body

Replace the fragile createdAt-vs-cursor check in tryFirstContactBody
with the recentlyProcessed set. The cursor advances globally based on
notification updated_at — when a different notification with a later
updated_at is processed first, the cursor can advance past the issue's
created_at, causing the first-contact check to incorrectly skip the
issue body (forget reply bug, found in E2E TC-2b).

* refactor(channels): two-layer dedup for GitHub adapter

Layer 1: global cursor filters notifications by updated_at (sorted
ascending, old first). Layer 2: server-side last_read_at filters
comments by created_at (sorted ascending).

- Delete recentlyProcessed Set (no longer needed)
- Sort notifications by updated_at ascending before processing
- Sort comments by created_at ascending before processing
- Pass latest comment created_at to markThreadAsRead as last_read_at

* fix(channels): address review findings on GitHub adapter

Blockers:
- sessionScope: add defaultSessionScope to ChannelPlugin, apply in
  parseChannelConfig so router and adapter agree on 'chat_thread'
- channel-registry.test.ts: add 'github' to expected type list

Should-fix:
- Replace per-thread markThreadAsRead (PATCH) with bulk
  markNotificationsAsRead (PUT /notifications + last_read_at).
  API errors stop the batch without marking failed notifications
  read; handleInbound errors still advance (error comment posted).
- connect() throws on bot identity failure instead of failing open
- metadata appended after promptText (inside sender attribution)
- isSharedSessionTarget includes 'chat_thread' scope

Nits:
- startPollLoop re-entrancy guard
- clean-package-build-artifacts.js includes github
- index.ts re-exports GithubChannel

* fix(channels): use max updated_at of all fetched notifications as last_read_at

Prevents re-fetching the same notifications in the next poll cycle.
The bulk PUT /notifications marks all fetched notifications as read
up to the max updated_at, regardless of per-notification success.

* fix(channels): address review round 2 findings

- #12: loadCursorFromDisk rejects arrays
- #13: pollInterval validates positive finite number
- #19: first-contact gate uses dispatchedMention flag (not newComments.length)
- #25: stripBotMention no longer trims (preserves indentation)
- #27: remove adapter-level requireMention, unify on GroupGate
- #31: add chat_thread SessionRouter routing key tests
- #33: clear metadata on collect-mode synthetic envelope
- #35: fix PollingChannelBase.test import path
- #36: add @octokit/rest to 15-channel-adapters.md dependencies

* docs(channels): document known limitations for GitHub adapter

- First start skips existing unread notifications (cursor = now)
- Requires classic PAT (fine-grained PATs lack notifications API)
- PR review comments not enumerated (issue comments only)

* fix(channels): address review round 3 findings

- #9: buildMetadata derives web URL from baseUrl (GHE support)
- #12: sendThreadMessage throws on invalid threadId format
- #19: mention lookbehind matches cc:@bot and "@bot" patterns
- #23: cursor file name uses sha256 hash to prevent collision
- #26: test verifies cursor persistence to disk
- #31: postErrorComment double-failure logs to stderr
- #45: tests use mkdtempSync isolation instead of real QWEN_HOME

* fix(channels): pass threadId through pairing flow + sendResponseMessage test

- #13+16: onPairingRequired receives envelope.threadId and passes it
  to sendThreadMessage, so pairing codes are delivered on threaded
  channels (GitHub) instead of throwing
- #6: add test verifying sendResponseMessage resolves threadId from
  router.getTarget and passes it to sendThreadMessage

* fix(channels): pass proxy to Octokit for daemon-worker environments

- #44: read this.proxy from ChannelBaseOptions and pass
  HttpsProxyAgent to Octokit request.agent, matching the
  Telegram adapter pattern

* fix(channels): address review findings — immutable senderId, comment time window, validateCursor, retry wrapper

- senderId uses immutable user.id; allowedUsers resolved to IDs at connect
- Comment filter upper bound: updated_at <= maxUpdatedAt (batch window)
- Per-notification errors use continue (best-effort), not break
- validateCursor() virtual hook for subclass cursor shape validation
- sendThreadMessage/postErrorComment wrapped in githubApi() retry
- webOrigin handles default api.github.com → github.com
- Docs: classic PAT only, markNotificationsAsRead, dedup claims removed
- Tests: threadId priority, metadata consumption, defaultSessionScope,
  QWEN_HOME isolation, persistent mock rejection

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(channels): mark notifications read before processing to prevent duplicate replies

Bot's own replies bump notification updated_at past the pre-captured
maxUpdatedAt, so markNotificationsAsRead(maxUpdatedAt) failed to mark
them read — the next poll re-fetched the same comments and replied
again.

Move markNotificationsAsRead + cursor advance before the processing
loop (best-effort delivery). This is safe because bot's own comments
do not flip notifications back to unread. Update docs to reflect the
new poll cycle order and best-effort semantics.

* fix(channels): update sender gate after allowedUser ID resolution and harden tests

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(channels): cursor-based comment window to prevent duplicate replies

PUT /notifications is async (202) with a last_read_at cutoff — the
bot's reply bumps updated_at past the cutoff before the server
processes the mark, so the notification is never marked read and gets
re-fetched on the next poll, causing duplicate replies.

Use the cursor value before advancement as an exclusive lower bound
for the comment enumeration window: (windowSince, maxUpdatedAt].
Comments already eligible in a previous poll are excluded regardless
of whether the mark succeeded. Zero new persistent state.

* fix(channels): cursor-based comment window to prevent duplicate replies

PUT /notifications is async (202) with a last_read_at cutoff — the
bot's reply bumps updated_at past the cutoff before the server
processes the mark, so the notification is never marked read and gets
re-fetched on the next poll, causing duplicate replies.

Use the cursor value before advancement as an exclusive lower bound
for the comment enumeration window, with per-notification last_read_at
as the preferred lower bound when available (server-side per-thread
watermark). Comments already eligible in a previous poll are excluded
regardless of whether the mark succeeded. Zero new persistent state.

* fix(channels): address review findings — null guard, cursor validation, metadata dedup, abortable sleep, docs

- Guard against null notification.subject.url in pollOnce
- Validate lastProcessedAt is a parseable date in validateCursor
- Add metadata: undefined to second collect-mode drain path
- Refactor abortableSleep as protected method on PollingChannelBase
- Fix docs: requireMention is nested under groups.*
- Add tests: chat_thread shared session, dispatchedBodies eviction,
  cursor enumeration window, last_read_at in mention tests

* docs(channels): sync docs with implementation — cursor shape, error handling, GitHub adapter tables, first-contact

- Design doc: update Cursor to { lastProcessedAt, dispatchedBodies? }, add
  validateCursor date check, abortableSleep protected method, break-on-error
  semantics, subject.url null guard
- Developer docs: add GitHub to adapter table and adapter matrix
- User guide: add first-contact step to How It Works, clarify mark-before-process

* fix(channels): address review round 2 — error dedup, abortable retry, backoff reset, window test

- Record dispatchedBody on first-contact handleInbound failure to prevent
  duplicate error comments when mark-read async hasn't taken effect
- Use abortableSleep instead of raw setTimeout in githubApi retry so
  disconnect() can interrupt rate-limit cooldowns
- Reset consecutiveErrors in startPollLoop so stop/restart cycles don't
  inherit stale elevated backoff
- Add test for cursor window client-side lower-bound exclusion filter

* fix(channels): address review round 3 — cursor validation, error dedup, sender gate, bot-self body

- validateCursor: normalize falsy non-array dispatchedBodies (false/0/""/null)
  to [] instead of passing them through to .includes() which throws TypeError
- Set dispatchedMention after postErrorComment to prevent first-contact from
  posting a duplicate error comment on the same thread
- Only set dispatchedMention when the sender passes the sender gate, so a
  disallowed commenter's mention no longer suppresses a valid first-contact
  body from an allowed issue author
- Skip bot-authored issue bodies in tryFirstContactBody to prevent
  self-response loops under open sender policy

* fix(channels): address review suggestions — test coverage, cursor filename, assertion precision

- Pairing flow: add threadId pass-through regression test
- pollInterval: add table-driven edge cases (0, -1, NaN, Infinity, string)
- Add null-URL notification followed by valid notification batch test
- Fix comment window test to assert paginate call 3 (listComments) not call 2
- Truncate cursor filename encoded prefix to 200 chars (filesystem 255 limit)
- Assert mark-read uses batch maxUpdatedAt, not just { read: true }
- Assert real GitHub plugin declares defaultSessionScope chat_thread
- Add invocationCallOrder assertion for mark-before-process ordering

* fix(channels): address review round 4 — allowedUsers throw on resolve failure, crash table fix, mark-read failure test

* fix(channels): address review round 5 — created_at filter, retry-after NaN guard, retry/sendThreadMessage tests, docs fixes

* fix(channels): address ci-bot review 4778587403 — reconnect idempotency, github type enumerations, retry/webOrigin tests

* chore(channels): align channel-github version to 0.21.0 after upstream merge

* chore(channels): update package-lock.json for channel-github 0.21.0

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: Shaojin Wen <shaojin.wensj@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Co-authored-by: OrbitZore <orbitzore@users.noreply.github.com>
2026-07-25 09:31:50 +00:00

18 KiB

Channel Adapters

Overview

packages/channels/ contains the IM channel adapters that turn a chat platform's incoming message into an agent prompt and send the agent response back to the chat platform. Four concrete channels ship today: DingTalk, WeChat (Weixin), Telegram, and Feishu. They share a base layer (packages/channels/base/) and an adapter-facing ChannelAgentBridge contract.

There are two current host modes:

  • qwen channel start [name] is the standalone ACP-backed channel service. It passes adapters an AcpBridge implementation of ChannelAgentBridge.
  • qwen serve --channel <name> and qwen serve --channel all are experimental daemon-managed modes. Named selections are grouped by owning workspace and qwen serve starts one out-of-process worker per owning runtime; each worker connects to the daemon through the SDK and adapters receive a DaemonChannelBridge-backed ChannelAgentBridge facade. --channel all remains a primary-only selection.

In daemon-managed mode, each channel maps inbound chat traffic to daemon sessions under a configurable SessionScope (user, thread, or single). The adapter delegates to DaemonChannelBridge, which delegates to the SDK's DaemonSessionClient (see 13-sdk-daemon-client.md). Every named channel must resolve to one registered, trusted workspace. The worker uses that runtime's canonical cwd, QWEN_DAEMON_WORKSPACE, and environment overlay; ownership resolution never falls back to primary.

Webhook-triggered channel tasks

Webhook-triggered tasks are hosted by qwen serve and executed inside the daemon-managed channel worker. The HTTP route validates the source and forwards a ChannelWebhookTask to the worker over IPC. The worker calls ChannelBase.runWebhookTask(), so adapters do not implement webhook parsing.

Adapters still participate through proactive send support: supportsProactiveSend() tells the host whether a channel can send without an inbound message, supportsProactiveTarget() handles delivery limits for specific target shapes, and pushProactive() carries the outbound content.

Responsibilities

  • Receive inbound messages from the channel's native transport (DingTalk WebSocket stream, WeChat HTTP long-poll, Telegram Bot long-poll, Feishu WebSocket or HTTP webhook).
  • Resolve (senderId, groupId?) into a daemon session via DaemonChannelSessionFactory.
  • Forward the user message as a daemon prompt and stream the response back as outbound chat messages, possibly chunked.
  • Render permission requests as chat-native prompts when interactive; otherwise auto-approve according to ChannelConfig.approvalMode.
  • Apply sender gating (allowlists / denylists), group gating, and content normalization (markdown / HTML per channel).

Architecture

DaemonChannelBridge (shared base, packages/channels/base/src/DaemonChannelBridge.ts)

class DaemonChannelBridge extends EventEmitter {
  constructor(opts: {
    cwd: string;
    sessionFactory: DaemonChannelSessionFactory;
    modelServiceId?: string;
    sessionScope?: SessionScope;
  });
  newSession(cwd: string): Promise<string>;
  loadSession(sessionId: string, cwd: string): Promise<string>;
  prompt(sessionId: string, text: string, options?): Promise<string>;
  cancelSession(sessionId: string): Promise<void>;
  stop(): void;
}

Holds daemon session clients keyed by daemon sessionId; ChannelBase and SessionRouter decide which inbound chat target maps to that session. Each attached session has:

  • A DaemonChannelSessionClient (shape of DaemonSessionClient minus channel-irrelevant methods).
  • A live SSE consumer pump.
  • A debounced prompt assembler (for adapters that fragment user input across multiple inbound messages).
  • An auto-approve policy per request.

Events emitted: textChunk, toolCall, sessionUpdate, permissionRequest, permissionResolved, modelSwitched, modelSwitchFailed, sessionDied, promptComplete, and error. Channel adapters wire these into platform-native APIs.

ChannelBase (packages/channels/base/src/ChannelBase.ts)

Abstract base every adapter extends:

abstract class ChannelBase {
  abstract connect(): Promise<void>;
  abstract sendMessage(chatId: string, text: string): Promise<void>;
  abstract disconnect(): void;
  handleInbound(envelope: Envelope): Promise<void>; // → SessionRouter.resolve + bridge.prompt
}

All internal message delivery routes through sendThreadMessage(chatId, threadId, text). The default implementation falls through to sendMessage(chatId, text), ignoring threadId — IM adapters are unaffected. Polling adapters (e.g. GitHub) override sendThreadMessage to post comments on a specific issue/PR using the threadId.

Handles common cross-cutting concerns: sender gating (allowlist / denylist), group gating, message block streaming (chunk size, throttling), inbound debounce.

Per-channel adapters

Adapter File Transport Notes
DingTalk packages/channels/dingtalk/src/DingtalkAdapter.ts DingTalk Stream SDK WebSocket Sends via sessionWebhook POST; media images downloaded via DT API, base64 in envelope.
WeChat (Weixin) packages/channels/weixin/src/WeixinAdapter.ts iLink Bot HTTP long-poll Sends via proprietary sendText / sendImage API; typing indicators.
Telegram packages/channels/telegram/src/TelegramAdapter.ts Telegram Bot API long-poll (grammy) Sends HTML chunks via sendMessage.
Feishu packages/channels/feishu/src/FeishuAdapter.ts Feishu/Lark Stream WebSocket (default) or HTTP webhook Sends via Lark SDK as interactive cards; webhook mode requires encryptKey for HMAC signature verification.
GitHub packages/channels/github/src/GithubAdapter.ts GitHub Notifications API polling (@octokit/rest) Extends PollingChannelBase; cursor-based comment window dedup; posts comments via Issues API.

Each adapter implements:

  1. Inbound transport (subscribe / poll for messages).
  2. Envelope construction ({ senderId, groupId?, text, media?, raw }).
  3. Sender / group gating (delegates to ChannelBase).
  4. Outbound serialization (markdown → HTML / WeChat-native / DingTalk-native).
  5. Lifecycle (start / shutdown).

Adapter matrix

Adapter Transport Identity Permission UX Auto-approve config
DingTalk WebSocket stream senderStaffId (+ optional conversationId for groups) Inline buttons via DT markdown ChannelConfig.approvalMode = 'auto' | 'prompt'
WeChat HTTP long-poll senderWxid (+ optional groupWxid) Text-only prompts with reply tokens Same
Telegram Bot API long-poll from.id (+ optional chat.id for groups) Inline keyboard buttons Same
Feishu WebSocket stream / HTTP webhook sender.open_id (+ optional chat_id for groups) Interactive card buttons Same
GitHub Notifications API polling Numeric user.id (immutable; login resolved at connect) Error comment + re-mention senderPolicy: 'allowlist' | 'open'

Note: The "Permission UX" column describes each platform's native affordance, but none is wired up yet — AcpBridge.requestPermission currently auto-approves every request (packages/channels/base/src/AcpBridge.ts), and ChannelConfig.approvalMode is declared but not yet read. Interactive approval is planned (Phase 5).

Workflow

Inbound prompt

sequenceDiagram
    autonumber
    participant CH as Channel platform
    participant AD as Channel adapter
    participant CB as ChannelBase
    participant BR as DaemonChannelBridge
    participant SC as DaemonChannelSessionClient
    participant D as Daemon

    CH-->>AD: inbound message
    AD->>AD: build Envelope { senderId, groupId?, text, media? }
    AD->>CB: handleInbound(envelope)
    CB->>CB: sender / group gating
    CB->>CB: SessionRouter.resolve(...) → sessionId
    CB->>BR: prompt(sessionId, promptText, attachments?)
    BR->>SC: session.prompt({...})
    SC->>D: POST /session/:id/prompt

SSE-driven outbound

sequenceDiagram
    autonumber
    participant D as Daemon
    participant SC as DaemonChannelSessionClient
    participant BR as DaemonChannelBridge
    participant CB as ChannelBase
    participant AD as Channel adapter
    participant CH as Channel platform

    D-->>SC: SSE: session_update (agent_message_chunk)
    SC-->>BR: DaemonEvent
    BR-->>CB: emit 'textChunk'
    CB->>CB: assemble response / block streaming
    CB->>AD: sendMessage(chatId, chunk or full response)
    AD->>CH: sendText / sendMessage / sendChunk

Permission auto-approve

sequenceDiagram
    autonumber
    participant D as Daemon
    participant SC as DaemonChannelSessionClient
    participant BR as DaemonChannelBridge
    participant AD as Channel adapter

    D-->>SC: SSE: permission_request
    SC-->>BR: DaemonEvent
    alt config.approvalMode == 'auto'
        BR->>SC: session.respondToPermission({...})
    else 'prompt'
        BR-->>AD: emit 'permissionRequest' (renders chat-native UI)
        AD->>BR: user picks option → respondToPermission
    end

State & Lifecycle

  • DaemonChannelBridge lives for the lifetime of the channel adapter; sessions inside it live according to the configured SessionScope.
  • Each active session reconnects automatically if SSE drops — DaemonSessionClient.events() tracks lastSeenEventId so replay is correct.
  • shutdown() closes every active session and the underlying transport (the channel's WebSocket / long-poll).
  • DingTalk's WebSocket stream supports server-push; WeChat's long-poll requires a backoff strategy on idle responses; Telegram's long-poll has a built-in timeout parameter.

Runtime selection and settings reload

The long-lived ChannelWorkerManager owns the committed daemon selection and workspace-grouped supervisors. A daemon may boot without --channel; the first strict-gated PUT /workspace/channel dynamically loads the channel runtime, reserves the service pidfile, resolves workspace ownership, and starts the selected workers. GET /workspace/channel reads the manager snapshot and DELETE /workspace/channel stops it idempotently. SDK helpers are getChannelWorkerControl(), setChannelWorkerSelection(), and stopChannelWorker(); the CLI entry is qwen channel set plus remote status and stop variants.

The daemon reads channel settings from settings.json when each worker starts (packages/cli/src/commands/channel/daemon-worker.tsloadSettingsloadChannelsConfig). POST /workspace/channel/reload re-reads those settings and force-reconciles the committed selection. All lifecycle mutations share one FIFO lane. Unchanged workspace groups survive ordinary selection replacement; changed groups stop and start sequentially while the serve-owned PID lease remains held.

If a replacement fails, newly started workers are stopped and old workers are restored before the request returns. A supervisor that cannot observe exit after SIGTERM and SIGKILL retains its child reference and fails stop; the manager keeps the PID lease and never starts a second worker. Webhook configuration and routing change only when selection commit succeeds. Runtime selections are process-local and disappear on daemon restart.

Adapter connect() failures are reported separately from worker lifecycle errors. The worker sends each bounded, credential-redacted failure over startup IPC and waits for a supervisor acknowledgement before trying the next adapter. A partially connected worker remains running and exposes startupFailures in its snapshot. If every adapter in a dynamic attempt fails, the 502 channel_worker_start_failed response carries workspace-annotated attempted failures while state reflects the rollback result; subsequent GET responses do not retain the attempt. Daemon boot with no connected adapter remains fail-fast. The optional adapter code is diagnostic only, and the current phase is connect.

Dependencies

  • packages/channels/base/ChannelBase, PollingChannelBase, DaemonChannelBridge, types.ts (ChannelConfig, Envelope, SessionScope, ChannelPlugin).
  • packages/sdk-typescript/src/daemon/DaemonSessionClient and friends.
  • Per-channel SDKs: @dingtalk/stream (DingTalk), proprietary iLink Bot HTTP (Weixin), grammy (Telegram), @octokit/rest (GitHub polling).

Configuration

ChannelConfig (from packages/channels/base/src/types.ts):

Knob Effect
sessionScope 'user' (sender + chat), 'thread' (thread id or chat), 'chat_thread' (channel + chatId + threadId, for polling adapters), or 'single' (one shared session per channel).
approvalMode 'auto' (auto-respond) / 'prompt' (render UI).
allowlist?: string[] Sender ids allowed; missing = open.
denylist?: string[] Sender ids denied.
chunkSize, chunkIntervalMs Outbound block streaming settings.
daemon: { baseUrl, token?, clientId? } Forwarded to DaemonChannelSessionFactory.

Channel-specific keys layer on top (DingTalk: streamCredentials; WeChat: ilinkUrl, botId; Telegram: botToken; Feishu: clientId (appId), clientSecret (appSecret), verificationToken, encryptKey (webhook mode)).

Caveats & Known Limits

  • Channels do not directly import @qwen-code/sdk. They go through ChannelBaseDaemonChannelBridgeDaemonChannelSessionClient (which the bridge constructs from the SDK). The indirection lets the bridge swap implementations, such as a test stub, without requiring channel changes.
  • Permission UX is per-channel. DingTalk uses markdown buttons; WeChat is text-only; Telegram uses inline keyboards; Feishu uses interactive card buttons. (All currently auto-approve via AcpBridge; interactive approval is planned.) No common "interactive permission widget" abstraction yet.
  • Auto-approve is a deployment-side decision, not a daemon-side one. The daemon's permission_mediation policy still applies; auto-approve only means the channel responds without prompting the human. Do not combine auto with enforce-grade workflows.
  • Per-channel rate limits / message-size limits are the adapter's job. DaemonChannelBridge only handles chunking; pushing past WeChat's per-message size or Telegram's flood limit is on the adapter.
  • No DingTalk / WeChat / Telegram / Feishu reverse-call — channels are one-way (chat → daemon → chat). The IM platform's native push path, such as a DingTalk card callback, is not wired into the bridge yet.

References

  • packages/channels/base/src/DaemonChannelBridge.ts
  • packages/channels/base/src/ChannelBase.ts
  • packages/channels/base/src/types.ts
  • packages/cli/src/serve/channel-worker-manager.ts (selection lifecycle + serialization)
  • packages/cli/src/serve/channel-worker-group.ts (workspace-differential reconcile)
  • packages/cli/src/serve/channel-worker-supervisor.ts (child supervision)
  • packages/cli/src/serve/routes/workspace-channel-control.ts (GET/PUT/DELETE/reload resource)
  • packages/channels/dingtalk/src/DingtalkAdapter.ts
  • packages/channels/weixin/src/WeixinAdapter.ts
  • packages/channels/telegram/src/TelegramAdapter.ts
  • packages/channels/plugin-example/ (reference plugin scaffold)
  • Channel plugin guide: ../channel-plugins.md.
  • SDK reference: 13-sdk-daemon-client.md.