Channel Loop Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add channel /loop support for recurring chat-bound agent work, replacing the closed /schedule PR stack with loop terminology and lifecycle inspection.
Architecture: Channel loops are stored by the channel gateway in a JSON file under Qwen home, scanned by a small channel-owned cron scheduler, and executed through ChannelBase using the existing SessionRouter and per-session queue. This iteration shares core cron parsing but does not reuse core CronScheduler; the channel layer needs chat target scoping, proactive-send capability checks, and lifecycle fields.
Tech Stack: TypeScript ESM, Vitest, @qwen-code/qwen-code-core cron utilities, channel packages, CLI channel start command.
Issue: https://github.com/QwenLM/qwen-code/issues/6068
File Structure
- Create
packages/channels/base/src/ChannelLoopStore.ts: JSON persistence for loop definitions and lifecycle fields. - Create
packages/channels/base/src/ChannelLoopScheduler.ts: tick loop that finds due channel loops, runs them once, and records lifecycle results. - Modify
packages/channels/base/src/ChannelBase.ts: add/loop add/list/inspect/cancel, proactive loop execution, adapter capability hooks, and target authorization checks. - Modify
packages/channels/base/src/index.ts: export channel loop store/scheduler/types. - Modify
packages/channels/base/src/paths.ts: addchannelLoopPath(). - Modify
packages/channels/base/src/types.ts: ensureSessionTargetcarries channel/chat/thread/group target fields used by persisted loops. - Modify
packages/channels/feishu/src/FeishuAdapter.ts: opt in to proactive loop messages where direct chat send is supported. - Modify
packages/channels/telegram/src/TelegramAdapter.ts: opt in to proactive loop messages. - Modify
packages/cli/src/commands/channel/start.ts: create loop store/scheduler and tie lifecycle to channel startup, crash recovery, and shutdown. - Modify
packages/core/src/index.ts: exportparseCronandnextFireTime. - Add tests next to touched source files.
Task 1: Core Exports And Store
Files:
-
Modify:
packages/core/src/index.ts -
Modify:
packages/channels/base/src/paths.ts -
Create:
packages/channels/base/src/ChannelLoopStore.ts -
Create:
packages/channels/base/src/ChannelLoopStore.test.ts -
Modify:
packages/channels/base/src/index.ts -
Step 1: Write failing store tests
Add packages/channels/base/src/ChannelLoopStore.test.ts with tests for:
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { describe, expect, it } from 'vitest';
import { ChannelLoopStore } from './ChannelLoopStore.js';
const target = {
channelName: 'telegram-main',
senderId: 'user-1',
chatId: 'chat-1',
isGroup: false,
};
describe('ChannelLoopStore', () => {
it('creates enabled channel loops with lifecycle defaults', async () => {
const dir = await mkdtemp(join(tmpdir(), 'channel-loop-store-'));
const store = new ChannelLoopStore({
filePath: join(dir, 'loops.json'),
now: () => new Date('2026-06-30T09:00:00.000Z'),
idFactory: () => 'loop-1',
});
const loop = await store.create({
channelName: 'telegram-main',
target,
cwd: '/repo',
cron: '0 9 * * *',
prompt: 'post summary',
label: 'post summary',
recurring: true,
createdBy: 'Alice',
});
expect(loop).toMatchObject({
id: 'loop-1',
enabled: true,
consecutiveFailures: 0,
runCount: 0,
createdAt: '2026-06-30T09:00:00.000Z',
});
await expect(store.listForTarget('telegram-main', target)).resolves.toHaveLength(1);
});
it('enforces target quotas atomically through createForTarget', async () => {
const dir = await mkdtemp(join(tmpdir(), 'channel-loop-quota-'));
let next = 0;
const store = new ChannelLoopStore({
filePath: join(dir, 'loops.json'),
idFactory: () => `loop-${++next}`,
});
const input = {
channelName: 'telegram-main',
target,
cwd: '/repo',
cron: '0 9 * * *',
prompt: 'post summary',
recurring: true,
createdBy: 'Alice',
};
await expect(store.createForTarget(input, 1)).resolves.toMatchObject({ id: 'loop-1' });
await expect(store.createForTarget(input, 1)).resolves.toBeUndefined();
});
it('loads pre-lifecycle loop JSON with runCount defaulted to 0', async () => {
const dir = await mkdtemp(join(tmpdir(), 'channel-loop-legacy-'));
const filePath = join(dir, 'loops.json');
await writeFile(filePath, JSON.stringify([
{
id: 'loop-legacy',
channelName: 'telegram-main',
target,
cwd: '/repo',
cron: '0 9 * * *',
prompt: 'post summary',
recurring: true,
enabled: true,
createdBy: 'Alice',
createdAt: '2026-06-30T09:00:00.000Z',
consecutiveFailures: 0,
},
]));
await expect(new ChannelLoopStore({ filePath }).list()).resolves.toMatchObject([
{ id: 'loop-legacy', runCount: 0 },
]);
});
it('refuses corrupt JSON instead of treating it as empty state', async () => {
const dir = await mkdtemp(join(tmpdir(), 'channel-loop-corrupt-'));
const filePath = join(dir, 'loops.json');
await writeFile(filePath, '{nope');
await expect(new ChannelLoopStore({ filePath }).list()).rejects.toThrow(
'Malformed JSON',
);
});
});- Step 2: Run store tests and verify RED
Run:
cd packages/channels/base && npx vitest run src/ChannelLoopStore.test.tsExpected: fails because ChannelLoopStore does not exist.
- Step 3: Implement store and path
Create ChannelLoopStore.ts with:
import * as crypto from 'node:crypto';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import type { SessionTarget } from './types.js';
export type ChannelLoopStatus = 'ok' | 'error';
export interface ChannelLoop {
id: string;
channelName: string;
target: SessionTarget;
cwd: string;
cron: string;
prompt: string;
label?: string;
recurring: boolean;
enabled: boolean;
createdBy: string;
createdAt: string;
lastFiredAt?: string;
lastFinishedAt?: string;
lastResultPreview?: string;
lastStatus?: ChannelLoopStatus;
lastError?: string;
consecutiveFailures: number;
runningSince?: string;
runCount: number;
}
export type ChannelLoopInput = Omit<
ChannelLoop,
| 'id'
| 'enabled'
| 'createdAt'
| 'lastFiredAt'
| 'lastFinishedAt'
| 'lastResultPreview'
| 'lastStatus'
| 'lastError'
| 'consecutiveFailures'
| 'runningSince'
| 'runCount'
>;
export type ChannelLoopPatch = Partial<
Pick<
ChannelLoop,
| 'enabled'
| 'lastFiredAt'
| 'lastFinishedAt'
| 'lastResultPreview'
| 'lastStatus'
| 'lastError'
| 'consecutiveFailures'
| 'runningSince'
| 'runCount'
>
>;
export interface ChannelLoopStoreOptions {
filePath: string;
now?: () => Date;
idFactory?: () => string;
}
export class ChannelLoopStore {
private readonly filePath: string;
private readonly now: () => Date;
private readonly idFactory: () => string;
private pendingUpdate: Promise<void> = Promise.resolve();
constructor(options: ChannelLoopStoreOptions) {
this.filePath = options.filePath;
this.now = options.now ?? (() => new Date());
this.idFactory = options.idFactory ?? (() => crypto.randomUUID());
}
async list(): Promise<ChannelLoop[]> {
return this.readLoops();
}
async listForTarget(
channelName: string,
target: SessionTarget,
): Promise<ChannelLoop[]> {
const loops = await this.readLoops();
return loops.filter(
(loop) =>
loop.channelName === channelName && sameTarget(loop.target, target),
);
}
async create(input: ChannelLoopInput): Promise<ChannelLoop> {
const loop = this.buildLoop(input);
await this.updateLoops((loops) => [...loops, loop]);
return loop;
}
async createForTarget(
input: ChannelLoopInput,
maxEnabledLoops: number,
): Promise<ChannelLoop | undefined> {
let created: ChannelLoop | undefined;
await this.updateLoops((loops) => {
const enabledForTarget = loops.filter(
(loop) =>
loop.enabled &&
loop.channelName === input.channelName &&
sameTarget(loop.target, input.target),
).length;
if (enabledForTarget >= maxEnabledLoops) return loops;
created = this.buildLoop(input);
return [...loops, created];
});
return created;
}
async update(id: string, patch: ChannelLoopPatch): Promise<boolean> {
let found = false;
await this.updateLoops((loops) =>
loops.map((loop) => {
if (loop.id !== id) return loop;
found = true;
return { ...loop, ...patch };
}),
);
return found;
}
async disable(id: string): Promise<boolean> {
return this.update(id, { enabled: false });
}
private buildLoop(input: ChannelLoopInput): ChannelLoop {
return {
...input,
id: this.idFactory(),
enabled: true,
createdAt: this.now().toISOString(),
consecutiveFailures: 0,
runCount: 0,
};
}
private async updateLoops(
mutate: (loops: ChannelLoop[]) => ChannelLoop[],
): Promise<void> {
const nextUpdate = this.pendingUpdate.then(async () => {
const loops = await this.readLoops();
await this.writeLoops(mutate(loops));
});
this.pendingUpdate = nextUpdate.catch(() => {});
await nextUpdate;
}
private async readLoops(): Promise<ChannelLoop[]> {
let raw: string;
try {
raw = await fs.readFile(this.filePath, 'utf8');
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(
`Malformed JSON in ${this.filePath}; fix or delete the file.`,
);
}
if (!Array.isArray(parsed)) {
throw new Error(
`Expected a JSON array in ${this.filePath}; fix or delete the file.`,
);
}
for (const [index, value] of parsed.entries()) {
if (!isChannelLoop(value)) {
throw new Error(`Invalid channel loop at index ${index} in ${this.filePath}.`);
}
}
return parsed.map(normalizeLoop);
}
private async writeLoops(loops: ChannelLoop[]): Promise<void> {
await fs.mkdir(path.dirname(this.filePath), { recursive: true });
const tmpPath = `${this.filePath}.${crypto.randomBytes(6).toString('hex')}.tmp`;
try {
await fs.writeFile(tmpPath, JSON.stringify(loops, null, 2), 'utf8');
await fs.rename(tmpPath, this.filePath);
} catch (err) {
await fs.rm(tmpPath, { force: true }).catch(() => {});
throw err;
}
}
}
function sameTarget(a: SessionTarget, b: SessionTarget): boolean {
const sameGroupChat = a.isGroup === true && b.isGroup === true;
return (
a.channelName === b.channelName &&
(sameGroupChat || a.senderId === b.senderId) &&
a.chatId === b.chatId &&
a.threadId === b.threadId &&
a.isGroup === b.isGroup
);
}
function isSessionTarget(value: unknown): value is SessionTarget {
if (typeof value !== 'object' || value === null) return false;
const target = value as Record<string, unknown>;
return (
typeof target['channelName'] === 'string' &&
typeof target['senderId'] === 'string' &&
typeof target['chatId'] === 'string' &&
(target['threadId'] === undefined ||
typeof target['threadId'] === 'string') &&
(target['isGroup'] === undefined || typeof target['isGroup'] === 'boolean')
);
}
function isChannelLoop(value: unknown): value is ChannelLoop {
if (typeof value !== 'object' || value === null) return false;
const loop = value as Record<string, unknown>;
return (
typeof loop['id'] === 'string' &&
typeof loop['channelName'] === 'string' &&
isSessionTarget(loop['target']) &&
typeof loop['cwd'] === 'string' &&
typeof loop['cron'] === 'string' &&
typeof loop['prompt'] === 'string' &&
(loop['label'] === undefined || typeof loop['label'] === 'string') &&
typeof loop['recurring'] === 'boolean' &&
typeof loop['enabled'] === 'boolean' &&
typeof loop['createdBy'] === 'string' &&
typeof loop['createdAt'] === 'string' &&
(loop['lastFiredAt'] === undefined ||
typeof loop['lastFiredAt'] === 'string') &&
(loop['lastFinishedAt'] === undefined ||
typeof loop['lastFinishedAt'] === 'string') &&
(loop['lastResultPreview'] === undefined ||
typeof loop['lastResultPreview'] === 'string') &&
(loop['lastStatus'] === undefined ||
loop['lastStatus'] === 'ok' ||
loop['lastStatus'] === 'error') &&
(loop['lastError'] === undefined || typeof loop['lastError'] === 'string') &&
typeof loop['consecutiveFailures'] === 'number' &&
(loop['runningSince'] === undefined ||
typeof loop['runningSince'] === 'string') &&
(loop['runCount'] === undefined || typeof loop['runCount'] === 'number')
);
}
function normalizeLoop(loop: ChannelLoop): ChannelLoop {
return { ...loop, runCount: loop.runCount ?? 0 };
}Add to paths.ts:
export function channelLoopPath(): string {
return path.join(getGlobalQwenDir(), 'channels', 'cron.json');
}The persisted filename stays cron.json for compatibility with the closed PR
stack’s local data, even though the user-facing command and code API use loop
terminology.
Export from index.ts and export cron helpers from packages/core/src/index.ts:
export { ChannelLoopStore } from './ChannelLoopStore.js';
export type { ChannelLoop, ChannelLoopInput, ChannelLoopPatch } from './ChannelLoopStore.js';
export { channelLoopPath } from './paths.js';
export { nextFireTime, parseCron } from './utils/cronParser.js';- Step 4: Run store tests and verify GREEN
Run:
cd packages/channels/base && npx vitest run src/ChannelLoopStore.test.tsExpected: all tests pass.
Task 2: Channel Loop Scheduler
Files:
-
Create:
packages/channels/base/src/ChannelLoopScheduler.ts -
Create:
packages/channels/base/src/ChannelLoopScheduler.test.ts -
Modify:
packages/channels/base/src/index.ts -
Step 1: Write failing scheduler tests
Add tests proving due loops fire once, lifecycle state records success/failure, stop() clears in-flight state, and invalid cron does not fire.
Run:
cd packages/channels/base && npx vitest run src/ChannelLoopScheduler.test.tsExpected: fails because scheduler does not exist.
- Step 2: Implement scheduler
Create a scheduler with this public shape:
export interface ChannelLoopRunner {
runLoopPrompt(
loop: ChannelLoop,
options?: { timeoutMs?: number },
): Promise<string | undefined>;
}
export interface ChannelLoopSchedulerOptions {
store: Pick<ChannelLoopStore, 'list' | 'update' | 'disable'>;
channels: ReadonlyMap<string, ChannelLoopRunner>;
nextFireTime: (cron: string, after: Date) => Date;
now?: () => Date;
maxConsecutiveFailures?: number;
intervalMs?: number;
loopTimeoutMs?: number;
}Core behavior:
-
start()schedules ticks and unrefs the timer. -
stop()clears timer,runningTick, andinFlightLoops. -
tick()avoids overlapping ticks. -
runTick()loads enabled due loops for connected channels. -
fire()recordsrunningSince, callschannel.runLoopPrompt, records success/failure lifecycle, disables one-shot loops, and disables recurring loops aftermaxConsecutiveFailures. -
Store result preview capped to 500 chars and error capped to 1000 chars.
-
Step 3: Run scheduler tests and verify GREEN
Run:
cd packages/channels/base && npx vitest run src/ChannelLoopScheduler.test.tsExpected: all tests pass.
Task 3: ChannelBase /loop Command Surface
Files:
-
Modify:
packages/channels/base/src/ChannelBase.ts -
Modify:
packages/channels/base/src/ChannelBase.test.ts -
Step 1: Write failing command tests
Add tests under the existing slash commands describe block for:
/loop add "0 9 * * *" post summarycreates a loop for the current channel target./loop listreturns enriched loop lines./loop inspect <id>returns lifecycle details and prompt./loop cancel <id>only disables loops owned by the current target.- Missing controller says
Loops are not available. - Unsupported proactive adapter rejects
/loop add. - Threaded targets reject unless the adapter supports them.
- Quota limit rejects with loop terminology.
/schedule ...is not a local command and falls through as normal text.
Run:
cd packages/channels/base && npx vitest run src/ChannelBase.test.tsExpected: new /loop tests fail.
- Step 2: Implement command surface
Add:
export interface ChannelLoopController {
create(input: ChannelLoopInput): Promise<ChannelLoop>;
createForTarget?(input: ChannelLoopInput, maxEnabledLoops: number): Promise<ChannelLoop | undefined>;
listForTarget(channelName: string, target: SessionTarget): Promise<ChannelLoop[]>;
disable(id: string): Promise<boolean>;
validateCron(cron: string): void;
nextFireTime?(loop: ChannelLoop): Date;
}
export interface ChannelLoopPromptOptions {
timeoutMs?: number;
}Add loopController?: ChannelLoopController to ChannelBaseOptions, register command name loop, and implement:
private async handleLoopCommand(envelope: Envelope, args: string): Promise<boolean>
private async handleLoopAdd(envelope: Envelope, args: string): Promise<boolean>
private async handleLoopList(envelope: Envelope): Promise<boolean>
private async handleLoopInspect(envelope: Envelope, id: string | undefined): Promise<boolean>
private async handleLoopCancel(envelope: Envelope, id: string | undefined): Promise<boolean>Use these user-facing messages:
Loops are not available.
Only authorized members can use loops in this shared session.
Usage: /loop add "<cron>" <prompt> | /loop list | /loop inspect <id> | /loop cancel <id>
This channel does not support proactive loop messages.
This channel does not support proactive loop messages for this chat target.
Loop prompt is too long; keep it under 4000 characters.
Too many loops for this chat. Cancel an existing loop before adding another.
Loop <id>: <cron>
No loops.
No loop <id>.
Cancelled loop <id>.Add:
supportsProactiveSend(): boolean {
return false;
}
protected supportsProactiveTarget(target: SessionTarget): boolean {
return target.threadId === undefined;
}
protected async pushProactive(target: SessionTarget, text: string): Promise<void> {
if (target.threadId) {
throw new Error('Channel does not support proactive loop messages for threaded targets.');
}
await this.sendMessage(target.chatId, text);
}- Step 3: Run ChannelBase command tests
Run:
cd packages/channels/base && npx vitest run src/ChannelBase.test.tsExpected: all ChannelBase tests pass.
Task 4: Loop Prompt Execution
Files:
-
Modify:
packages/channels/base/src/ChannelBase.ts -
Modify:
packages/channels/base/src/ChannelBase.test.ts -
Step 1: Write failing execution tests
Add tests under a loop prompts describe block:
runLoopPromptresolves a target session, prefixes prompt with[Loop "<label>" created by <createdBy>], runs bridge prompt, and pushes proactive response.- It queues behind an in-flight normal turn and starts timeout only after the queued turn begins.
- It cancels and evicts the bridge session on timeout.
- It disables a loop whose persisted target is no longer authorized.
- It does not push a late response after cancellation.
- It drains collected messages after the loop turn completes.
Run:
cd packages/channels/base && npx vitest run src/ChannelBase.test.tsExpected: new execution tests fail.
- Step 2: Implement
runLoopPrompt
Implement:
async runLoopPrompt(
loop: ChannelLoop,
options: ChannelLoopPromptOptions = {},
): Promise<string | undefined>Reuse the existing per-session queue, active prompt state, onPromptStart, onResponseChunk, onPromptEnd, generation guard, and collect-mode buffering behavior used by normal inbound messages. The prompt prefix must use loop terminology:
[Loop "<label>" created by <createdBy>]
<prompt>Timeout errors should use:
loop timed out- Step 3: Run ChannelBase execution tests
Run:
cd packages/channels/base && npx vitest run src/ChannelBase.test.tsExpected: all ChannelBase tests pass.
Task 5: Adapter Opt-In
Files:
-
Modify:
packages/channels/telegram/src/TelegramAdapter.ts -
Modify:
packages/channels/telegram/src/TelegramAdapter.test.ts -
Modify:
packages/channels/feishu/src/FeishuAdapter.ts -
Modify:
packages/channels/feishu/src/adapter.test.ts -
Step 1: Write failing adapter tests
Tests should prove Telegram and Feishu return true from supportsProactiveSend() and can push proactive loop output to direct chat targets.
Run:
cd packages/channels/telegram && npx vitest run src/TelegramAdapter.test.ts
cd packages/channels/feishu && npx vitest run src/adapter.test.tsExpected: new tests fail.
- Step 2: Implement adapter opt-in
In both adapters:
override supportsProactiveSend(): boolean {
return true;
}For Feishu threaded targets, override supportsProactiveTarget/pushProactive only for targets the adapter can address safely. Keep unsupported targets fail-closed.
- Step 3: Run adapter tests
Run:
cd packages/channels/telegram && npx vitest run src/TelegramAdapter.test.ts
cd packages/channels/feishu && npx vitest run src/adapter.test.tsExpected: all adapter tests pass.
Task 6: CLI Startup Wiring
Files:
-
Modify:
packages/cli/src/commands/channel/start.ts -
Modify:
packages/cli/src/commands/channel/start.test.ts -
Step 1: Write failing CLI tests
Update the mocked @qwen-code/channel-base module to include ChannelLoopStore and ChannelLoopScheduler, then assert:
startSinglecreates one store and scheduler.createChannelreceives{ loopController }.- Scheduler starts after bridge/channel setup.
- Scheduler stops on shutdown and bridge crash recovery.
- Scheduler restarts after bridge recovery.
startAllwires the same controller/scheduler across all channels.
Run:
cd packages/cli && npx vitest run src/commands/channel/start.test.tsExpected: new tests fail.
- Step 2: Implement CLI wiring
Import:
import {
AcpBridge,
channelLoopPath,
ChannelLoopScheduler,
ChannelLoopStore,
SessionRouter,
} from '@qwen-code/channel-base';
import { nextFireTime, parseCron } from '@qwen-code/qwen-code-core';Create a controller:
function createLoopController(loopStore: ChannelLoopStore) {
return {
create: (input) => loopStore.create(input),
createForTarget: (input, maxEnabledLoops) =>
loopStore.createForTarget(input, maxEnabledLoops),
listForTarget: (channelName, target) =>
loopStore.listForTarget(channelName, target),
disable: (id) => loopStore.disable(id),
validateCron: (cron) => {
parseCron(cron);
},
nextFireTime: (loop) =>
nextFireTime(loop.cron, new Date(loop.lastFiredAt ?? loop.createdAt)),
};
}Pass { router, proxy, loopController } to channels. Start ChannelLoopScheduler after channels are connected. Stop it before replacing bridge on crash recovery and during shutdown.
- Step 3: Run CLI tests
Run:
cd packages/cli && npx vitest run src/commands/channel/start.test.tsExpected: all CLI start tests pass.
Task 7: Verification And PR
Files:
-
Modify:
.qwen/pr-drafts/channel-loop.md -
Step 1: Run focused verification
Run:
cd packages/channels/base && npx vitest run src/ChannelLoopStore.test.ts src/ChannelLoopScheduler.test.ts src/ChannelBase.test.ts src/SessionRouter.test.ts
cd packages/cli && npx vitest run src/commands/channel/start.test.ts
cd packages/channels/telegram && npx vitest run src/TelegramAdapter.test.ts
cd packages/channels/feishu && npx vitest run src/adapter.test.ts
npm run build
npm run typecheck
git diff --checkExpected: all tests pass, build/typecheck pass, diff check clean.
- Step 2: Dispatch 8 review agents
Dispatch eight independent review agents against the final diff:
- Command semantics reviewer: verify no
/scheduleuser-facing surface remains. - Scheduler correctness reviewer: due calculation, in-flight dedupe, timeout, lifecycle updates.
- Store correctness reviewer: persistence validation, target scoping, quota atomicity.
- Channel concurrency reviewer: session queue,
/clear, cancellation, collect-mode buffering. - Adapter capability reviewer: Feishu/Telegram proactive send, unsupported target failure.
- CLI lifecycle reviewer: start, shutdown, crash recovery, shared bridge.
- Security/privacy reviewer: prompt/result sanitization, stored previews, authorization.
- Test quality reviewer: tests fail for real behavior and avoid mock-only assertions where possible.
Each reviewer returns Critical/Important/Minor findings. Fix Critical and Important findings before opening the PR.
- Step 3: Create PR draft
Create .qwen/pr-drafts/channel-loop.md using the repository PR template. Include:
-
Motivation: channel recurring work should be
/loop, not/schedule. -
Changes: channel loop store, scheduler, command surface, lifecycle inspectability, Feishu/Telegram opt-in.
-
How to verify: behaviors, not only commands.
-
Link:
Fixes #6068. -
Step 4: Commit, push, and open PR
Run:
git add packages/channels/base packages/channels/telegram packages/channels/feishu packages/cli packages/core .qwen/pr-drafts docs/superpowers/plans/2026-06-30-channel-loop.md
git commit -m "feat(channel): add channel loop support"
git push -u origin feat/channel-loop
gh pr create --repo QwenLM/qwen-code --draft --title "feat(channel): add channel loop support" --body-file .qwen/pr-drafts/channel-loop.mdExpected: draft PR opened against QwenLM/qwen-code:main.
Self-Review
- Spec coverage: The plan covers issue #6068:
/loopcommands, persistence, scheduler execution, proactive send gating, lifecycle inspectability, tests, and replacement PR flow. - Placeholder scan: No
TBD,TODO, or vague implementation placeholders remain. - Type consistency: The public names are
ChannelLoop,ChannelLoopStore,ChannelLoopScheduler,ChannelLoopController, andrunLoopPrompt; no/schedulenames are part of the new API.