Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 53 additions & 26 deletions docs/superpowers/plans/2026-06-30-channel-loop.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
## 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`
Expand Down Expand Up @@ -80,7 +81,9 @@ describe('ChannelLoopStore', () => {
runCount: 0,
createdAt: '2026-06-30T09:00:00.000Z',
});
await expect(store.listForTarget('telegram-main', target)).resolves.toHaveLength(1);
await expect(
store.listForTarget('telegram-main', target),
).resolves.toHaveLength(1);
});

it('enforces target quotas atomically through createForTarget', async () => {
Expand All @@ -100,32 +103,37 @@ describe('ChannelLoopStore', () => {
createdBy: 'Alice',
};

await expect(store.createForTarget(input, 1)).resolves.toMatchObject({ id: 'loop-1' });
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 },
]);
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 () => {
Expand Down Expand Up @@ -334,7 +342,9 @@ export class ChannelLoopStore {
}
for (const [index, value] of parsed.entries()) {
if (!isChannelLoop(value)) {
throw new Error(`Invalid channel loop at index ${index} in ${this.filePath}.`);
throw new Error(
`Invalid channel loop at index ${index} in ${this.filePath}.`,
);
}
}
return parsed.map(normalizeLoop);
Expand Down Expand Up @@ -401,7 +411,8 @@ function isChannelLoop(value: unknown): value is ChannelLoop {
(loop['lastStatus'] === undefined ||
loop['lastStatus'] === 'ok' ||
loop['lastStatus'] === 'error') &&
(loop['lastError'] === undefined || typeof loop['lastError'] === 'string') &&
(loop['lastError'] === undefined ||
typeof loop['lastError'] === 'string') &&
typeof loop['consecutiveFailures'] === 'number' &&
(loop['runningSince'] === undefined ||
typeof loop['runningSince'] === 'string') &&
Expand Down Expand Up @@ -430,7 +441,11 @@ Export from `index.ts` and export cron helpers from `packages/core/src/index.ts`

```ts
export { ChannelLoopStore } from './ChannelLoopStore.js';
export type { ChannelLoop, ChannelLoopInput, ChannelLoopPatch } from './ChannelLoopStore.js';
export type {
ChannelLoop,
ChannelLoopInput,
ChannelLoopPatch,
} from './ChannelLoopStore.js';
export { channelLoopPath } from './paths.js';
export { nextFireTime, parseCron } from './utils/cronParser.js';
```
Expand All @@ -448,6 +463,7 @@ Expected: 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`
Expand Down Expand Up @@ -509,6 +525,7 @@ Expected: 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`

Expand Down Expand Up @@ -541,8 +558,14 @@ Add:
```ts
export interface ChannelLoopController {
create(input: ChannelLoopInput): Promise<ChannelLoop>;
createForTarget?(input: ChannelLoopInput, maxEnabledLoops: number): Promise<ChannelLoop | undefined>;
listForTarget(channelName: string, target: SessionTarget): 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;
Expand Down Expand Up @@ -611,6 +634,7 @@ Expected: 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`

Expand Down Expand Up @@ -671,6 +695,7 @@ Expected: 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`
Expand Down Expand Up @@ -715,6 +740,7 @@ Expected: 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`

Expand Down Expand Up @@ -787,6 +813,7 @@ Expected: all CLI start tests pass.
## Task 7: Verification And PR

**Files:**

- Modify: `.qwen/pr-drafts/channel-loop.md`

- [ ] **Step 1: Run focused verification**
Expand Down
4 changes: 1 addition & 3 deletions packages/chrome-extension/src/background/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,7 @@ function onWsMessage(ws: WebSocket, data: unknown): void {
// CDP-tunnel frames: route to the bridge, which drives the tab via
// chrome.debugger and pushes results/events back over the active socket.
if (isCdpBridgeFrame(msg['type'])) {
handleCdpFrame(msg as { type?: unknown }, (frame) =>
sendRaw(ws, frame),
);
handleCdpFrame(msg as { type?: unknown }, (frame) => sendRaw(ws, frame));
return;
}
// Other frame types (chat/session traffic) aren't ours; ignore.
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -1436,8 +1436,8 @@ export default {
'Switch the model for this session (--fast for suggestion model, [model-id] to switch immediately).',
'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, [model-id] to switch immediately).':
'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, [model-id] to switch immediately).',
'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).':
'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).',
'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, --project to persist to project settings, --global to persist to user settings, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).':
'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, --project to persist to project settings, --global to persist to user settings, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).',
"Inline one-shot override isn't supported in this mode — run '/model {{model}}' first, then send your prompt.":
"Inline one-shot override isn't supported in this mode — run '/model {{model}}' first, then send your prompt.",
"Inline one-shot override can't switch providers. '{{model}}' belongs to a different provider — run '/model {{model}}' first, then send your prompt.":
Expand All @@ -1451,6 +1451,10 @@ export default {
'Set the model for voice transcription',
'Set the image-capable model used to transcribe images for a text-only main model':
'Set the image-capable model used to transcribe images for a text-only main model',
'Persist the model selection to the project settings (workspace scope)':
'Persist the model selection to the project settings (workspace scope)',
'Persist the model selection to the user settings (global scope)':
'Persist the model selection to the user settings (global scope)',
'Select Fast Model': 'Select Fast Model',
'Select Vision Model': 'Select Vision Model',
'Select Voice Model': 'Select Voice Model',
Expand Down Expand Up @@ -1687,6 +1691,8 @@ export default {
// Dialogs - Model
// ============================================================================
'Select Model': 'Select Model',
' (this project)': ' (this project)',
' (global)': ' (global)',
'API Key': 'API Key',
'(default)': '(default)',
'(not set)': '(not set)',
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/i18n/locales/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -1288,6 +1288,12 @@ export default {
// Boîtes de dialogue - Modèle
// ============================================================================
'Select Model': 'Sélectionner un modèle',
' (this project)': ' (ce projet)',
' (global)': ' (global)',
'Persist the model selection to the project settings (workspace scope)':
'Persister la sélection du modèle dans les paramètres du projet (étendue workspace)',
'Persist the model selection to the user settings (global scope)':
'Persister la sélection du modèle dans les paramètres utilisateur (étendue globale)',
'API Key': 'API Key',
'(default)': '(par défaut)',
'(not set)': '(non défini)',
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/i18n/locales/ja.js
Original file line number Diff line number Diff line change
Expand Up @@ -1741,6 +1741,12 @@ export default {
'ANTHROPIC_BASE_URL environment variable not found.':
'環境変数 ANTHROPIC_BASE_URL が見つかりません。',
'Invalid auth method selected.': '無効な認証方式が選択されました。',
' (this project)': ' (このプロジェクト)',
' (global)': ' (グローバル)',
'Persist the model selection to the project settings (workspace scope)':
'モデルの選択をプロジェクト設定に永続化(ワークスペーススコープ)',
'Persist the model selection to the user settings (global scope)':
'モデルの選択をユーザー設定に永続化(グローバルスコープ)',
'API Key': 'API Key',
'(default)': '(デフォルト)',
'(not set)': '(未設定)',
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/i18n/locales/zh-TW.js
Original file line number Diff line number Diff line change
Expand Up @@ -1262,8 +1262,8 @@ export default {
'切換此會話的模型(--fast 可設置建議模型)',
'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, [model-id] to switch immediately).':
'切換此會話的模型(--fast 可設置建議模型,--voice 可設置語音轉寫模型,[model-id] 可立即切換)',
'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).':
'切換此會話的模型(--fast 可設置建議模型,--voice 可設置語音轉寫模型,--vision 可設置視覺橋接模型,[model-id] 可立即切換,或用 [model-id] [prompt] 在另一個模型上執行一次性提示;內聯提示按原文發送,不展開 @file)',
'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, --project to persist to project settings, --global to persist to user settings, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).':
'切換此會話的模型(--fast 建議模型,--voice 語音轉寫模型,--vision 視覺橋接模型,--project 持久化到專案設定,--global 持久化到使用者設定,[model-id] 立即切換,或用 [model-id] [prompt] 在另一個模型上執行一次性提示;內聯提示按原文發送,不展開 @file)',
"Inline one-shot override isn't supported in this mode — run '/model {{model}}' first, then send your prompt.":
"此模式不支援內聯一次性覆寫——請先執行 '/model {{model}}',再發送你的提示。",
"Inline one-shot override can't switch providers. '{{model}}' belongs to a different provider — run '/model {{model}}' first, then send your prompt.":
Expand All @@ -1276,6 +1276,10 @@ export default {
'Set the model for voice transcription': '設定語音轉寫模型',
'Set the image-capable model used to transcribe images for a text-only main model':
'設定用於為純文字主模型轉寫圖像的圖像能力模型',
'Persist the model selection to the project settings (workspace scope)':
'將模型選擇持久化到專案設定(工作區)',
'Persist the model selection to the user settings (global scope)':
'將模型選擇持久化到使用者設定(全域)',
'Select Fast Model': '選擇快速模型',
'Select Vision Model': '選擇視覺模型',
'Select Voice Model': '選擇語音模型',
Expand Down Expand Up @@ -1469,6 +1473,8 @@ export default {
'Invalid QWEN_DEFAULT_AUTH_TYPE value: "{{value}}". Valid values are: {{validValues}}':
'無效的 QWEN_DEFAULT_AUTH_TYPE 值:"{{value}}"。有效值為:{{validValues}}',
'Select Model': '選擇模型',
' (this project)': '(當前專案)',
' (global)': '(全域)',
'API Key': 'API Key',
'(default)': '(默認)',
'(not set)': '(未設置)',
Expand Down
10 changes: 8 additions & 2 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -1376,8 +1376,8 @@ export default {
'切换此会话的模型(--fast 可设置建议模型)',
'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, [model-id] to switch immediately).':
'切换此会话的模型(--fast 可设置建议模型,--voice 可设置语音转写模型,[model-id] 可立即切换)',
'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).':
'切换此会话的模型(--fast 可设置建议模型,--voice 可设置语音转写模型,--vision 可设置视觉桥接模型,[model-id] 可立即切换,或用 [model-id] [prompt] 在另一个模型上运行一次性提示;内联提示按原文发送,不展开 @file)',
'Switch the model for this session (--fast for suggestion model, --voice for voice transcription model, --vision for the vision bridge model, --project to persist to project settings, --global to persist to user settings, [model-id] to switch immediately, or [model-id] [prompt] to run a one-off prompt on another model; the inline prompt is sent verbatim without @file expansion).':
'切换此会话的模型(--fast 建议模型,--voice 语音转写模型,--vision 视觉桥接模型,--project 持久化到项目设置,--global 持久化到用户设置,[model-id] 立即切换,或用 [model-id] [prompt] 在另一个模型上运行一次性提示;内联提示按原文发送,不展开 @file)',
"Inline one-shot override isn't supported in this mode — run '/model {{model}}' first, then send your prompt.":
"此模式不支持内联一次性覆盖——请先运行 '/model {{model}}',再发送你的提示。",
"Inline one-shot override can't switch providers. '{{model}}' belongs to a different provider — run '/model {{model}}' first, then send your prompt.":
Expand All @@ -1390,6 +1390,10 @@ export default {
'Set the model for voice transcription': '设置语音转写模型',
'Set the image-capable model used to transcribe images for a text-only main model':
'设置用于为纯文本主模型转写图像的图像能力模型',
'Persist the model selection to the project settings (workspace scope)':
'将模型选择持久化到项目设置(工作区)',
'Persist the model selection to the user settings (global scope)':
'将模型选择持久化到用户设置(全局)',
'Select Fast Model': '选择快速模型',
'Select Vision Model': '选择视觉模型',
'Select Voice Model': '选择语音模型',
Expand Down Expand Up @@ -1611,6 +1615,8 @@ export default {
// Dialogs - Model
// ============================================================================
'Select Model': '选择模型',
' (this project)': '(当前项目)',
' (global)': '(全局)',
'API Key': 'API Key',
'(default)': '(默认)',
'(not set)': '(未设置)',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/test-utils/mockCommandContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export const createMockCommandContext = (
settings: {
merged: {},
setValue: vi.fn(),
isTrusted: true,
} as unknown as LoadedSettings,
logger: {
log: vi.fn(),
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1158,6 +1158,7 @@ export const AppContainer = (props: AppContainerProps) => {
isFastModelMode,
isVoiceModelMode,
isVisionModelMode,
modelDialogPersistScope,
openModelDialog,
closeModelDialog,
} = useModelCommand();
Expand Down Expand Up @@ -3747,6 +3748,7 @@ export const AppContainer = (props: AppContainerProps) => {
isFastModelMode,
isVoiceModelMode,
isVisionModelMode,
modelDialogPersistScope,
isTrustDialogOpen,
activeArenaDialog,
isPermissionsDialogOpen,
Expand Down Expand Up @@ -3887,6 +3889,7 @@ export const AppContainer = (props: AppContainerProps) => {
isFastModelMode,
isVoiceModelMode,
isVisionModelMode,
modelDialogPersistScope,
isTrustDialogOpen,
activeArenaDialog,
isPermissionsDialogOpen,
Expand Down
Loading
Loading