docs/superpowers/plans/2026-06-30-channel-loop.md
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
packages/channels/base/src/ChannelLoopStore.ts: JSON persistence for loop definitions and lifecycle fields.packages/channels/base/src/ChannelLoopScheduler.ts: tick loop that finds due channel loops, runs them once, and records lifecycle results.packages/channels/base/src/ChannelBase.ts: add /loop add/list/inspect/cancel, proactive loop execution, adapter capability hooks, and target authorization checks.packages/channels/base/src/index.ts: export channel loop store/scheduler/types.packages/channels/base/src/paths.ts: add channelLoopPath().packages/channels/base/src/types.ts: ensure SessionTarget carries channel/chat/thread/group target fields used by persisted loops.packages/channels/feishu/src/FeishuAdapter.ts: opt in to proactive loop messages where direct chat send is supported.packages/channels/telegram/src/TelegramAdapter.ts: opt in to proactive loop messages.packages/cli/src/commands/channel/start.ts: create loop store/scheduler and tie lifecycle to channel startup, crash recovery, and shutdown.packages/core/src/index.ts: export parseCron and nextFireTime.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',
);
});
});
Run:
cd packages/channels/base && npx vitest run src/ChannelLoopStore.test.ts
Expected: fails because ChannelLoopStore does not exist.
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';
Run:
cd packages/channels/base && npx vitest run src/ChannelLoopStore.test.ts
Expected: all tests pass.
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.ts
Expected: fails because scheduler does not exist.
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, and inFlightLoops.
tick() avoids overlapping ticks.
runTick() loads enabled due loops for connected channels.
fire() records runningSince, calls channel.runLoopPrompt, records success/failure lifecycle, disables one-shot loops, and disables recurring loops after maxConsecutiveFailures.
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.ts
Expected: all tests pass.
/loop Command SurfaceFiles:
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 summary creates a loop for the current channel target./loop list returns enriched loop lines./loop inspect <id> returns lifecycle details and prompt./loop cancel <id> only disables loops owned by the current target.Loops are not available./loop add./schedule ... is not a local command and falls through as normal text.Run:
cd packages/channels/base && npx vitest run src/ChannelBase.test.ts
Expected: new /loop tests fail.
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);
}
Run:
cd packages/channels/base && npx vitest run src/ChannelBase.test.ts
Expected: all ChannelBase tests pass.
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:
runLoopPrompt resolves a target session, prefixes prompt with [Loop "<label>" created by <createdBy>], runs bridge prompt, and pushes proactive response.Run:
cd packages/channels/base && npx vitest run src/ChannelBase.test.ts
Expected: new execution tests fail.
runLoopPromptImplement:
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
Run:
cd packages/channels/base && npx vitest run src/ChannelBase.test.ts
Expected: all ChannelBase tests pass.
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.ts
Expected: new tests fail.
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.
Run:
cd packages/channels/telegram && npx vitest run src/TelegramAdapter.test.ts
cd packages/channels/feishu && npx vitest run src/adapter.test.ts
Expected: all adapter tests pass.
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:
startSingle creates one store and scheduler.createChannel receives { loopController }.startAll wires the same controller/scheduler across all channels.Run:
cd packages/cli && npx vitest run src/commands/channel/start.test.ts
Expected: new tests fail.
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.
Run:
cd packages/cli && npx vitest run src/commands/channel/start.test.ts
Expected: all CLI start tests pass.
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 --check
Expected: all tests pass, build/typecheck pass, diff check clean.
Dispatch eight independent review agents against the final diff:
/schedule user-facing surface remains./clear, cancellation, collect-mode buffering.Each reviewer returns Critical/Important/Minor findings. Fix Critical and Important findings before opening the PR.
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.md
Expected: draft PR opened against QwenLM/qwen-code:main.
/loop commands, persistence, scheduler execution, proactive send gating, lifecycle inspectability, tests, and replacement PR flow.TBD, TODO, or vague implementation placeholders remain.ChannelLoop, ChannelLoopStore, ChannelLoopScheduler, ChannelLoopController, and runLoopPrompt; no /schedule names are part of the new API.