Skip to content
24 changes: 22 additions & 2 deletions docs/developers/channel-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ Your plugin sits in the Platform Adapter layer. You handle platform-specific con

```
Your Plugin → builds Envelope → handleInbound()
ChannelBase → gates → commands → routing → AcpBridge.prompt()
ChannelBase → gates → commands → routing → ChannelAgentBridge.prompt()
ChannelBase → calls your sendMessage() with the agent's response
```

`ChannelAgentBridge` is the adapter-facing bridge contract. The current standalone `qwen channel start` path provides an `AcpBridge`, but plugin code should type constructor parameters as `ChannelAgentBridge` so the same adapter can run behind other bridge implementations later.

Migration note for existing TypeScript plugins: if your adapter constructor or factory explicitly types `bridge` as `AcpBridge`, change that annotation to `ChannelAgentBridge` and keep using only the methods exposed by that contract. JavaScript plugins are unaffected at runtime, and standalone `qwen channel start` still passes the current `AcpBridge` implementation.

## The Plugin Object

Your extension entry point exports a `plugin` conforming to `ChannelPlugin`:
Expand All @@ -35,9 +39,23 @@ Extend `ChannelBase` and implement three methods:

```typescript
import { ChannelBase } from '@qwen-code/channel-base';
import type { Envelope } from '@qwen-code/channel-base';
import type {
ChannelBaseOptions,
ChannelAgentBridge,
ChannelConfig,
Envelope,
} from '@qwen-code/channel-base';

export class MyChannel extends ChannelBase {
constructor(
name: string,
config: ChannelConfig,
bridge: ChannelAgentBridge,
options?: ChannelBaseOptions,
) {
super(name, config, bridge, options);
}

async connect(): Promise<void> {
// Connect to your platform, register message handlers
// When a message arrives:
Expand All @@ -64,6 +82,8 @@ export class MyChannel extends ChannelBase {
}
```

Most adapters should pass `options` through unchanged. If an adapter creates its own `SessionRouter` and passes that router to `super()`, set `registerBridgeEvents: true` in `ChannelBaseOptions` so `ChannelBase` still receives `toolCall` and `sessionDied` events directly. Leave it unset for routers supplied by the channel gateway.

## The Envelope

The normalized message object you build from platform data. The boolean flags drive gate logic, so they must be accurate.
Expand Down
2 changes: 1 addition & 1 deletion docs/users/features/channels/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Channel plugins are loaded at startup from active extensions. When `qwen channel
3. Registers the channel type so it can be referenced in `settings.json`
4. Creates channel instances using the plugin's factory function

Your custom channel gets the full shared pipeline for free: sender gating, group policies, session routing, slash commands, crash recovery, and the ACP bridge to the agent.
Your custom channel gets the full shared pipeline for free: sender gating, group policies, session routing, slash commands, crash recovery, and an agent bridge. Standalone `qwen channel start` currently supplies `AcpBridge`; plugin adapter code should depend on the adapter-facing `ChannelAgentBridge` contract. Existing TypeScript plugins with an explicit `AcpBridge` bridge parameter should migrate that annotation to `ChannelAgentBridge`; JavaScript plugins are unaffected at runtime.

## Installing a Custom Channel

Expand Down
135 changes: 100 additions & 35 deletions packages/channels/base/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @qwen-code/channel-base

Base infrastructure for building Qwen Code channel adapters. Provides the abstract base class, access control, session routing, and the ACP bridge that communicates with the agent.
Base infrastructure for building Qwen Code channel adapters. Provides the abstract base class, access control, session routing, and the adapter-facing bridge interface used to communicate with the agent.

If you're building a channel plugin, this is your only dependency.

Expand All @@ -17,12 +17,22 @@ Subclass `ChannelBase` and implement three methods:
```typescript
import { ChannelBase } from '@qwen-code/channel-base';
import type {
ChannelAgentBridge,
ChannelBaseOptions,
ChannelConfig,
Envelope,
AcpBridge,
} from '@qwen-code/channel-base';

class MyChannel extends ChannelBase {
constructor(
name: string,
config: ChannelConfig,
bridge: ChannelAgentBridge,
options?: ChannelBaseOptions,
) {
super(name, config, bridge, options);
}

async connect(): Promise<void> {
// Connect to platform API, register message handlers.
// When a message arrives, build an Envelope and call:
Expand Down Expand Up @@ -55,6 +65,8 @@ export const plugin: ChannelPlugin = {

For a complete working example, see [`@qwen-code/channel-plugin-example`](../plugin-example/).

Migration note for existing TypeScript plugins: if your adapter constructor or factory explicitly types `bridge` as `AcpBridge`, change that annotation to `ChannelAgentBridge` and keep using only the methods exposed by that contract. JavaScript plugins are unaffected at runtime, and standalone `qwen channel start` still passes the current `AcpBridge` implementation.

## Architecture

```
Expand All @@ -63,9 +75,9 @@ Inbound: Platform message
→ GroupGate (group policy + mention gating)
→ SenderGate (allowlist / pairing / open)
→ Slash commands (/clear, /help, /status)
→ SessionRouter (resolve or create ACP session)
→ SessionRouter (resolve or create agent session)
→ Resolve attachments (images → bridge, files → prompt text)
→ AcpBridge.prompt() → agent
→ ChannelAgentBridge.prompt() → agent

Outbound: Agent response
→ BlockStreamer (if enabled: split into blocks at paragraph boundaries)
Expand All @@ -78,36 +90,39 @@ Everything between `handleInbound()` and `sendMessage()` is handled by the base

### Classes

| Class | Purpose |
| --------------- | ---------------------------------------------------------------- |
| `ChannelBase` | Abstract base class — extend this to build a channel adapter |
| `AcpBridge` | Spawns and communicates with the `qwen-code --acp` agent process |
| `BlockStreamer` | Progressive multi-message delivery for block streaming |
| `SessionRouter` | Maps senders to ACP sessions with configurable scoping |
| `SenderGate` | DM access control (allowlist / pairing / open) |
| `GroupGate` | Group chat policy and @mention gating |
| `PairingStore` | Pairing code generation, approval, and allowlist persistence |
| Class | Purpose |
| --------------- | ------------------------------------------------------------------------------------ |
| `ChannelBase` | Abstract base class — extend this to build a channel adapter |
| `AcpBridge` | Current standalone `qwen channel start` bridge implementation over `qwen-code --acp` |
| `BlockStreamer` | Progressive multi-message delivery for block streaming |
| `SessionRouter` | Maps senders to agent sessions with configurable scoping |
| `SenderGate` | DM access control (allowlist / pairing / open) |
| `GroupGate` | Group chat policy and @mention gating |
| `PairingStore` | Pairing code generation, approval, and allowlist persistence |

### Types

| Type | Description |
| --------------- | ---------------------------------------------- |
| `Attachment` | Structured file/image/audio/video attachment |
| `ChannelConfig` | Channel configuration from `settings.json` |
| `ChannelPlugin` | Plugin factory interface (what you export) |
| `Envelope` | Normalized inbound message format |
| `SenderPolicy` | `'allowlist' \| 'pairing' \| 'open'` |
| `GroupPolicy` | `'disabled' \| 'allowlist' \| 'open'` |
| `SessionScope` | `'user' \| 'thread' \| 'single'` |
| `GroupConfig` | Per-group settings (e.g. `requireMention`) |
| `SessionTarget` | Maps a session back to its channel/sender/chat |
| Type | Description |
| -------------------- | ------------------------------------------------------------------------ |
| `Attachment` | Structured file/image/audio/video attachment |
| `AvailableCommand` | Agent command advertised through the bridge |
| `ChannelAgentBridge` | Adapter-facing bridge contract used by `ChannelBase` and `SessionRouter` |
| `ChannelConfig` | Channel configuration from `settings.json` |
| `ChannelPlugin` | Plugin factory interface (what you export) |
| `Envelope` | Normalized inbound message format |
| `SenderPolicy` | `'allowlist' \| 'pairing' \| 'open'` |
| `GroupPolicy` | `'disabled' \| 'allowlist' \| 'open'` |
| `SessionScope` | `'user' \| 'thread' \| 'single'` |
| `GroupConfig` | Per-group settings (e.g. `requireMention`) |
| `SessionTarget` | Maps a session back to its channel/sender/chat |
| `ToolCallEvent` | Agent tool-call event delivered to adapters |

## API reference

### ChannelBase

```typescript
constructor(name: string, config: ChannelConfig, bridge: AcpBridge, options?: ChannelBaseOptions)
constructor(name: string, config: ChannelConfig, bridge: ChannelAgentBridge, options?: ChannelBaseOptions)
```

**Abstract methods** (you must implement):
Expand All @@ -123,7 +138,7 @@ constructor(name: string, config: ChannelConfig, bridge: AcpBridge, options?: Ch
| Method | Description |
| ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `handleInbound(envelope)` | Route an inbound message through the full pipeline (gate checks, commands, session, prompt). Call this from your message handler. |
| `setBridge(bridge)` | Replace the ACP bridge after crash recovery |
| `setBridge(bridge)` | Replace the agent bridge after crash recovery |
| `registerCommand(name, handler)` | Register a custom slash command (e.g. `/mycommand`) |
| `onToolCall(chatId, event)` | Hook called on agent tool invocations — override to show indicators |
| `onResponseChunk(chatId, chunk, sessionId)` | Hook called per streaming text chunk — override for progressive display (default: no-op) |
Expand All @@ -133,9 +148,58 @@ constructor(name: string, config: ChannelConfig, bridge: AcpBridge, options?: Ch

**Built-in slash commands:** `/clear` (`/reset`, `/new`), `/help`, `/status`

**ChannelBaseOptions:**

| Option | Description |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `router` | Optional `SessionRouter`. Omit for the default standalone router. |
| `proxy` | Optional proxy URL made available to adapters. |
| `registerBridgeEvents` | Set only when the adapter owns a supplied router and `ChannelBase` should consume bridge events directly. Leave unset for routers supplied by the gateway. |

### ChannelAgentBridge

`ChannelAgentBridge` is the adapter-facing contract. Channel adapters, channel plugins, `ChannelBase`, and `SessionRouter` should depend on this type instead of a concrete bridge implementation.

```typescript
interface ChannelAgentBridge {
readonly availableCommands: AvailableCommand[];
on(eventName: 'toolCall', listener: (event: ToolCallEvent) => void): unknown;
on(
eventName: 'textChunk',
listener: (sessionId: string, chunk: string) => void,
): unknown;
on(
eventName: 'sessionDied',
listener: (event: { sessionId: string; reason?: string }) => void,
): unknown;
off(eventName: 'toolCall', listener: (event: ToolCallEvent) => void): unknown;
off(
eventName: 'textChunk',
listener: (sessionId: string, chunk: string) => void,
): unknown;
off(
eventName: 'sessionDied',
listener: (event: { sessionId: string; reason?: string }) => void,
): unknown;
newSession(cwd: string): Promise<string>;
loadSession(sessionId: string, cwd: string): Promise<string>;
prompt(
sessionId: string,
text: string,
options?: { imageBase64?: string; imageMimeType?: string },
): Promise<string>;
cancelSession(sessionId: string): Promise<void>;
shellCommand?(
sessionId: string,
command: string,
signal?: AbortSignal,
): Promise<{ exitCode: number | null; output: string; aborted: boolean }>;
}
```

### AcpBridge

Manages the `qwen-code --acp` child process and ACP sessions.
`AcpBridge` is the current implementation used by standalone `qwen channel start`. It manages the `qwen-code --acp` child process and implements `ChannelAgentBridge`.

```typescript
constructor(options: { cliEntryPath: string; cwd: string; model?: string })
Expand All @@ -160,10 +224,10 @@ constructor(options: { cliEntryPath: string; cwd: string; model?: string })

### SessionRouter

Maps senders to ACP sessions based on the configured scope.
Maps senders to agent sessions based on the configured scope.

```typescript
constructor(bridge: AcpBridge, defaultCwd: string, scope?: SessionScope, persistPath?: string)
constructor(bridge: ChannelAgentBridge, defaultCwd: string, scope?: SessionScope, persistPath?: string)
```

**Routing keys by scope:**
Expand All @@ -174,12 +238,13 @@ constructor(bridge: AcpBridge, defaultCwd: string, scope?: SessionScope, persist
| `thread` | `channel:threadId` | One session per thread |
| `single` | `channel:__single__` | One shared session for the entire channel |

| Method | Description |
| --------------------------------------------------------- | ----------------------------------------------------------- |
| `resolve(channelName, senderId, chatId, threadId?, cwd?)` | Get or create a session for the given sender |
| `removeSession(channelName, senderId, chatId?)` | Remove session(s) — used by `/clear` |
| `restoreSessions()` | Reload sessions from disk after bridge restart |
| `clearAll()` | Clear all sessions and delete persist file (clean shutdown) |
| Method | Description |
| ---------------------------------------------------------- | ----------------------------------------------------------- |
| `resolve(channelName, senderId, chatId, threadId?, cwd?)` | Get or create a session for the given sender |
| `removeSession(channelName, senderId, chatId?, threadId?)` | Remove session(s) — used by `/clear` |
| `removeSessionId(sessionId)` | Remove all routing state for a bridge session ID |
| `restoreSessions()` | Reload sessions from disk after bridge restart |
| `clearAll()` | Clear all sessions and delete persist file (clean shutdown) |

### SenderGate

Expand Down
23 changes: 7 additions & 16 deletions packages/channels/base/src/AcpBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,29 +13,20 @@ import type {
RequestPermissionRequest,
RequestPermissionResponse,
} from '@agentclientprotocol/sdk';
import type {
AvailableCommand,
ChannelAgentBridge,
ToolCallEvent,
} from './ChannelAgentBridge.js';
export type { AvailableCommand, ToolCallEvent } from './ChannelAgentBridge.js';

export interface AcpBridgeOptions {
cliEntryPath: string;
cwd: string;
model?: string;
}

export interface AvailableCommand {
name: string;
description: string;
input?: { hint: string } | null;
}

export interface ToolCallEvent {
sessionId: string;
toolCallId: string;
kind: string;
title: string;
status: string;
rawInput?: Record<string, unknown>;
}

export class AcpBridge extends EventEmitter {
export class AcpBridge extends EventEmitter implements ChannelAgentBridge {
private child: ChildProcess | null = null;
private connection: ClientSideConnection | null = null;
private options: AcpBridgeOptions;
Expand Down
50 changes: 50 additions & 0 deletions packages/channels/base/src/ChannelAgentBridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export interface AvailableCommand {
name: string;
description: string;
input?: { hint: string } | null;
}

export interface ToolCallEvent {
sessionId: string;
toolCallId: string;
kind: string;
title: string;
status: string;
rawInput?: Record<string, unknown>;
}

export interface SessionDiedEvent {
sessionId: string;
reason?: string;
}

interface ChannelAgentBridgeEventMap {
sessionDied: [SessionDiedEvent];
textChunk: [sessionId: string, chunk: string];
toolCall: [ToolCallEvent];
}

export interface ChannelAgentBridge {
readonly availableCommands: AvailableCommand[];
on<K extends keyof ChannelAgentBridgeEventMap>(
eventName: K,
listener: (...args: ChannelAgentBridgeEventMap[K]) => void,
): unknown;
off<K extends keyof ChannelAgentBridgeEventMap>(
eventName: K,
listener: (...args: ChannelAgentBridgeEventMap[K]) => void,
): unknown;
newSession(cwd: string): Promise<string>;
loadSession(sessionId: string, cwd: string): Promise<string>;
prompt(
sessionId: string,
text: string,
options?: { imageBase64?: string; imageMimeType?: string },
): Promise<string>;
cancelSession(sessionId: string): Promise<void>;
shellCommand?(
sessionId: string,
command: string,
signal?: AbortSignal,
): Promise<{ exitCode: number | null; output: string; aborted: boolean }>;
}
Loading
Loading