Merge pull request #2731 from QwenLM/feat/in-session-cron-loops

feat(cron): add in-session loop scheduling with cron tools
This commit is contained in:
tanzhenxin 2026-04-01 16:18:46 +08:00 committed by GitHub
commit 76d64c9464
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
60 changed files with 3110 additions and 41 deletions

View file

@ -134,7 +134,9 @@ jobs:
run: | run: |
npm run preflight npm run preflight
npm run test:integration:cli:sandbox:none npm run test:integration:cli:sandbox:none
npm run test:integration:interactive:sandbox:none
npm run test:integration:cli:sandbox:docker npm run test:integration:cli:sandbox:docker
npm run test:integration:interactive:sandbox:docker
env: env:
OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}' OPENAI_API_KEY: '${{ secrets.OPENAI_API_KEY }}'
OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}' OPENAI_BASE_URL: '${{ secrets.OPENAI_BASE_URL }}'

View file

@ -14,4 +14,5 @@ export default {
sandbox: 'Sandboxing', sandbox: 'Sandboxing',
language: 'i18n', language: 'i18n',
hooks: 'Hooks', hooks: 'Hooks',
'scheduled-tasks': 'Scheduled Tasks',
}; };

View file

@ -0,0 +1,139 @@
# Run Prompts on a Schedule
> Use `/loop` and the cron scheduling tools to run prompts repeatedly, poll for status, or set one-time reminders within a Qwen Code session.
Scheduled tasks let Qwen Code re-run a prompt automatically on an interval. Use them to poll a deployment, babysit a PR, check back on a long-running build, or remind yourself to do something later in the session.
Tasks are session-scoped: they live in the current Qwen Code process and are gone when you exit. Nothing is written to disk.
> **Note:** Scheduled tasks are an experimental feature. Enable them with `experimental.cron: true` in your [settings](../configuration/settings.md), or set `QWEN_CODE_ENABLE_CRON=1` in your environment.
## Schedule a recurring prompt with /loop
The `/loop` [bundled skill](skills.md) is the quickest way to schedule a recurring prompt. Pass an optional interval and a prompt, and Qwen Code sets up a cron job that fires in the background while the session stays open.
```text
/loop 5m check if the deployment finished and tell me what happened
```
Qwen Code parses the interval, converts it to a cron expression, schedules the job, and confirms the cadence and job ID. It then immediately executes the prompt once — you don't have to wait for the first cron fire.
### Interval syntax
Intervals are optional. You can lead with them, trail with them, or leave them out entirely.
| Form | Example | Parsed interval |
| :---------------------- | :------------------------------------ | :--------------------------- |
| Leading token | `/loop 30m check the build` | every 30 minutes |
| Trailing `every` clause | `/loop check the build every 2 hours` | every 2 hours |
| No interval | `/loop check the build` | defaults to every 10 minutes |
Supported units are `s` for seconds, `m` for minutes, `h` for hours, and `d` for days. Seconds are rounded up to the nearest minute since cron has one-minute granularity. Intervals that don't divide evenly into their unit, such as `7m` or `90m`, are rounded to the nearest clean interval and Qwen Code tells you what it picked.
### Loop over another command
The scheduled prompt can itself be a command or skill invocation. This is useful for re-running a workflow you've already packaged.
```text
/loop 20m /review-pr 1234
```
Each time the job fires, Qwen Code runs `/review-pr 1234` as if you had typed it.
### Manage loops
`/loop` also supports two subcommands for managing existing jobs:
```text
/loop list
```
Lists all scheduled jobs with their IDs and cron expressions.
```text
/loop clear
```
Cancels all scheduled jobs at once.
## Set a one-time reminder
For one-shot reminders, describe what you want in natural language instead of using `/loop`. Qwen Code schedules a single-fire task that deletes itself after running.
```text
remind me at 3pm to push the release branch
```
```text
in 45 minutes, check whether the integration tests passed
```
Qwen Code pins the fire time to a specific minute and hour using a cron expression and confirms when it will fire.
## Manage scheduled tasks
Ask Qwen Code in natural language to list or cancel tasks, or reference the underlying tools directly.
```text
what scheduled tasks do I have?
```
```text
cancel the deploy check job
```
Under the hood, Qwen Code uses these tools:
| Tool | Purpose |
| :----------- | :-------------------------------------------------------------------------------------------------------------- |
| `CronCreate` | Schedule a new task. Accepts a 5-field cron expression, the prompt to run, and whether it recurs or fires once. |
| `CronList` | List all scheduled tasks with their IDs, schedules, and prompts. |
| `CronDelete` | Cancel a task by ID. |
Each scheduled task has an 8-character ID you can pass to `CronDelete`. A session can hold up to 50 scheduled tasks at once.
## How scheduled tasks run
The scheduler checks every second for due tasks and enqueues them when the session is idle. A scheduled prompt fires between your turns, not while Qwen Code is mid-response. If Qwen Code is busy when a task comes due, the prompt waits until the current turn ends.
All times are interpreted in your local timezone. A cron expression like `0 9 * * *` means 9am wherever you're running Qwen Code, not UTC.
### Jitter
To avoid every session hitting the API at the same wall-clock moment, the scheduler adds a small deterministic offset to fire times:
- **Recurring tasks** fire up to 10% of their period late, capped at 15 minutes. An hourly job might fire anywhere from `:00` to `:06`.
- **One-shot tasks** scheduled for the top or bottom of the hour (minute `:00` or `:30`) fire up to 90 seconds early.
The offset is derived from the task ID, so the same task always gets the same offset. If exact timing matters, pick a minute that is not `:00` or `:30`, for example `3 9 * * *` instead of `0 9 * * *`, and the one-shot jitter will not apply.
### Three-day expiry
Recurring tasks automatically expire 3 days after creation. The task fires one final time, then deletes itself. This bounds how long a forgotten loop can run. If you need a recurring task to last longer, cancel and recreate it before it expires.
One-shot tasks do not expire on a timer — they simply delete themselves after firing once.
## Cron expression reference
`CronCreate` accepts standard 5-field cron expressions: `minute hour day-of-month month day-of-week`. All fields support wildcards (`*`), single values (`5`), steps (`*/15`), ranges (`1-5`), and comma-separated lists (`1,15,30`).
| Example | Meaning |
| :------------- | :--------------------------- |
| `*/5 * * * *` | Every 5 minutes |
| `0 * * * *` | Every hour on the hour |
| `7 * * * *` | Every hour at 7 minutes past |
| `0 9 * * *` | Every day at 9am local |
| `0 9 * * 1-5` | Weekdays at 9am local |
| `30 14 15 3 *` | March 15 at 2:30pm local |
Day-of-week uses `0` or `7` for Sunday through `6` for Saturday. When both day-of-month and day-of-week are constrained (neither is `*`), a date matches if either field matches — this follows standard vixie-cron semantics.
Extended syntax like `L`, `W`, `?`, and name aliases such as `MON` or `JAN` is not supported.
## Limitations
Session-scoped scheduling has inherent constraints:
- Tasks only fire while Qwen Code is running and idle. Closing the terminal or letting the session exit cancels everything.
- No catch-up for missed fires. If a task's scheduled time passes while Qwen Code is busy on a long-running request, it fires once when Qwen Code becomes idle, not once per missed interval.
- No persistence across restarts. Restarting Qwen Code clears all session-scoped tasks.

View file

@ -0,0 +1,380 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* ACP integration tests for in-session cron/loop scheduling.
*
* These verify that cron jobs created during an ACP session fire correctly
* and stream results back to the client via sessionUpdate notifications,
* even after the originating prompt has already returned.
*
* The two tests share one ACP session to stay within 2 minutes total:
* 1. Fast smoke test cron tools available (no cron fire needed)
* 2. Combined test create job, verify session responsive, wait for
* cron fire, check content + _meta.source, then clean up
*/
import { spawn } from 'node:child_process';
import { readFileSync, writeFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { setTimeout as delay } from 'node:timers/promises';
import { describe, it, expect } from 'vitest';
import { TestRig } from '../test-helper.js';
const REQUEST_TIMEOUT_MS = 60_000;
const IS_SANDBOX =
process.env['QWEN_SANDBOX'] &&
process.env['QWEN_SANDBOX']!.toLowerCase() !== 'false';
type PendingRequest = {
resolve: (value: unknown) => void;
reject: (reason: Error) => void;
timeout: NodeJS.Timeout;
};
type SessionUpdateNotification = {
sessionId?: string;
update?: {
sessionUpdate?: string;
content?: {
type: string;
text?: string;
};
title?: string;
toolCallId?: string;
status?: string;
_meta?: Record<string, unknown>;
[key: string]: unknown;
};
};
type PermissionRequest = {
id: number;
sessionId?: string;
toolCall?: {
toolCallId: string;
title: string;
kind: string;
status: string;
};
options?: Array<{
optionId: string;
name: string;
kind: string;
}>;
};
/**
* Sets up an ACP test environment with cron support enabled.
*/
function setupAcpCronTest(rig: TestRig) {
const pending = new Map<number, PendingRequest>();
let nextRequestId = 1;
const sessionUpdates: (SessionUpdateNotification & {
receivedAt: number;
})[] = [];
const stderr: string[] = [];
const agent = spawn(
'node',
[rig.bundlePath, '--acp', '--no-chat-recording'],
{
cwd: rig.testDir!,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
...process.env,
QWEN_CODE_ENABLE_CRON: '1',
},
},
);
agent.stderr?.on('data', (chunk: Buffer) => {
stderr.push(chunk.toString());
});
const rl = createInterface({ input: agent.stdout });
const send = (json: unknown) => {
agent.stdin.write(`${JSON.stringify(json)}\n`);
};
const sendResponse = (id: number, result: unknown) => {
send({ jsonrpc: '2.0', id, result });
};
const sendRequest = (method: string, params?: unknown) =>
new Promise<unknown>((resolve, reject) => {
const id = nextRequestId++;
const timeout = setTimeout(() => {
pending.delete(id);
reject(new Error(`Request ${id} (${method}) timed out`));
}, REQUEST_TIMEOUT_MS);
pending.set(id, { resolve, reject, timeout });
send({ jsonrpc: '2.0', id, method, params });
});
const handleResponse = (msg: {
id: number;
result?: unknown;
error?: { message?: string };
}) => {
const waiter = pending.get(msg.id);
if (!waiter) return;
clearTimeout(waiter.timeout);
pending.delete(msg.id);
if (msg.error) {
const error = new Error(msg.error.message ?? 'Unknown error');
(error as Error & { response?: unknown }).response = msg.error;
waiter.reject(error);
} else {
waiter.resolve(msg.result);
}
};
const handleMessage = (msg: {
id?: number;
method?: string;
params?: SessionUpdateNotification & {
path?: string;
content?: string;
sessionId?: string;
toolCall?: PermissionRequest['toolCall'];
options?: PermissionRequest['options'];
};
result?: unknown;
error?: { message?: string };
}) => {
if (typeof msg.id !== 'undefined' && ('result' in msg || 'error' in msg)) {
handleResponse(
msg as {
id: number;
result?: unknown;
error?: { message?: string };
},
);
return;
}
if (msg.method === 'session/update') {
sessionUpdates.push({
sessionId: msg.params?.sessionId,
update: msg.params?.update,
receivedAt: Date.now(),
});
return;
}
if (
msg.method === 'session/request_permission' &&
typeof msg.id === 'number'
) {
sendResponse(msg.id, {
outcome: { optionId: 'proceed_once', outcome: 'selected' },
});
return;
}
if (msg.method === 'fs/read_text_file' && typeof msg.id === 'number') {
try {
const content = readFileSync(msg.params?.path ?? '', 'utf8');
sendResponse(msg.id, { content });
} catch (e) {
sendResponse(msg.id, { content: `ERROR: ${(e as Error).message}` });
}
return;
}
if (msg.method === 'fs/write_text_file' && typeof msg.id === 'number') {
try {
writeFileSync(
msg.params?.path ?? '',
msg.params?.content ?? '',
'utf8',
);
sendResponse(msg.id, null);
} catch (e) {
sendResponse(msg.id, { message: (e as Error).message });
}
}
};
rl.on('line', (line: string) => {
if (!line.trim()) return;
try {
const msg = JSON.parse(line);
handleMessage(msg);
} catch {
// Ignore non-JSON output
}
});
/**
* Polls sessionUpdates until a notification matching the predicate appears,
* or the timeout expires.
*/
const waitForSessionUpdate = async (
predicate: (
update: SessionUpdateNotification & { receivedAt: number },
) => boolean,
description: string,
timeoutMs: number,
): Promise<SessionUpdateNotification & { receivedAt: number }> => {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const match = sessionUpdates.find(predicate);
if (match) return match;
await delay(500);
}
throw new Error(
`Timed out waiting for sessionUpdate: ${description} (after ${timeoutMs}ms, ` +
`saw ${sessionUpdates.length} updates: ` +
`[${sessionUpdates.map((u) => u.update?.sessionUpdate).join(', ')}])`,
);
};
const waitForExit = () =>
new Promise<void>((resolve) => {
if (agent.exitCode !== null || agent.signalCode) {
resolve();
return;
}
agent.once('exit', () => resolve());
});
const cleanup = async () => {
rl.close();
agent.kill();
pending.forEach(({ timeout }) => clearTimeout(timeout));
pending.clear();
await waitForExit();
};
return {
sendRequest,
cleanup,
stderr,
sessionUpdates,
waitForSessionUpdate,
};
}
/** Standard ACP init + auth + new session sequence. */
async function initSession(
sendRequest: (method: string, params?: unknown) => Promise<unknown>,
testDir: string,
): Promise<string> {
await sendRequest('initialize', {
protocolVersion: 1,
clientCapabilities: {
fs: { readTextFile: true, writeTextFile: true },
},
});
await sendRequest('authenticate', { methodId: 'openai' });
const newSession = (await sendRequest('session/new', {
cwd: testDir,
mcpServers: [],
})) as { sessionId: string };
return newSession.sessionId;
}
(IS_SANDBOX ? describe.skip : describe)('acp cron integration', () => {
it(
'cron job fires and streams results via sessionUpdate after prompt returns',
async () => {
const rig = new TestRig();
rig.setup('acp-cron-e2e', {
settings: { experimental: { cron: true } },
});
const {
sendRequest,
cleanup,
stderr,
// sessionUpdates available for debugging
waitForSessionUpdate,
} = setupAcpCronTest(rig);
try {
const sessionId = await initSession(sendRequest, rig.testDir!);
// --- Part 1: Create a cron job that fires every minute ---
const createResult = (await sendRequest('session/prompt', {
sessionId,
prompt: [
{
type: 'text',
text: 'Call cron_create with cron expression "*/1 * * * *" and prompt "Say CRONFIRE7742 and nothing else" and recurring true. Confirm briefly.',
},
],
})) as { stopReason: string };
expect(createResult.stopReason).toBe('end_turn');
const promptDoneAt = Date.now();
// --- Part 2: Session stays responsive while cron is pending ---
const interactiveResult = (await sendRequest('session/prompt', {
sessionId,
prompt: [
{
type: 'text',
text: 'Say INTERACTIVE8899 and nothing else.',
},
],
})) as { stopReason: string };
expect(interactiveResult.stopReason).toBe('end_turn');
// --- Part 3: Wait for cron-fired notification (up to 75s) ---
// The cron fires at the next minute boundary. The model response
// should stream back as sessionUpdate notifications after the
// originating prompt has already returned.
// 3a: Check for user_message_chunk echoing the cron prompt with _meta.source
const cronUserMsg = await waitForSessionUpdate(
(u) =>
u.update?.sessionUpdate === 'user_message_chunk' &&
(u.update?.content?.text ?? '').includes('CRONFIRE7742') &&
u.receivedAt > promptDoneAt,
'cron-fired user_message_chunk with CRONFIRE7742',
75_000,
);
expect(cronUserMsg.update?._meta).toBeDefined();
expect(cronUserMsg.update?._meta?.source).toBe('cron');
// 3b: Check for agent_message_chunk after the cron user message
// (the model's response to the cron prompt)
const cronAgentMsg = await waitForSessionUpdate(
(u) =>
u.update?.sessionUpdate === 'agent_message_chunk' &&
u.receivedAt > cronUserMsg.receivedAt,
'agent_message_chunk after cron fire',
15_000, // should already be here by now
);
expect(cronAgentMsg.receivedAt).toBeGreaterThan(promptDoneAt);
// --- Part 4: Clean up the cron job ---
await sendRequest('session/prompt', {
sessionId,
prompt: [
{
type: 'text',
text: 'Delete all cron jobs using cron_delete.',
},
],
});
} catch (e) {
if (stderr.length) console.error('Agent stderr:', stderr.join(''));
throw e;
} finally {
await cleanup();
}
},
{ timeout: 120_000, retry: 0 },
);
});

View file

@ -9,7 +9,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
import { createInterface } from 'node:readline'; import { createInterface } from 'node:readline';
import { setTimeout as delay } from 'node:timers/promises'; import { setTimeout as delay } from 'node:timers/promises';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { TestRig } from './test-helper.js'; import { TestRig } from '../test-helper.js';
const REQUEST_TIMEOUT_MS = 60_000; const REQUEST_TIMEOUT_MS = 60_000;
const INITIAL_PROMPT = 'Create a quick note (smoke test).'; const INITIAL_PROMPT = 'Create a quick note (smoke test).';

View file

@ -0,0 +1,131 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, afterEach } from 'vitest';
import {
TestRig,
printDebugInfo,
validateModelOutput,
} from '../test-helper.js';
describe('cron-tools', () => {
let rig: TestRig;
afterEach(async () => {
if (rig) {
await rig.cleanup();
}
// Clean up env vars
delete process.env['QWEN_CODE_ENABLE_CRON'];
});
it('should have cron tools registered when enabled via settings', async () => {
rig = new TestRig();
await rig.setup('cron-tools-registered', {
settings: { experimental: { cron: true } },
});
const result = await rig.run(
'Do you have access to tools called cron_create, cron_list, and cron_delete? Reply with just "yes" or "no".',
);
validateModelOutput(result, null, 'cron tools registered');
expect(result.toLowerCase()).toContain('yes');
});
it('should have cron tools registered when enabled via env var', async () => {
rig = new TestRig();
await rig.setup('cron-tools-env-var');
process.env['QWEN_CODE_ENABLE_CRON'] = '1';
const result = await rig.run(
'Do you have access to tools called cron_create, cron_list, and cron_delete? Reply with just "yes" or "no".',
);
validateModelOutput(result, null, 'cron tools via env var');
expect(result.toLowerCase()).toContain('yes');
});
it('should NOT have cron tools by default', async () => {
rig = new TestRig();
await rig.setup('cron-tools-disabled-by-default');
const result = await rig.run(
'Do you have access to a tool called cron_create? Reply with just "yes" or "no".',
);
validateModelOutput(result, null, 'cron disabled by default');
expect(result.toLowerCase()).toContain('no');
});
it('should create, list, and delete a cron job in a single turn', async () => {
rig = new TestRig();
await rig.setup('cron-create-list-delete', {
settings: { experimental: { cron: true } },
});
const result = await rig.run(
'Call cron_create with cron "*/5 * * * *", prompt "test ping", recurring true. Then call cron_list. Then delete that job using cron_delete. Then call cron_list again. How many jobs remain? Reply with just the number.',
);
const foundCreate = await rig.waitForToolCall('cron_create');
const foundList = await rig.waitForToolCall('cron_list');
const foundDelete = await rig.waitForToolCall('cron_delete');
if (!foundCreate || !foundList || !foundDelete) {
printDebugInfo(rig, result, {
'cron_create found': foundCreate,
'cron_list found': foundList,
'cron_delete found': foundDelete,
});
}
expect(foundCreate, 'Expected cron_create tool call').toBeTruthy();
expect(foundList, 'Expected cron_list tool call').toBeTruthy();
expect(foundDelete, 'Expected cron_delete tool call').toBeTruthy();
validateModelOutput(result, '0', 'cron create-list-delete');
});
it('should create a one-shot (non-recurring) job', async () => {
rig = new TestRig();
await rig.setup('cron-one-shot', {
settings: { experimental: { cron: true } },
});
const result = await rig.run(
'Do these steps: (1) Call cron_create with cron "*/5 * * * *", prompt "one-shot test", recurring false. (2) Call cron_list. Is the job marked as recurring or one-shot? Remember the answer. (3) Delete all cron jobs. Reply with just "recurring" or "one-shot".',
);
const foundCreate = await rig.waitForToolCall('cron_create');
const foundList = await rig.waitForToolCall('cron_list');
if (!foundCreate || !foundList) {
printDebugInfo(rig, result, {
'cron_create found': foundCreate,
'cron_list found': foundList,
});
}
expect(foundCreate, 'Expected cron_create tool call').toBeTruthy();
expect(foundList, 'Expected cron_list tool call').toBeTruthy();
validateModelOutput(result, 'one-shot', 'cron one-shot');
});
it('should exit normally in -p mode when no cron jobs are created', async () => {
rig = new TestRig();
await rig.setup('cron-no-jobs-exit', {
settings: { experimental: { cron: true } },
});
// A normal -p call without cron should still exit quickly
const result = await rig.run('What is 2+2? Reply with just the number.');
validateModelOutput(result, '4', 'no cron exit');
});
});

View file

@ -5,7 +5,11 @@
*/ */
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; import {
TestRig,
printDebugInfo,
validateModelOutput,
} from '../test-helper.js';
describe('edit', () => { describe('edit', () => {
it('should be able to edit content in a file', async () => { it('should be able to edit content in a file', async () => {

View file

@ -5,7 +5,7 @@
*/ */
import { expect, test } from 'vitest'; import { expect, test } from 'vitest';
import { TestRig } from './test-helper.js'; import { TestRig } from '../test-helper.js';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';

View file

@ -5,7 +5,11 @@
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; import {
TestRig,
printDebugInfo,
validateModelOutput,
} from '../test-helper.js';
describe('file-system', () => { describe('file-system', () => {
it('should be able to read a file', async () => { it('should be able to read a file', async () => {

View file

@ -5,7 +5,7 @@
*/ */
import { expect, describe, it, beforeEach, afterEach } from 'vitest'; import { expect, describe, it, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js'; import { TestRig } from '../test-helper.js';
describe('JSON output', () => { describe('JSON output', () => {
let rig: TestRig; let rig: TestRig;

View file

@ -5,7 +5,11 @@
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; import {
TestRig,
printDebugInfo,
validateModelOutput,
} from '../test-helper.js';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';

View file

@ -24,7 +24,7 @@
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { beforeAll, describe, expect, it } from 'vitest'; import { beforeAll, describe, expect, it } from 'vitest';
import { TestRig } from './test-helper.js'; import { TestRig } from '../test-helper.js';
// Create a minimal MCP server that doesn't require external dependencies // Create a minimal MCP server that doesn't require external dependencies
// This implements the MCP protocol directly using Node.js built-ins // This implements the MCP protocol directly using Node.js built-ins

View file

@ -5,7 +5,11 @@
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; import {
TestRig,
printDebugInfo,
validateModelOutput,
} from '../test-helper.js';
describe('read_many_files', () => { describe('read_many_files', () => {
it.skip('should be able to read multiple files', async () => { it.skip('should be able to read multiple files', async () => {

View file

@ -5,7 +5,11 @@
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; import {
TestRig,
printDebugInfo,
validateModelOutput,
} from '../test-helper.js';
describe('run_shell_command', () => { describe('run_shell_command', () => {
it('should be able to run a shell command', async () => { it('should be able to run a shell command', async () => {

View file

@ -5,7 +5,11 @@
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; import {
TestRig,
printDebugInfo,
validateModelOutput,
} from '../test-helper.js';
describe('save_memory', () => { describe('save_memory', () => {
// Skipped due to flaky model behavior - the model sometimes answers the question // Skipped due to flaky model behavior - the model sometimes answers the question

View file

@ -5,12 +5,12 @@
*/ */
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js'; import { TestRig } from '../test-helper.js';
import { writeFileSync, readFileSync } from 'node:fs'; import { writeFileSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
// Import settings fixtures from unified workspace file // Import settings fixtures from unified workspace file
import workspacesSettings from './fixtures/settings-migration/workspaces.json' with { type: 'json' }; import workspacesSettings from '../fixtures/settings-migration/workspaces.json' with { type: 'json' };
const { const {
v1Settings, v1Settings,

View file

@ -11,7 +11,7 @@
*/ */
import { describe, it, beforeAll, expect } from 'vitest'; import { describe, it, beforeAll, expect } from 'vitest';
import { TestRig, validateModelOutput } from './test-helper.js'; import { TestRig, validateModelOutput } from '../test-helper.js';
import { join } from 'node:path'; import { join } from 'node:path';
import { writeFileSync } from 'node:fs'; import { writeFileSync } from 'node:fs';

View file

@ -5,7 +5,11 @@
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; import {
TestRig,
printDebugInfo,
validateModelOutput,
} from '../test-helper.js';
describe.skip('stdin context', () => { describe.skip('stdin context', () => {
it('should be able to use stdin as context for a prompt', async () => { it('should be able to use stdin as context for a prompt', async () => {

View file

@ -5,7 +5,7 @@
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { TestRig } from './test-helper.js'; import { TestRig } from '../test-helper.js';
describe('telemetry', () => { describe('telemetry', () => {
it('should emit a metric and a log event', async () => { it('should emit a metric and a log event', async () => {

View file

@ -5,7 +5,11 @@
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; import {
TestRig,
printDebugInfo,
validateModelOutput,
} from '../test-helper.js';
describe('todo_write', () => { describe('todo_write', () => {
it('should be able to create and manage a todo list', async () => { it('should be able to create and manage a todo list', async () => {

View file

@ -7,7 +7,7 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { writeFileSync, readFileSync } from 'node:fs'; import { writeFileSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { TestRig } from './test-helper.js'; import { TestRig } from '../test-helper.js';
// Windows skip (Option A: avoid infra scope) // Windows skip (Option A: avoid infra scope)
const d = process.platform === 'win32' ? describe.skip : describe; const d = process.platform === 'win32' ? describe.skip : describe;

View file

@ -5,7 +5,11 @@
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { TestRig, printDebugInfo, validateModelOutput } from './test-helper.js'; import {
TestRig,
printDebugInfo,
validateModelOutput,
} from '../test-helper.js';
describe('web_search', () => { describe('web_search', () => {
it('should be able to search the web', async () => { it('should be able to search the web', async () => {

View file

@ -10,7 +10,7 @@ import {
createToolCallErrorMessage, createToolCallErrorMessage,
printDebugInfo, printDebugInfo,
validateModelOutput, validateModelOutput,
} from './test-helper.js'; } from '../test-helper.js';
describe('write_file', () => { describe('write_file', () => {
it('should be able to write a file', async () => { it('should be able to write a file', async () => {

View file

@ -5,7 +5,7 @@
*/ */
import { expect, describe, it, beforeEach, afterEach } from 'vitest'; import { expect, describe, it, beforeEach, afterEach } from 'vitest';
import { TestRig, type } from './test-helper.js'; import { TestRig, type } from '../test-helper.js';
describe('Interactive Mode', () => { describe('Interactive Mode', () => {
let rig: TestRig; let rig: TestRig;

View file

@ -0,0 +1,140 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* In-session cron/loop interactive E2E tests.
*
* These drive the full interactive TUI via InteractiveSession (node-pty +
* @xterm/headless) and read the rendered terminal screen. No browser needed.
*
* Ported from the standalone script at
* terminal-capture/test-cron-interactive-e2e.ts.
*/
import { describe, it, expect, afterEach } from 'vitest';
import { InteractiveSession } from './interactive-session.js';
function makeEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
delete env['NO_COLOR'];
return {
...env,
QWEN_CODE_ENABLE_CRON: '1',
FORCE_COLOR: '1',
TERM: 'xterm-256color',
NODE_NO_WARNINGS: '1',
};
}
describe('cron interactive', () => {
let session: InteractiveSession | null = null;
afterEach(async () => {
if (session) {
await session.close();
session = null;
}
});
it(
'loop fires inline in conversation',
async () => {
session = await InteractiveSession.start({
env: makeEnv(),
args: ['--approval-mode', 'yolo'],
});
await session.send(
'Call cron_create with expression "*/1 * * * *" and prompt "PONG7742" and recurring true. Confirm briefly.',
);
await session.waitForScreen(
(scr) => scr.split('\n').some((l) => l.trim() === '> PONG7742'),
'cron-injected prompt "> PONG7742"',
90_000,
);
await session.idle(5000);
const finalScreen = await session.screen();
const afterPrompt = finalScreen.slice(
finalScreen.lastIndexOf('> PONG7742'),
);
expect(afterPrompt).toContain('✦');
},
{ timeout: 180_000 },
);
it(
'user input takes priority over cron',
async () => {
session = await InteractiveSession.start({
env: makeEnv(),
args: ['--approval-mode', 'yolo'],
});
await session.send(
'Call cron_create with expression "*/1 * * * *" and prompt "CRONTICK99" and recurring true. Confirm briefly.',
);
await session.waitForScreen(
(scr) => scr.split('\n').some((l) => l.trim() === '> CRONTICK99'),
'first cron fire "> CRONTICK99"',
90_000,
);
await session.idle(5000);
await session.send('Reply with exactly USERPRIORITY77 nothing else');
await session.waitForScreen(
(scr) => scr.includes('USERPRIORITY77'),
'model response containing USERPRIORITY77',
);
const screen = await session.screen();
expect(screen).toContain('Type your message');
},
{ timeout: 180_000 },
);
it(
'error during cron turn does not kill the loop',
async () => {
session = await InteractiveSession.start({
env: makeEnv(),
args: ['--approval-mode', 'yolo'],
});
await session.send(
'Call cron_create with expression "*/1 * * * *" and prompt "Read the file /tmp/nonexistent_e2e_99.txt and report its contents. If it does not exist say FILEERR88." and recurring true. Confirm briefly.',
);
await session.waitForScreen(
(scr) => scr.includes('FILEERR88'),
'model reporting FILEERR88 from cron prompt',
90_000,
);
await session.idle(5000);
await session.send('Reply with exactly ALIVE99 nothing else');
await session.waitForScreen(
(scr) => scr.includes('ALIVE99'),
'model response ALIVE99',
);
await session.send(
'Call cron_list and tell me how many jobs exist. Say "COUNT: N"',
);
await session.idle(8000);
const screen = await session.screen();
expect(
screen.includes('COUNT: 1') ||
screen.includes('1 job') ||
screen.includes('Active cron jobs (1)'),
).toBe(true);
},
{ timeout: 180_000 },
);
});

View file

@ -5,7 +5,7 @@
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { TestRig } from './test-helper.js'; import { TestRig } from '../test-helper.js';
describe('Ctrl+C exit', () => { describe('Ctrl+C exit', () => {
// (#9782) Temporarily disabling on windows because it is failing on main and every // (#9782) Temporarily disabling on windows because it is failing on main and every

View file

@ -5,7 +5,7 @@
*/ */
import { expect, describe, it, beforeEach, afterEach } from 'vitest'; import { expect, describe, it, beforeEach, afterEach } from 'vitest';
import { TestRig, type, printDebugInfo } from './test-helper.js'; import { TestRig, type, printDebugInfo } from '../test-helper.js';
describe('Interactive file system', () => { describe('Interactive file system', () => {
let rig: TestRig; let rig: TestRig;

View file

@ -5,7 +5,7 @@
*/ */
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { TestRig } from './test-helper.js'; import { TestRig } from '../test-helper.js';
describe('/hooks command', () => { describe('/hooks command', () => {
let rig: TestRig; let rig: TestRig;

View file

@ -0,0 +1,213 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* InteractiveSession lightweight terminal session driver for interactive
* integration tests.
*
* Architecture:
* node-pty (pseudo-terminal)
* raw ANSI byte stream
* @xterm/headless (pure Node.js terminal emulator)
* proper ANSI processing: cursor movement, line clearing, scrollback
* buffer.active.getLine() rendered screen text
*
* No browser, no Playwright runs entirely in Node.js.
*/
import * as pty from '@lydell/node-pty';
import stripAnsi from 'strip-ansi';
// @xterm/headless is CJS — use default import + destructure
import xtermHeadless from '@xterm/headless';
const { Terminal } = xtermHeadless;
type Terminal = InstanceType<typeof Terminal>;
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
export interface InteractiveSessionOptions {
/** Terminal columns, default 100 */
cols?: number;
/** Terminal rows, default 40 */
rows?: number;
/** Working directory, default project root */
cwd?: string;
/** Environment variables */
env?: NodeJS.ProcessEnv;
/** Extra CLI arguments (e.g. ['--approval-mode', 'yolo']) */
args?: string[];
}
export class InteractiveSession {
private ptyProcess: pty.IPty;
private terminal: Terminal;
private rawOutput = '';
private pendingWrite: Promise<void> = Promise.resolve();
private constructor(ptyProcess: pty.IPty, terminal: Terminal) {
this.ptyProcess = ptyProcess;
this.terminal = terminal;
ptyProcess.onData((data) => {
this.rawOutput += data;
// Chain writes so flush() can await all pending data
this.pendingWrite = this.pendingWrite.then(
() =>
new Promise<void>((resolve) => {
terminal.write(data, resolve);
}),
);
});
}
/** Wait for all pending PTY data to be processed by xterm. */
private async flush(): Promise<void> {
await this.pendingWrite;
}
/**
* Start a new interactive session with the CLI.
*
* @example
* ```ts
* const session = await InteractiveSession.start({
* env: { QWEN_CODE_ENABLE_CRON: '1' },
* args: ['--approval-mode', 'yolo'],
* });
* ```
*/
static async start(
options?: InteractiveSessionOptions,
): Promise<InteractiveSession> {
const cols = options?.cols ?? 100;
const rows = options?.rows ?? 40;
const cwd = options?.cwd ?? join(__dirname, '..', '..');
const args = options?.args ?? [];
const baseEnv = { ...process.env };
delete baseEnv['NO_COLOR'];
const env = options?.env ?? baseEnv;
const terminal = new Terminal({
cols,
rows,
scrollback: 1000,
allowProposedApi: true,
});
const bundlePath = join(__dirname, '..', '..', 'dist/cli.js');
const ptyProcess = pty.spawn('node', [bundlePath, ...args], {
name: 'xterm-256color',
cols,
rows,
cwd,
env: env as Record<string, string>,
});
const session = new InteractiveSession(ptyProcess, terminal);
await session.waitFor('Type your message', 30_000);
return session;
}
/** Send text followed by Enter. */
async send(text: string): Promise<void> {
// Type character by character to avoid paste detection
for (const char of text) {
this.ptyProcess.write(char);
await sleep(5);
}
await sleep(300);
this.ptyProcess.write('\r');
}
/** Wait for text to appear in raw output. */
async waitFor(text: string, timeout = 120_000): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeout) {
if (
stripAnsi(this.rawOutput).toLowerCase().includes(text.toLowerCase())
) {
return;
}
await sleep(200);
}
throw new Error(
`Timeout (${timeout}ms) waiting for text: "${text}"\n` +
`Last 500 chars: ${stripAnsi(this.rawOutput).slice(-500)}`,
);
}
/** Wait for output to stabilize (no new output for `stableMs`). */
async idle(stableMs = 5000, timeout = 120_000): Promise<void> {
const start = Date.now();
let lastLength = this.rawOutput.length;
let lastChangeTime = Date.now();
while (Date.now() - start < timeout) {
await sleep(100);
if (this.rawOutput.length !== lastLength) {
lastLength = this.rawOutput.length;
lastChangeTime = Date.now();
} else if (Date.now() - lastChangeTime >= stableMs) {
return;
}
}
}
/**
* Read the rendered terminal screen what a user would actually see.
* Uses @xterm/headless buffer to get properly processed output,
* handling cursor movement, line clearing, and scrollback.
*/
async screen(): Promise<string> {
await this.flush();
const buf = this.terminal.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buf.length; i++) {
const line = buf.getLine(i);
lines.push(line ? line.translateToString(true) : '');
}
// Trim trailing empty lines
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
lines.pop();
}
return lines.join('\n');
}
/**
* Poll the screen until `predicate` returns true.
* Returns the screen text when matched.
*/
async waitForScreen(
predicate: (screen: string) => boolean,
description: string,
timeout = 120_000,
): Promise<string> {
const start = Date.now();
while (Date.now() - start < timeout) {
await sleep(3000);
const s = await this.screen();
if (predicate(s)) return s;
}
const finalScreen = await this.screen();
throw new Error(
`Timeout (${timeout}ms) waiting for: ${description}\n` +
`Screen (last 600):\n${finalScreen.slice(-600)}`,
);
}
/** Kill the PTY process and dispose the terminal. */
async close(): Promise<void> {
try {
this.ptyProcess.kill();
} catch {
// Process may have already exited
}
this.terminal.dispose();
}
}

View file

@ -5,7 +5,7 @@
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { TestRig } from './test-helper.js'; import { TestRig } from '../test-helper.js';
describe('mixed input crash prevention', () => { describe('mixed input crash prevention', () => {
it('should not crash when using mixed prompt inputs', async () => { it('should not crash when using mixed prompt inputs', async () => {

View file

@ -0,0 +1,17 @@
import type { ScenarioConfig } from '../scenario-runner.js';
/**
* Demonstrates the /loop skill and cron scheduling tools.
* Creates a recurring job, lists it, then clears all jobs.
*/
export default {
name: 'cron-loop',
spawn: ['node', 'dist/cli.js', '--yolo'],
terminal: { title: 'qwen-code', cwd: '../../..' },
flow: [
{ type: 'hi' },
{ type: '/loop 1m say hi to me' },
{ type: '/loop list' },
{ type: '/loop clear' },
],
} satisfies ScenarioConfig;

View file

@ -640,6 +640,46 @@ export class TerminalCapture {
return this.rawOutput; return this.rawOutput;
} }
/**
* Get the current rendered terminal screen text from xterm.js.
*
* Unlike getOutput() which returns the accumulated raw PTY stream (with
* duplicates from Ink TUI redraws), this returns the actual screen content
* as rendered by xterm.js what a user would see right now.
*
* Includes scrollback buffer content.
*/
async getScreenText(): Promise<string> {
if (!this.page) throw new Error('Not initialized');
await this.flush();
return this.page.evaluate(() => {
const W = window as unknown as Record<string, unknown>;
const term = W['term'] as {
buffer: {
active: {
length: number;
getLine: (
i: number,
) =>
| { translateToString: (trimRight?: boolean) => string }
| undefined;
};
};
};
const buf = term.buffer.active;
const lines: string[] = [];
for (let i = 0; i < buf.length; i++) {
const line = buf.getLine(i);
lines.push(line ? line.translateToString(true) : '');
}
// Trim trailing empty lines
while (lines.length > 0 && lines[lines.length - 1].trim() === '') {
lines.pop();
}
return lines.join('\n');
});
}
// ── Cleanup ────────────────────────────── // ── Cleanup ──────────────────────────────
/** /**

1
package-lock.json generated
View file

@ -26,6 +26,7 @@
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^3.1.1", "@vitest/coverage-v8": "^3.1.1",
"@vitest/eslint-plugin": "^1.3.4", "@vitest/eslint-plugin": "^1.3.4",
"@xterm/headless": "^5.5.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",

View file

@ -38,8 +38,10 @@
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests", "test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript", "test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript", "test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript",
"test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'", "test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests cli",
"test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'", "test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests cli",
"test:integration:interactive:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests interactive",
"test:integration:interactive:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests interactive",
"test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests", "test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests",
"test:terminal-bench:oracle": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'oracle'", "test:terminal-bench:oracle": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'oracle'",
"test:terminal-bench:qwen": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'qwen'", "test:terminal-bench:qwen": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests -t 'qwen'",
@ -84,6 +86,7 @@
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^3.1.1", "@vitest/coverage-v8": "^3.1.1",
"@vitest/eslint-plugin": "^1.3.4", "@vitest/eslint-plugin": "^1.3.4",
"@xterm/headless": "^5.5.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",

View file

@ -75,6 +75,7 @@ describe('Session', () => {
getTargetDir: vi.fn().mockReturnValue(process.cwd()), getTargetDir: vi.fn().mockReturnValue(process.cwd()),
getDebugMode: vi.fn().mockReturnValue(false), getDebugMode: vi.fn().mockReturnValue(false),
getAuthType: vi.fn().mockImplementation(() => currentAuthType), getAuthType: vi.fn().mockImplementation(() => currentAuthType),
isCronEnabled: vi.fn().mockReturnValue(false),
} as unknown as Config; } as unknown as Config;
mockClient = { mockClient = {

View file

@ -112,6 +112,12 @@ export class Session implements SessionContext {
private turn: number = 0; private turn: number = 0;
private readonly runtimeBaseDir: string; private readonly runtimeBaseDir: string;
// Cron scheduling state
private cronQueue: string[] = [];
private cronProcessing = false;
private cronAbortController: AbortController | null = null;
private cronCompletion: Promise<void> | null = null;
// Modular components // Modular components
private readonly historyReplayer: HistoryReplayer; private readonly historyReplayer: HistoryReplayer;
private readonly toolCallEmitter: ToolCallEmitter; private readonly toolCallEmitter: ToolCallEmitter;
@ -155,12 +161,37 @@ export class Session implements SessionContext {
} }
async cancelPendingPrompt(): Promise<void> { async cancelPendingPrompt(): Promise<void> {
if (!this.pendingPrompt) { const hadPrompt = !!this.pendingPrompt;
const hadCron = !!this.cronAbortController;
if (!hadPrompt && !hadCron) {
throw new Error('Not currently generating'); throw new Error('Not currently generating');
} }
this.pendingPrompt.abort(); if (this.pendingPrompt) {
this.pendingPrompt = null; this.pendingPrompt.abort();
this.pendingPrompt = null;
}
// Cancel any in-progress cron execution
if (this.cronAbortController) {
this.cronAbortController.abort();
this.cronAbortController = null;
this.cronQueue = [];
this.cronProcessing = false;
}
// Stop scheduler and emit exit summary
const scheduler = this.config.isCronEnabled()
? this.config.getCronScheduler()
: null;
if (scheduler) {
const summary = scheduler.getExitSummary();
scheduler.stop();
if (summary) {
await this.messageEmitter.emitAgentMessage(summary);
}
}
} }
async prompt(params: PromptRequest): Promise<PromptResponse> { async prompt(params: PromptRequest): Promise<PromptResponse> {
@ -170,6 +201,22 @@ export class Session implements SessionContext {
const pendingSend = new AbortController(); const pendingSend = new AbortController();
this.pendingPrompt = pendingSend; this.pendingPrompt = pendingSend;
// Abort any in-progress cron execution (user prompt takes priority)
if (this.cronAbortController) {
this.cronAbortController.abort();
this.cronAbortController = null;
this.cronQueue = [];
this.cronProcessing = false;
}
if (this.cronCompletion) {
try {
await this.cronCompletion;
} catch {
// Expected: cron was aborted
}
this.cronCompletion = null;
}
// Wait for the previous prompt to finish so chat history is consistent. // Wait for the previous prompt to finish so chat history is consistent.
if (this.pendingPromptCompletion) { if (this.pendingPromptCompletion) {
try { try {
@ -191,7 +238,12 @@ export class Session implements SessionContext {
}); });
try { try {
return await this.#executePrompt(params, pendingSend); const result = await this.#executePrompt(params, pendingSend);
this.pendingPrompt = null;
this.#startCronSchedulerIfNeeded();
// Drain any cron prompts that queued while the prompt was active
void this.#drainCronQueue();
return result;
} finally { } finally {
resolveCompletion(); resolveCompletion();
} }
@ -376,6 +428,169 @@ export class Session implements SessionContext {
await this.client.sessionUpdate(params); await this.client.sessionUpdate(params);
} }
/**
* Starts the cron scheduler if cron is enabled and jobs exist.
* The scheduler runs in the background, pushing fired prompts into
* `cronQueue` and triggering `#drainCronQueue`.
*/
#startCronSchedulerIfNeeded(): void {
if (!this.config.isCronEnabled()) return;
const scheduler = this.config.getCronScheduler();
if (scheduler.size === 0) return;
scheduler.start((job: { prompt: string }) => {
this.cronQueue.push(job.prompt);
void this.#drainCronQueue();
});
}
/**
* Processes queued cron prompts one at a time. Uses `cronProcessing`
* as a mutex to prevent concurrent access to the chat.
*/
async #drainCronQueue(): Promise<void> {
if (this.cronProcessing) return;
// Don't process cron while a user prompt is active — the queue will be
// drained after the prompt completes (see end of prompt()).
if (this.pendingPrompt) return;
this.cronProcessing = true;
let resolveCompletion!: () => void;
this.cronCompletion = new Promise<void>((resolve) => {
resolveCompletion = resolve;
});
try {
while (this.cronQueue.length > 0) {
const prompt = this.cronQueue.shift()!;
await this.#executeCronPrompt(prompt);
}
} finally {
this.cronProcessing = false;
resolveCompletion();
this.cronCompletion = null;
// Stop scheduler if all jobs were deleted during execution
if (this.config.isCronEnabled()) {
const scheduler = this.config.getCronScheduler();
if (scheduler.size === 0) {
scheduler.stop();
}
}
}
}
/**
* Executes a single cron-fired prompt: echoes it as a user message with
* `_meta.source='cron'`, streams the model response, and handles tool calls.
*/
async #executeCronPrompt(prompt: string): Promise<void> {
return Storage.runWithRuntimeBaseDir(
this.runtimeBaseDir,
this.config.getWorkingDir(),
async () => {
const ac = new AbortController();
this.cronAbortController = ac;
const promptId =
this.config.getSessionId() + '########cron' + Date.now();
try {
// Echo the cron prompt as a user message so the client sees it
await this.sendUpdate({
sessionUpdate: 'user_message_chunk',
content: { type: 'text', text: prompt },
_meta: { source: 'cron' },
});
let nextMessage: Content | null = {
role: 'user',
parts: [{ text: prompt }],
};
while (nextMessage !== null) {
if (ac.signal.aborted) return;
const functionCalls: FunctionCall[] = [];
let usageMetadata: GenerateContentResponseUsageMetadata | null =
null;
const streamStartTime = Date.now();
const responseStream = await this.chat.sendMessageStream(
this.config.getModel(),
{
message: nextMessage.parts ?? [],
config: { abortSignal: ac.signal },
},
promptId,
);
nextMessage = null;
for await (const resp of responseStream) {
if (ac.signal.aborted) return;
if (
resp.type === StreamEventType.CHUNK &&
resp.value.candidates &&
resp.value.candidates.length > 0
) {
const candidate = resp.value.candidates[0];
for (const part of candidate.content?.parts ?? []) {
if (!part.text) continue;
this.messageEmitter.emitMessage(
part.text,
'assistant',
part.thought,
);
}
}
if (
resp.type === StreamEventType.CHUNK &&
resp.value.usageMetadata
) {
usageMetadata = resp.value.usageMetadata;
}
if (
resp.type === StreamEventType.CHUNK &&
resp.value.functionCalls
) {
functionCalls.push(...resp.value.functionCalls);
}
}
if (usageMetadata) {
const durationMs = Date.now() - streamStartTime;
await this.messageEmitter.emitUsageMetadata(
usageMetadata,
'',
durationMs,
);
}
if (functionCalls.length > 0) {
const toolResponseParts: Part[] = [];
for (const fc of functionCalls) {
const response = await this.runTool(ac.signal, promptId, fc);
toolResponseParts.push(...response);
}
nextMessage = { role: 'user', parts: toolResponseParts };
}
}
} catch (error) {
if (ac.signal.aborted) return;
debugLogger.error('Error processing cron prompt:', error);
const msg = error instanceof Error ? error.message : String(error);
await this.messageEmitter.emitAgentMessage(`[cron error] ${msg}`);
} finally {
if (this.cronAbortController === ac) {
this.cronAbortController = null;
}
}
},
);
}
async sendAvailableCommandsUpdate(): Promise<void> { async sendAvailableCommandsUpdate(): Promise<void> {
const abortController = new AbortController(); const abortController = new AbortController();
try { try {

View file

@ -1082,6 +1082,7 @@ export async function loadCliConfig(
maxSessionTurns: maxSessionTurns:
argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1, argv.maxSessionTurns ?? settings.model?.maxSessionTurns ?? -1,
experimentalZedIntegration: argv.acp || argv.experimentalAcp || false, experimentalZedIntegration: argv.acp || argv.experimentalAcp || false,
cronEnabled: settings.experimental?.cron ?? false,
listExtensions: argv.listExtensions || false, listExtensions: argv.listExtensions || false,
overrideExtensions: overrideExtensions || argv.extensions, overrideExtensions: overrideExtensions || argv.extensions,
noBrowser: !!process.env['NO_BROWSER'], noBrowser: !!process.env['NO_BROWSER'],

View file

@ -1571,9 +1571,20 @@ const SETTINGS_SCHEMA = {
category: 'Experimental', category: 'Experimental',
requiresRestart: true, requiresRestart: true,
default: {}, default: {},
description: 'Setting to enable experimental features', description: 'Settings to enable experimental features.',
showInDialog: false, showInDialog: false,
properties: {}, properties: {
cron: {
type: 'boolean',
label: 'Enable Cron/Loop Tools',
category: 'Experimental',
requiresRestart: true,
default: false,
description:
'Enable in-session cron/loop tools (experimental). When enabled, the model can create recurring prompts using cron_create, cron_list, and cron_delete tools. Can also be enabled via QWEN_CODE_ENABLE_CRON=1 environment variable.',
showInDialog: true,
},
},
}, },
} as const satisfies SettingsSchema; } as const satisfies SettingsSchema;

View file

@ -144,6 +144,8 @@ describe('runNonInteractive', () => {
}), }),
getExperimentalZedIntegration: vi.fn().mockReturnValue(false), getExperimentalZedIntegration: vi.fn().mockReturnValue(false),
isInteractive: vi.fn().mockReturnValue(false), isInteractive: vi.fn().mockReturnValue(false),
isCronEnabled: vi.fn().mockReturnValue(false),
getCronScheduler: vi.fn().mockReturnValue(null),
} as unknown as Config; } as unknown as Config;
mockSettings = { mockSettings = {

View file

@ -371,6 +371,138 @@ export async function runNonInteractive(
} }
currentMessages = [{ role: 'user', parts: toolResponseParts }]; currentMessages = [{ role: 'user', parts: toolResponseParts }];
} else { } else {
// No more tool calls — check if cron jobs are keeping us alive
const scheduler = !config.isCronEnabled()
? null
: config.getCronScheduler();
if (scheduler && scheduler.size > 0) {
// Start the scheduler and wait for all jobs to complete or be deleted.
// Each fired prompt is processed as a new turn through the same loop.
await new Promise<void>((resolve) => {
const cronQueue: string[] = [];
let processing = false;
const checkDone = () => {
if (scheduler.size === 0 && !processing) {
scheduler.stop();
resolve();
}
};
const drainQueue = async () => {
if (processing) return;
processing = true;
try {
while (cronQueue.length > 0) {
const cronPrompt = cronQueue.shift()!;
turnCount++;
let cronMessages: Content[] = [
{ role: 'user', parts: [{ text: cronPrompt }] },
];
let cronIsFirstTurn = true;
while (true) {
const cronToolCallRequests: ToolCallRequestInfo[] = [];
const cronApiStartTime = Date.now();
const cronStream = geminiClient.sendMessageStream(
cronMessages[0]?.parts || [],
abortController.signal,
prompt_id,
{
type: cronIsFirstTurn
? SendMessageType.Cron
: SendMessageType.ToolResult,
},
);
cronIsFirstTurn = false;
adapter.startAssistantMessage();
for await (const event of cronStream) {
if (abortController.signal.aborted) {
const summary = scheduler.getExitSummary();
scheduler.stop();
if (summary) {
process.stderr.write(summary + '\n');
}
resolve();
return;
}
adapter.processEvent(event);
if (event.type === GeminiEventType.ToolCallRequest) {
cronToolCallRequests.push(event.value);
}
}
adapter.finalizeAssistantMessage();
totalApiDurationMs += Date.now() - cronApiStartTime;
if (cronToolCallRequests.length > 0) {
const cronToolResponseParts: Part[] = [];
for (const requestInfo of cronToolCallRequests) {
const isAgentTool = requestInfo.name === 'agent';
const { handler: outputUpdateHandler } = isAgentTool
? createAgentToolProgressHandler(
config,
requestInfo.callId,
adapter,
)
: createToolProgressHandler(requestInfo, adapter);
const toolResponse = await executeToolCall(
config,
requestInfo,
abortController.signal,
{ outputUpdateHandler },
);
if (toolResponse.error) {
handleToolError(
requestInfo.name,
toolResponse.error,
config,
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
typeof toolResponse.resultDisplay === 'string'
? toolResponse.resultDisplay
: undefined,
);
}
adapter.emitToolResult(requestInfo, toolResponse);
if (toolResponse.responseParts) {
cronToolResponseParts.push(
...toolResponse.responseParts,
);
}
}
cronMessages = [
{ role: 'user', parts: cronToolResponseParts },
];
} else {
break;
}
}
}
} catch (error) {
debugLogger.error('Error processing cron prompt:', error);
} finally {
processing = false;
checkDone();
}
};
scheduler.start((job: { prompt: string }) => {
cronQueue.push(job.prompt);
void drainQueue();
});
// Also check immediately in case jobs were already deleted
checkDone();
});
}
const metrics = uiTelemetryService.getMetrics(); const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics); const usage = computeUsageFromMetrics(metrics);
// Get stats for JSON format output // Get stats for JSON format output

View file

@ -204,6 +204,8 @@ describe('useGeminiStream', () => {
.mockReturnValue(contentGeneratorConfig), .mockReturnValue(contentGeneratorConfig),
getMaxSessionTurns: vi.fn(() => 50), getMaxSessionTurns: vi.fn(() => 50),
getArenaAgentClient: vi.fn(() => null), getArenaAgentClient: vi.fn(() => null),
isCronEnabled: vi.fn(() => false),
getCronScheduler: vi.fn(() => null),
} as unknown as Config; } as unknown as Config;
mockOnDebugMessage = vi.fn(); mockOnDebugMessage = vi.fn();
mockHandleSlashCommand = vi.fn().mockResolvedValue(false); mockHandleSlashCommand = vi.fn().mockResolvedValue(false);

View file

@ -1236,7 +1236,10 @@ export const useGeminiStream = (
} }
// Check image format support for non-continuations // Check image format support for non-continuations
if (submitType === SendMessageType.UserQuery) { if (
submitType === SendMessageType.UserQuery ||
submitType === SendMessageType.Cron
) {
const formatCheck = checkImageFormatsSupport(queryToSend); const formatCheck = checkImageFormatsSupport(queryToSend);
if (formatCheck.hasUnsupportedFormats) { if (formatCheck.hasUnsupportedFormats) {
addItem( addItem(
@ -1253,7 +1256,10 @@ export const useGeminiStream = (
lastPromptRef.current = finalQueryToSend; lastPromptRef.current = finalQueryToSend;
lastPromptErroredRef.current = false; lastPromptErroredRef.current = false;
if (submitType === SendMessageType.UserQuery) { if (
submitType === SendMessageType.UserQuery ||
submitType === SendMessageType.Cron
) {
// trigger new prompt event for session stats in CLI // trigger new prompt event for session stats in CLI
startNewPrompt(); startNewPrompt();
@ -1698,6 +1704,38 @@ export const useGeminiStream = (
storage, storage,
]); ]);
// ─── Cron scheduler integration ─────────────────────────
const cronQueueRef = useRef<string[]>([]);
const [cronTrigger, setCronTrigger] = useState(0);
// Start the scheduler on mount, stop on unmount
useEffect(() => {
if (!config.isCronEnabled()) return;
const scheduler = config.getCronScheduler();
scheduler.start((job: { prompt: string }) => {
cronQueueRef.current.push(job.prompt);
setCronTrigger((n) => n + 1);
});
return () => {
const summary = scheduler.getExitSummary();
scheduler.stop();
if (summary) {
process.stderr.write(summary + '\n');
}
};
}, [config]);
// When idle, drain the cron queue one prompt at a time
useEffect(() => {
if (
streamingState === StreamingState.Idle &&
cronQueueRef.current.length > 0
) {
const prompt = cronQueueRef.current.shift()!;
submitQuery(prompt, SendMessageType.Cron);
}
}, [streamingState, submitQuery, cronTrigger]);
return { return {
streamingState, streamingState,
submitQuery, submitQuery,

View file

@ -58,6 +58,7 @@ import type {
import { type AgentEventEmitter, AgentEventType } from './agent-events.js'; import { type AgentEventEmitter, AgentEventType } from './agent-events.js';
import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js'; import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js';
import { AgentTool } from '../../tools/agent.js'; import { AgentTool } from '../../tools/agent.js';
import { ToolNames } from '../../tools/tool-names.js';
import { DEFAULT_QWEN_MODEL } from '../../config/models.js'; import { DEFAULT_QWEN_MODEL } from '../../config/models.js';
import { type ContextState, templateString } from './agent-headless.js'; import { type ContextState, templateString } from './agent-headless.js';
@ -273,6 +274,15 @@ export class AgentCore {
const toolRegistry = this.runtimeContext.getToolRegistry(); const toolRegistry = this.runtimeContext.getToolRegistry();
const toolsList: FunctionDeclaration[] = []; const toolsList: FunctionDeclaration[] = [];
// Tools excluded from subagents: AgentTool (prevent recursion) and
// cron tools (session-scoped, should only be used by the main session).
const excludedFromSubagents = new Set<string>([
AgentTool.Name,
ToolNames.CRON_CREATE,
ToolNames.CRON_LIST,
ToolNames.CRON_DELETE,
]);
if (this.toolConfig) { if (this.toolConfig) {
const asStrings = this.toolConfig.tools.filter( const asStrings = this.toolConfig.tools.filter(
(t): t is string => typeof t === 'string', (t): t is string => typeof t === 'string',
@ -286,11 +296,13 @@ export class AgentCore {
toolsList.push( toolsList.push(
...toolRegistry ...toolRegistry
.getFunctionDeclarations() .getFunctionDeclarations()
.filter((t) => t.name !== AgentTool.Name), .filter((t) => !(t.name && excludedFromSubagents.has(t.name))),
); );
} else { } else {
toolsList.push( toolsList.push(
...toolRegistry.getFunctionDeclarationsFiltered(asStrings), ...toolRegistry.getFunctionDeclarationsFiltered(
asStrings.filter((name) => !excludedFromSubagents.has(name)),
),
); );
} }
toolsList.push(...onlyInlineDecls); toolsList.push(...onlyInlineDecls);
@ -299,7 +311,7 @@ export class AgentCore {
toolsList.push( toolsList.push(
...toolRegistry ...toolRegistry
.getFunctionDeclarations() .getFunctionDeclarations()
.filter((t) => t.name !== AgentTool.Name), .filter((t) => !(t.name && excludedFromSubagents.has(t.name))),
); );
} }

View file

@ -41,6 +41,7 @@ import {
type FileEncodingType, type FileEncodingType,
} from '../services/fileSystemService.js'; } from '../services/fileSystemService.js';
import { GitService } from '../services/gitService.js'; import { GitService } from '../services/gitService.js';
import { CronScheduler } from '../services/cronScheduler.js';
// Tools // Tools
import { AskUserQuestionTool } from '../tools/askUserQuestion.js'; import { AskUserQuestionTool } from '../tools/askUserQuestion.js';
@ -63,6 +64,9 @@ import { WebFetchTool } from '../tools/web-fetch.js';
import { WebSearchTool } from '../tools/web-search/index.js'; import { WebSearchTool } from '../tools/web-search/index.js';
import { WriteFileTool } from '../tools/write-file.js'; import { WriteFileTool } from '../tools/write-file.js';
import { LspTool } from '../tools/lsp.js'; import { LspTool } from '../tools/lsp.js';
import { CronCreateTool } from '../tools/cron-create.js';
import { CronListTool } from '../tools/cron-list.js';
import { CronDeleteTool } from '../tools/cron-delete.js';
import type { LspClient } from '../lsp/types.js'; import type { LspClient } from '../lsp/types.js';
// Other modules // Other modules
@ -368,6 +372,7 @@ export interface ConfigParameters {
maxSessionTurns?: number; maxSessionTurns?: number;
sessionTokenLimit?: number; sessionTokenLimit?: number;
experimentalZedIntegration?: boolean; experimentalZedIntegration?: boolean;
cronEnabled?: boolean;
listExtensions?: boolean; listExtensions?: boolean;
overrideExtensions?: string[]; overrideExtensions?: string[];
allowedMcpServers?: string[]; allowedMcpServers?: string[];
@ -530,6 +535,7 @@ export class Config {
private readonly usageStatisticsEnabled: boolean; private readonly usageStatisticsEnabled: boolean;
private geminiClient!: GeminiClient; private geminiClient!: GeminiClient;
private baseLlmClient!: BaseLlmClient; private baseLlmClient!: BaseLlmClient;
private cronScheduler: CronScheduler | null = null;
private readonly fileFiltering: { private readonly fileFiltering: {
respectGitIgnore: boolean; respectGitIgnore: boolean;
respectQwenIgnore: boolean; respectQwenIgnore: boolean;
@ -557,6 +563,7 @@ export class Config {
private readonly cliVersion?: string; private readonly cliVersion?: string;
private readonly experimentalZedIntegration: boolean = false; private readonly experimentalZedIntegration: boolean = false;
private readonly cronEnabled: boolean = false;
private readonly chatRecordingEnabled: boolean; private readonly chatRecordingEnabled: boolean;
private readonly loadMemoryFromIncludeDirectories: boolean = false; private readonly loadMemoryFromIncludeDirectories: boolean = false;
private readonly importFormat: 'tree' | 'flat'; private readonly importFormat: 'tree' | 'flat';
@ -679,6 +686,7 @@ export class Config {
this.sessionTokenLimit = params.sessionTokenLimit ?? -1; this.sessionTokenLimit = params.sessionTokenLimit ?? -1;
this.experimentalZedIntegration = this.experimentalZedIntegration =
params.experimentalZedIntegration ?? false; params.experimentalZedIntegration ?? false;
this.cronEnabled = params.cronEnabled ?? false;
this.listExtensions = params.listExtensions ?? false; this.listExtensions = params.listExtensions ?? false;
this.overrideExtensions = params.overrideExtensions; this.overrideExtensions = params.overrideExtensions;
this.noBrowser = params.noBrowser ?? false; this.noBrowser = params.noBrowser ?? false;
@ -1687,6 +1695,19 @@ export class Config {
return this.geminiClient; return this.geminiClient;
} }
getCronScheduler(): CronScheduler {
if (!this.cronScheduler) {
this.cronScheduler = new CronScheduler();
}
return this.cronScheduler;
}
isCronEnabled(): boolean {
// Cron is experimental and opt-in: enabled via settings or env var
if (process.env['QWEN_CODE_ENABLE_CRON'] === '1') return true;
return this.cronEnabled;
}
getEnableRecursiveFileSearch(): boolean { getEnableRecursiveFileSearch(): boolean {
return this.fileFiltering.enableRecursiveFileSearch; return this.fileFiltering.enableRecursiveFileSearch;
} }
@ -2204,6 +2225,13 @@ export class Config {
await registerCoreTool(LspTool, this); await registerCoreTool(LspTool, this);
} }
// Register cron tools unless disabled
if (this.isCronEnabled()) {
await registerCoreTool(CronCreateTool, this);
await registerCoreTool(CronListTool, this);
await registerCoreTool(CronDeleteTool, this);
}
if (!options?.skipDiscovery) { if (!options?.skipDiscovery) {
await registry.discoverAllTools(); await registry.discoverAllTools();
} }

View file

@ -92,6 +92,8 @@ export enum SendMessageType {
ToolResult = 'toolResult', ToolResult = 'toolResult',
Retry = 'retry', Retry = 'retry',
Hook = 'hook', Hook = 'hook',
/** Cron-fired prompt. Behaves like UserQuery but skips UserPromptSubmit hook. */
Cron = 'cron',
} }
export interface SendMessageOptions { export interface SendMessageOptions {
@ -472,6 +474,7 @@ export class GeminiClient {
const messageBus = this.config.getMessageBus(); const messageBus = this.config.getMessageBus();
if ( if (
messageType !== SendMessageType.Retry && messageType !== SendMessageType.Retry &&
messageType !== SendMessageType.Cron &&
hooksEnabled && hooksEnabled &&
messageBus && messageBus &&
this.config.hasHooksForEvent('UserPromptSubmit') this.config.hasHooksForEvent('UserPromptSubmit')
@ -516,7 +519,10 @@ export class GeminiClient {
} }
} }
if (messageType === SendMessageType.UserQuery) { if (
messageType === SendMessageType.UserQuery ||
messageType === SendMessageType.Cron
) {
this.loopDetector.reset(prompt_id); this.loopDetector.reset(prompt_id);
this.lastPromptId = prompt_id; this.lastPromptId = prompt_id;
@ -614,7 +620,10 @@ export class GeminiClient {
// append system reminders to the request // append system reminders to the request
let requestToSent = await flatMapTextParts(request, async (text) => [text]); let requestToSent = await flatMapTextParts(request, async (text) => [text]);
if (messageType === SendMessageType.UserQuery) { if (
messageType === SendMessageType.UserQuery ||
messageType === SendMessageType.Cron
) {
const systemReminders = []; const systemReminders = [];
// add subagent system reminder if there are subagents // add subagent system reminder if there are subagents

View file

@ -97,12 +97,16 @@ export * from './tools/tool-registry.js';
export * from './tools/web-fetch.js'; export * from './tools/web-fetch.js';
export * from './tools/web-search/index.js'; export * from './tools/web-search/index.js';
export * from './tools/write-file.js'; export * from './tools/write-file.js';
export * from './tools/cron-create.js';
export * from './tools/cron-list.js';
export * from './tools/cron-delete.js';
// ============================================================================ // ============================================================================
// Services // Services
// ============================================================================ // ============================================================================
export * from './services/chatRecordingService.js'; export * from './services/chatRecordingService.js';
export * from './services/cronScheduler.js';
export * from './services/fileDiscoveryService.js'; export * from './services/fileDiscoveryService.js';
export * from './services/fileSystemService.js'; export * from './services/fileSystemService.js';
export * from './services/gitService.js'; export * from './services/gitService.js';

View file

@ -0,0 +1,286 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { CronScheduler, type CronJob } from './cronScheduler.js';
describe('CronScheduler', () => {
let scheduler: CronScheduler;
beforeEach(() => {
scheduler = new CronScheduler();
});
afterEach(() => {
scheduler.destroy();
});
describe('create', () => {
it('creates a job with valid fields', () => {
const job = scheduler.create('*/5 * * * *', 'test prompt', true);
expect(job.id).toHaveLength(8);
expect(job.cronExpr).toBe('*/5 * * * *');
expect(job.prompt).toBe('test prompt');
expect(job.recurring).toBe(true);
expect(job.createdAt).toBeGreaterThan(0);
expect(job.expiresAt).toBeGreaterThan(job.createdAt);
});
it('creates one-shot jobs with zero jitter', () => {
const job = scheduler.create('*/1 * * * *', 'once', false);
expect(job.jitterMs).toBe(0);
});
it('enforces max 50 jobs', () => {
for (let i = 0; i < 50; i++) {
scheduler.create('*/1 * * * *', `job-${i}`, true);
}
expect(() => scheduler.create('*/1 * * * *', 'job-51', true)).toThrow(
'Maximum number of cron jobs (50) reached',
);
});
it('generates unique IDs', () => {
const ids = new Set<string>();
for (let i = 0; i < 20; i++) {
const job = scheduler.create('*/1 * * * *', `job-${i}`, true);
ids.add(job.id);
}
expect(ids.size).toBe(20);
});
});
describe('delete', () => {
it('removes an existing job', () => {
const job = scheduler.create('*/1 * * * *', 'test', true);
expect(scheduler.delete(job.id)).toBe(true);
expect(scheduler.list()).toHaveLength(0);
});
it('returns false for non-existent job', () => {
expect(scheduler.delete('nonexistent')).toBe(false);
});
});
describe('list', () => {
it('returns empty array when no jobs', () => {
expect(scheduler.list()).toEqual([]);
});
it('returns all jobs', () => {
scheduler.create('*/1 * * * *', 'a', true);
scheduler.create('*/2 * * * *', 'b', false);
const jobs = scheduler.list();
expect(jobs).toHaveLength(2);
expect(jobs.map((j) => j.prompt).sort()).toEqual(['a', 'b']);
});
});
describe('size', () => {
it('tracks job count', () => {
expect(scheduler.size).toBe(0);
const job = scheduler.create('*/1 * * * *', 'a', true);
expect(scheduler.size).toBe(1);
scheduler.delete(job.id);
expect(scheduler.size).toBe(0);
});
});
describe('tick', () => {
it('fires callback when a job matches', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
// Use every-minute cron so jitter is tiny (max ~6s for 1-min period)
scheduler.create('*/1 * * * *', 'match', true);
// Tick at 10:30:59 — past any jitter for a 1-min period job
const date = new Date(2025, 0, 15, 10, 30, 59);
scheduler.tick(date);
expect(fired).toHaveLength(1);
expect(fired[0]!.prompt).toBe('match');
});
it('does not fire when no match', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
scheduler.create('30 10 * * *', 'no match', true);
// Tick at 10:31 — should not fire
scheduler.tick(new Date(2025, 0, 15, 10, 31, 0));
expect(fired).toHaveLength(0);
});
it('does not double-fire in same minute', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
scheduler.create('*/1 * * * *', 'once per minute', true);
// Both ticks in second 59 — past jitter for a 1-min period job
const date1 = new Date(2025, 0, 15, 10, 30, 59);
const date2 = new Date(2025, 0, 15, 10, 30, 59, 500);
scheduler.tick(date1);
scheduler.tick(date2);
expect(fired).toHaveLength(1);
});
it('removes one-shot jobs after firing', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
// One-shot: jitter is 0, so second 1 is fine
scheduler.create('30 10 * * *', 'one-shot', false);
scheduler.tick(new Date(2025, 0, 15, 10, 30, 1));
expect(fired).toHaveLength(1);
expect(scheduler.list()).toHaveLength(0);
});
it('keeps recurring jobs after firing', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
scheduler.create('*/1 * * * *', 'recurring', true);
// Tick at second 59 — past any jitter for a 1-min period job
scheduler.tick(new Date(2025, 0, 15, 10, 30, 59));
expect(fired).toHaveLength(1);
expect(scheduler.list()).toHaveLength(1);
});
it('removes expired jobs', () => {
scheduler.start(() => {});
const job = scheduler.create('*/1 * * * *', 'expire me', true);
// Tick far in the future (past expiry)
const farFuture = new Date(job.expiresAt + 1000);
scheduler.tick(farFuture);
expect(scheduler.list()).toHaveLength(0);
});
it('fires in next minute after first fire', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
// Every minute
scheduler.create('* * * * *', 'every minute', true);
scheduler.tick(new Date(2025, 0, 15, 10, 30, 59));
expect(fired).toHaveLength(1);
// Next minute
scheduler.tick(new Date(2025, 0, 15, 10, 31, 59));
expect(fired).toHaveLength(2);
});
it('fires recurring jobs after the matching minute when positive jitter delays them', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
const job = scheduler.create('0 * * * *', 'hourly delayed', true);
job.jitterMs = 6 * 60 * 1000;
scheduler.tick(new Date(2025, 0, 15, 10, 5, 59));
expect(fired).toHaveLength(0);
scheduler.tick(new Date(2025, 0, 15, 10, 6, 0));
expect(fired).toHaveLength(1);
expect(fired[0]!.prompt).toBe('hourly delayed');
});
it('fires one-shot jobs before the matching minute when negative jitter advances them', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
const job = scheduler.create('30 10 * * *', 'oneshot early', false);
job.jitterMs = -30 * 1000;
scheduler.tick(new Date(2025, 0, 15, 10, 29, 29));
expect(fired).toHaveLength(0);
scheduler.tick(new Date(2025, 0, 15, 10, 29, 30));
expect(fired).toHaveLength(1);
expect(fired[0]!.prompt).toBe('oneshot early');
});
});
describe('start/stop', () => {
it('starts and stops without error', () => {
scheduler.start(() => {});
expect(scheduler.running).toBe(true);
scheduler.stop();
expect(scheduler.running).toBe(false);
});
it('does not fire after stop', () => {
const fired: CronJob[] = [];
scheduler.start((job) => fired.push(job));
scheduler.stop();
scheduler.create('30 10 * * *', 'no fire', true);
scheduler.tick(new Date(2025, 0, 15, 10, 30, 1));
// tick still works manually, but onFire is cleared
expect(fired).toHaveLength(0);
});
it('start is idempotent', () => {
scheduler.start(() => {});
scheduler.start(() => {}); // should not throw or create duplicate timers
expect(scheduler.running).toBe(true);
});
});
describe('getExitSummary', () => {
it('returns null when no jobs', () => {
expect(scheduler.getExitSummary()).toBeNull();
});
it('returns summary with single job', () => {
scheduler.create('*/5 * * * *', 'check the build', true);
const summary = scheduler.getExitSummary()!;
expect(summary).toContain('1 active loop cancelled:');
expect(summary).toContain('Every 5 minutes');
expect(summary).toContain('check the build');
});
it('returns summary with multiple jobs', () => {
scheduler.create('*/5 * * * *', 'check the build', true);
scheduler.create('*/30 * * * *', 'check PR reviews', true);
const summary = scheduler.getExitSummary()!;
expect(summary).toContain('2 active loops cancelled:');
expect(summary).toContain('check the build');
expect(summary).toContain('check PR reviews');
});
it('truncates long prompts', () => {
const longPrompt = 'a'.repeat(100);
scheduler.create('*/1 * * * *', longPrompt, true);
const summary = scheduler.getExitSummary()!;
expect(summary).toContain('...');
// Should not contain the full 100-char prompt
expect(summary).not.toContain(longPrompt);
});
it('returns null after all jobs are deleted', () => {
const job = scheduler.create('*/1 * * * *', 'temp', true);
scheduler.delete(job.id);
expect(scheduler.getExitSummary()).toBeNull();
});
});
describe('destroy', () => {
it('stops and clears all jobs', () => {
scheduler.create('*/1 * * * *', 'a', true);
scheduler.create('*/2 * * * *', 'b', true);
scheduler.start(() => {});
scheduler.destroy();
expect(scheduler.running).toBe(false);
expect(scheduler.list()).toHaveLength(0);
});
});
});

View file

@ -0,0 +1,282 @@
/**
* In-session cron scheduler. Jobs live in memory and are gone when the
* process exits. Ticks every second, fires callbacks when jobs are due.
*/
import { matches, nextFireTime } from '../utils/cronParser.js';
import { humanReadableCron } from '../utils/cronDisplay.js';
const MAX_JOBS = 50;
const THREE_DAYS_MS = 3 * 24 * 60 * 60 * 1000;
// Recurring: up to 10% of period, capped at 15 minutes.
const MAX_RECURRING_JITTER_MS = 15 * 60 * 1000;
// One-shot: up to 90s early for jobs landing on :00 or :30.
const MAX_ONESHOT_JITTER_MS = 90 * 1000;
export interface CronJob {
id: string;
cronExpr: string;
prompt: string;
recurring: boolean;
createdAt: number;
expiresAt: number;
lastFiredAt?: number;
jitterMs: number;
}
/**
* Deterministic hash from a string ID, returned as a positive integer.
*/
function hashId(id: string): number {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) | 0;
}
return Math.abs(hash);
}
/**
* Derives a deterministic jitter offset from a job ID and its cron period.
* Recurring jobs: up to 10% of period, capped at 15 minutes (added after fire time).
* One-shot jobs landing on :00 or :30: up to 90s early (subtracted before fire time).
* Other one-shot jobs: 0 jitter.
*/
function computeJitter(
id: string,
cronExpr: string,
recurring: boolean,
): number {
const hash = hashId(id);
if (recurring) {
// Estimate period by computing two consecutive fire times
const now = new Date();
try {
const first = nextFireTime(cronExpr, now);
const second = nextFireTime(cronExpr, first);
const periodMs = second.getTime() - first.getTime();
const tenPercent = periodMs * 0.1;
const maxJitter = Math.min(tenPercent, MAX_RECURRING_JITTER_MS);
return hash % Math.max(1, Math.floor(maxJitter));
} catch {
return 0;
}
}
// One-shot: apply up to 90s early jitter only when minute is :00 or :30
try {
const fields = cronExpr.trim().split(/\s+/);
const minuteField = fields[0] ?? '';
const minuteVal = parseInt(minuteField, 10);
if (!isNaN(minuteVal) && (minuteVal === 0 || minuteVal === 30)) {
// Negative jitter = fire early
return -(hash % MAX_ONESHOT_JITTER_MS);
}
} catch {
// fall through
}
return 0;
}
function generateId(): string {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let id = '';
for (let i = 0; i < 8; i++) {
id += chars[Math.floor(Math.random() * chars.length)];
}
return id;
}
export class CronScheduler {
private jobs = new Map<string, CronJob>();
private timer: ReturnType<typeof setInterval> | null = null;
private onFire: ((job: CronJob) => void) | null = null;
/**
* Creates a new cron job. Returns the created job.
* Throws if the max job limit is reached.
*/
create(cronExpr: string, prompt: string, recurring: boolean): CronJob {
if (this.jobs.size >= MAX_JOBS) {
throw new Error(
`Maximum number of cron jobs (${MAX_JOBS}) reached. Delete some jobs first.`,
);
}
const id = generateId();
const now = Date.now();
const jitterMs = computeJitter(id, cronExpr, recurring);
const job: CronJob = {
id,
cronExpr,
prompt,
recurring,
createdAt: now,
expiresAt: recurring ? now + THREE_DAYS_MS : Infinity,
jitterMs,
};
this.jobs.set(id, job);
return job;
}
/**
* Deletes a job by ID. Returns true if the job existed.
*/
delete(id: string): boolean {
return this.jobs.delete(id);
}
/**
* Returns all active jobs.
*/
list(): CronJob[] {
return [...this.jobs.values()];
}
/**
* Returns the number of active jobs.
*/
get size(): number {
return this.jobs.size;
}
/**
* Starts the scheduler tick. Calls `onFire` when a job is due.
* Only fires when called does not auto-fire missed intervals.
*/
start(onFire: (job: CronJob) => void): void {
this.onFire = onFire;
if (this.timer) return; // already running
this.timer = setInterval(() => {
this.tick();
}, 1000);
}
/**
* Stops the scheduler. Does not clear jobs they remain queryable.
*/
stop(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
this.onFire = null;
}
/**
* Returns true if the scheduler is running.
*/
get running(): boolean {
return this.timer !== null;
}
/**
* Manual tick checks all jobs against the current time and fires those
* that are due. Exported for testing.
*/
tick(now?: Date): void {
const currentDate = now ?? new Date();
const currentMs = currentDate.getTime();
for (const job of this.jobs.values()) {
// Check expiry
if (currentMs >= job.expiresAt) {
this.jobs.delete(job.id);
continue;
}
// Find the cron minute whose jittered fire time we might be in.
// For positive jitter (recurring) the fire time is *after* the matching
// minute, so we look backwards. For negative jitter (one-shot :00/:30)
// the fire time is *before* the matching minute, so we look at the
// current minute.
//
// We scan a window of minutes around `now` equal to the absolute jitter
// so that a +6 min jitter on an hourly job still finds the :00 match.
const absJitter = Math.abs(job.jitterMs);
const windowMinutes = Math.ceil(absJitter / 60_000);
// Build the candidate minute-start at the beginning of the current minute
const nowMinuteStart = new Date(currentDate);
nowMinuteStart.setSeconds(0, 0);
const nowMinuteMs = nowMinuteStart.getTime();
let matchedMinuteMs: number | null = null;
// Scan from (now - windowMinutes) to (now) for positive jitter,
// or (now) to (now + windowMinutes) for negative jitter.
// In practice we scan both directions to keep the code simple.
for (let offset = -windowMinutes; offset <= windowMinutes; offset++) {
const candidateMs = nowMinuteMs + offset * 60_000;
const candidateDate = new Date(candidateMs);
if (!matches(job.cronExpr, candidateDate)) continue;
const fireTimeMs = candidateMs + job.jitterMs;
if (currentMs >= fireTimeMs) {
// This candidate's jittered fire time has passed — it's a match.
// Pick the latest matching minute to avoid re-triggering old ones.
if (matchedMinuteMs === null || candidateMs > matchedMinuteMs) {
matchedMinuteMs = candidateMs;
}
}
}
if (matchedMinuteMs === null) {
continue; // No matching minute whose jittered time has arrived
}
// Prevent double-firing: compare against the cron minute we last fired for
if (
job.lastFiredAt !== undefined &&
job.lastFiredAt === matchedMinuteMs
) {
continue; // Already fired for this cron minute
}
// Fire! Record the matched cron minute (not wall-clock time) so the
// double-fire guard works when jitter pushes the fire into a later minute.
job.lastFiredAt = matchedMinuteMs;
if (!job.recurring) {
this.jobs.delete(job.id);
}
if (this.onFire) {
this.onFire(job);
}
}
}
/**
* Returns a human-readable summary of active jobs for display on session
* exit. Returns null if there are no active jobs.
*/
getExitSummary(): string | null {
if (this.jobs.size === 0) return null;
const count = this.jobs.size;
const lines = [
`Session ending. ${count} active loop${count === 1 ? '' : 's'} cancelled:`,
];
for (const job of this.jobs.values()) {
const schedule = humanReadableCron(job.cronExpr);
// Truncate long prompts
const prompt =
job.prompt.length > 60 ? job.prompt.slice(0, 57) + '...' : job.prompt;
lines.push(` - [${job.id}] ${schedule}: ${prompt}`);
}
return lines.join('\n');
}
/**
* Clears all jobs and stops the scheduler.
*/
destroy(): void {
this.stop();
this.jobs.clear();
}
}

View file

@ -0,0 +1,61 @@
---
name: loop
description: Create a recurring loop that runs a prompt on a schedule. Usage - /loop 5m check the build, /loop check the PR every 30m, /loop run tests (defaults to 10m). /loop list to show jobs, /loop clear to cancel all.
allowedTools:
- cron_create
- cron_list
- cron_delete
---
# /loop — schedule a recurring prompt
## Subcommands
If the input (after stripping the `/loop` prefix) is exactly one of these keywords, run the subcommand instead of scheduling:
- **`list`** — call CronList and display the results. Done.
- **`clear`** — call CronList, then call CronDelete for every job returned. Confirm how many were cancelled. Done.
Otherwise, parse the input below into `[interval] <prompt…>` and schedule it with CronCreate.
## Parsing (in priority order)
1. **Leading token**: if the first whitespace-delimited token matches `^\d+[smhd]$` (e.g. `5m`, `2h`), that's the interval; the rest is the prompt.
2. **Trailing "every" clause**: otherwise, if the input ends with `every <N><unit>` or `every <N> <unit-word>` (e.g. `every 20m`, `every 5 minutes`, `every 2 hours`), extract that as the interval and strip it from the prompt. Only match when what follows "every" is a time expression — `check every PR` has no interval.
3. **Default**: otherwise, interval is `10m` and the entire input is the prompt.
If the resulting prompt is empty, show usage `/loop [interval] <prompt>` and stop — do not call CronCreate.
Examples:
- `5m /babysit-prs` → interval `5m`, prompt `/babysit-prs` (rule 1)
- `check the deploy every 20m` → interval `20m`, prompt `check the deploy` (rule 2)
- `run tests every 5 minutes` → interval `5m`, prompt `run tests` (rule 2)
- `check the deploy` → interval `10m`, prompt `check the deploy` (rule 3)
- `check every PR` → interval `10m`, prompt `check every PR` (rule 3 — "every" not followed by time)
- `5m` → empty prompt → show usage
## Interval → cron
Supported suffixes: `s` (seconds, rounded up to nearest minute, min 1), `m` (minutes), `h` (hours), `d` (days). Convert:
| Interval pattern | Cron expression | Notes |
| ----------------- | ---------------------- | ----------------------------------------- |
| `Nm` where N ≤ 59 | `*/N * * * *` | every N minutes |
| `Nm` where N ≥ 60 | `0 */H * * *` | round to hours (H = N/60, must divide 24) |
| `Nh` where N ≤ 23 | `0 */N * * *` | every N hours |
| `Nd` | `0 0 */N * *` | every N days at midnight local |
| `Ns` | treat as `ceil(N/60)m` | cron minimum granularity is 1 minute |
**If the interval doesn't cleanly divide its unit** (e.g. `7m``*/7 * * * *` gives uneven gaps at :56→:00; `90m` → 1.5h which cron can't express), pick the nearest clean interval and tell the user what you rounded to before scheduling.
## Action
1. Call CronCreate with:
- `cron`: the expression from the table above
- `prompt`: the parsed prompt from above, verbatim (slash commands are passed through unchanged)
- `recurring`: `true`
2. Briefly confirm: what's scheduled, the cron expression, the human-readable cadence, that recurring tasks auto-expire after 3 days, and that they can cancel sooner with CronDelete (include the job ID).
3. **Then immediately execute the parsed prompt now** — don't wait for the first cron fire. If it's a slash command, invoke it via the Skill tool; otherwise act on it directly.
## Input

View file

@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CronCreateTool } from './cron-create.js';
import { CronScheduler } from '../services/cronScheduler.js';
function makeConfig() {
const scheduler = new CronScheduler();
return {
getCronScheduler: () => scheduler,
_scheduler: scheduler,
} as unknown as import('../config/config.js').Config & {
_scheduler: CronScheduler;
};
}
describe('CronCreateTool', () => {
let config: ReturnType<typeof makeConfig>;
let tool: CronCreateTool;
beforeEach(() => {
config = makeConfig();
tool = new CronCreateTool(config);
});
it('has the correct name', () => {
expect(tool.name).toBe('cron_create');
});
it('creates a recurring job by default', async () => {
const invocation = tool.build({
cron: '*/5 * * * *',
prompt: 'check status',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('Scheduled recurring job');
expect(result.llmContent).toContain('Auto-expires after 3 days');
expect(config._scheduler.list()).toHaveLength(1);
});
it('creates a one-shot job when recurring=false', async () => {
const invocation = tool.build({
cron: '*/1 * * * *',
prompt: 'once',
recurring: false,
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('Scheduled one-shot task');
expect(result.llmContent).toContain('fire once then auto-delete');
const jobs = config._scheduler.list();
expect(jobs).toHaveLength(1);
expect(jobs[0]!.recurring).toBe(false);
});
it('returns error for invalid cron expression', async () => {
const invocation = tool.build({
cron: 'bad cron',
prompt: 'fail',
});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
});
it('validates required params', () => {
expect(() => tool.build({ cron: '*/1 * * * *' } as never)).toThrow();
expect(() => tool.build({ prompt: 'test' } as never)).toThrow();
});
});

View file

@ -0,0 +1,137 @@
/**
* cron_create tool creates a new in-session cron job.
*/
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames, ToolDisplayNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import { parseCron } from '../utils/cronParser.js';
import { humanReadableCron } from '../utils/cronDisplay.js';
export interface CronCreateParams {
cron: string;
prompt: string;
recurring?: boolean;
}
class CronCreateInvocation extends BaseToolInvocation<
CronCreateParams,
ToolResult
> {
constructor(
private config: Config,
params: CronCreateParams,
) {
super(params);
}
getDescription(): string {
return `${this.params.cron}: ${this.params.prompt}`;
}
async execute(): Promise<ToolResult> {
const scheduler = this.config.getCronScheduler();
const recurring = this.params.recurring !== false;
try {
// Validate cron expression before creating the job
parseCron(this.params.cron);
const job = scheduler.create(
this.params.cron,
this.params.prompt,
recurring,
);
const display = humanReadableCron(job.cronExpr);
const returnDisplay = `Scheduled ${job.id} (${display})`;
let llmContent: string;
if (recurring) {
llmContent =
`Scheduled recurring job ${job.id} (${job.cronExpr}). ` +
'Session-only (not written to disk, dies when Qwen Code exits). ' +
'Auto-expires after 3 days. Use CronDelete to cancel sooner.';
} else {
llmContent =
`Scheduled one-shot task ${job.id} (${job.cronExpr}). ` +
'Session-only (not written to disk, dies when Qwen Code exits). ' +
'It will fire once then auto-delete.';
}
return { llmContent, returnDisplay };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
llmContent: `Error creating cron job: ${message}`,
returnDisplay: message,
error: { message },
};
}
}
}
export class CronCreateTool extends BaseDeclarativeTool<
CronCreateParams,
ToolResult
> {
static readonly Name = ToolNames.CRON_CREATE;
constructor(private config: Config) {
super(
CronCreateTool.Name,
ToolDisplayNames.CRON_CREATE,
'Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders.\n\n' +
'Uses standard 5-field cron in the user\'s local timezone: minute hour day-of-month month day-of-week. "0 9 * * *" means 9am local — no timezone conversion needed.\n\n' +
'## One-shot tasks (recurring: false)\n\n' +
'For "remind me at X" or "at <time>, do Y" requests — fire once then auto-delete.\n' +
'Pin minute/hour/day-of-month/month to specific values:\n' +
' "remind me at 2:30pm today to check the deploy" → cron: "30 14 <today_dom> <today_month> *", recurring: false\n' +
' "tomorrow morning, run the smoke test" → cron: "57 8 <tomorrow_dom> <tomorrow_month> *", recurring: false\n\n' +
'## Recurring jobs (recurring: true, the default)\n\n' +
'For "every N minutes" / "every hour" / "weekdays at 9am" requests:\n' +
' "*/5 * * * *" (every 5 min), "0 * * * *" (hourly), "0 9 * * 1-5" (weekdays at 9am local)\n\n' +
'## Avoid the :00 and :30 minute marks when the task allows it\n\n' +
'Every user who asks for "9am" gets `0 9`, and every user who asks for "hourly" gets `0 *` — which means requests from across the planet land on the API at the same instant. When the user\'s request is approximate, pick a minute that is NOT 0 or 30:\n' +
' "every morning around 9" → "57 8 * * *" or "3 9 * * *" (not "0 9 * * *")\n' +
' "hourly" → "7 * * * *" (not "0 * * * *")\n' +
' "in an hour or so, remind me to..." → pick whatever minute you land on, don\'t round\n\n' +
'Only use minute 0 or 30 when the user names that exact time and clearly means it ("at 9:00 sharp", "at half past", coordinating with a meeting). When in doubt, nudge a few minutes early or late — the user will not notice, and the fleet will.\n\n' +
'## Session-only\n\n' +
'Jobs live only in this Qwen Code session — nothing is written to disk, and the job is gone when Qwen Code exits.\n\n' +
'## Runtime behavior\n\n' +
'Jobs only fire while the REPL is idle (not mid-query). The scheduler adds a small deterministic jitter on top of whatever you pick: recurring tasks fire up to 10% of their period late (max 15 min); one-shot tasks landing on :00 or :30 fire up to 90 s early. Picking an off-minute is still the bigger lever.\n\n' +
'Recurring tasks auto-expire after 3 days — they fire one final time, then are deleted. This bounds session lifetime. Tell the user about the 3-day limit when scheduling recurring jobs.\n\n' +
'Returns a job ID you can pass to CronDelete.',
Kind.Other,
{
type: 'object',
properties: {
cron: {
type: 'string',
description:
'Standard 5-field cron expression in local time: "M H DoM Mon DoW" (e.g. "*/5 * * * *" = every 5 minutes, "30 14 28 2 *" = Feb 28 at 2:30pm local once).',
},
prompt: {
type: 'string',
description: 'The prompt to enqueue at each fire time.',
},
recurring: {
type: 'boolean',
description:
'true (default) = fire on every cron match until deleted or auto-expired after 3 days. false = fire once at the next match, then auto-delete. Use false for "remind me at X" one-shot requests with pinned minute/hour/dom/month.',
},
},
required: ['cron', 'prompt'],
additionalProperties: false,
},
);
}
protected createInvocation(
params: CronCreateParams,
): ToolInvocation<CronCreateParams, ToolResult> {
return new CronCreateInvocation(this.config, params);
}
}

View file

@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CronDeleteTool } from './cron-delete.js';
import { CronScheduler } from '../services/cronScheduler.js';
function makeConfig() {
const scheduler = new CronScheduler();
return {
getCronScheduler: () => scheduler,
_scheduler: scheduler,
} as unknown as import('../config/config.js').Config & {
_scheduler: CronScheduler;
};
}
describe('CronDeleteTool', () => {
let config: ReturnType<typeof makeConfig>;
let tool: CronDeleteTool;
beforeEach(() => {
config = makeConfig();
tool = new CronDeleteTool(config);
});
it('has the correct name', () => {
expect(tool.name).toBe('cron_delete');
});
it('deletes an existing job', async () => {
const job = config._scheduler.create('*/1 * * * *', 'test', true);
const invocation = tool.build({ id: job.id });
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('Cancelled job');
expect(config._scheduler.list()).toHaveLength(0);
});
it('returns error for non-existent job', async () => {
const invocation = tool.build({ id: 'nonexist' });
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeDefined();
expect(result.llmContent).toContain('not found');
});
it('validates required params', () => {
expect(() => tool.build({} as never)).toThrow();
});
});

View file

@ -0,0 +1,79 @@
/**
* cron_delete tool deletes an in-session cron job by ID.
*/
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames, ToolDisplayNames } from './tool-names.js';
import type { Config } from '../config/config.js';
export interface CronDeleteParams {
id: string;
}
class CronDeleteInvocation extends BaseToolInvocation<
CronDeleteParams,
ToolResult
> {
constructor(
private config: Config,
params: CronDeleteParams,
) {
super(params);
}
getDescription(): string {
return this.params.id;
}
async execute(): Promise<ToolResult> {
const scheduler = this.config.getCronScheduler();
const deleted = scheduler.delete(this.params.id);
if (deleted) {
const llmContent = `Cancelled job ${this.params.id}.`;
const returnDisplay = `Cancelled ${this.params.id}`;
return { llmContent, returnDisplay };
} else {
const result = `Job ${this.params.id} not found.`;
return {
llmContent: result,
returnDisplay: result,
error: { message: result },
};
}
}
}
export class CronDeleteTool extends BaseDeclarativeTool<
CronDeleteParams,
ToolResult
> {
static readonly Name = ToolNames.CRON_DELETE;
constructor(private config: Config) {
super(
CronDeleteTool.Name,
ToolDisplayNames.CRON_DELETE,
'Cancel a cron job previously scheduled with CronCreate. Removes it from the in-memory session store.',
Kind.Other,
{
type: 'object',
properties: {
id: {
type: 'string',
description: 'Job ID returned by CronCreate.',
},
},
required: ['id'],
additionalProperties: false,
},
);
}
protected createInvocation(
params: CronDeleteParams,
): ToolInvocation<CronDeleteParams, ToolResult> {
return new CronDeleteInvocation(this.config, params);
}
}

View file

@ -0,0 +1,49 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { CronListTool } from './cron-list.js';
import { CronScheduler } from '../services/cronScheduler.js';
function makeConfig() {
const scheduler = new CronScheduler();
return {
getCronScheduler: () => scheduler,
_scheduler: scheduler,
} as unknown as import('../config/config.js').Config & {
_scheduler: CronScheduler;
};
}
describe('CronListTool', () => {
let config: ReturnType<typeof makeConfig>;
let tool: CronListTool;
beforeEach(() => {
config = makeConfig();
tool = new CronListTool(config);
});
it('has the correct name', () => {
expect(tool.name).toBe('cron_list');
});
it('returns empty message when no jobs', async () => {
const invocation = tool.build({});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain('No active cron jobs');
});
it('lists created jobs', async () => {
config._scheduler.create('*/5 * * * *', 'check build', true);
config._scheduler.create('*/1 * * * *', 'ping', false);
const invocation = tool.build({});
const result = await invocation.execute(new AbortController().signal);
expect(result.error).toBeUndefined();
expect(result.llmContent).toContain(
'(recurring) [session-only]: check build',
);
expect(result.llmContent).toContain('(one-shot) [session-only]: ping');
// Two lines, one per job
expect(String(result.llmContent).split('\n')).toHaveLength(2);
});
});

View file

@ -0,0 +1,75 @@
/**
* cron_list tool lists all active in-session cron jobs.
*/
import type { ToolInvocation, ToolResult } from './tools.js';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js';
import { ToolNames, ToolDisplayNames } from './tool-names.js';
import type { Config } from '../config/config.js';
import { humanReadableCron } from '../utils/cronDisplay.js';
export type CronListParams = Record<string, never>;
class CronListInvocation extends BaseToolInvocation<
CronListParams,
ToolResult
> {
constructor(
private config: Config,
params: CronListParams,
) {
super(params);
}
getDescription(): string {
return '';
}
async execute(): Promise<ToolResult> {
const scheduler = this.config.getCronScheduler();
const jobs = scheduler.list();
if (jobs.length === 0) {
const result = 'No active cron jobs.';
return { llmContent: result, returnDisplay: result };
}
const llmLines = jobs.map((job) => {
const type = job.recurring ? 'recurring' : 'one-shot';
return `${job.id}${job.cronExpr} (${type}) [session-only]: ${job.prompt}`;
});
const llmContent = llmLines.join('\n');
const displayLines = jobs.map((job) => `${job.id} ${humanReadableCron(job.cronExpr)}`);
const returnDisplay = displayLines.join('\n');
return { llmContent, returnDisplay };
}
}
export class CronListTool extends BaseDeclarativeTool<
CronListParams,
ToolResult
> {
static readonly Name = ToolNames.CRON_LIST;
constructor(private config: Config) {
super(
CronListTool.Name,
ToolDisplayNames.CRON_LIST,
'List all cron jobs scheduled via CronCreate in this session.',
Kind.Other,
{
type: 'object',
properties: {},
additionalProperties: false,
},
);
}
protected createInvocation(
params: CronListParams,
): ToolInvocation<CronListParams, ToolResult> {
return new CronListInvocation(this.config, params);
}
}

View file

@ -26,6 +26,9 @@ export const ToolNames = {
LS: 'list_directory', LS: 'list_directory',
LSP: 'lsp', LSP: 'lsp',
ASK_USER_QUESTION: 'ask_user_question', ASK_USER_QUESTION: 'ask_user_question',
CRON_CREATE: 'cron_create',
CRON_LIST: 'cron_list',
CRON_DELETE: 'cron_delete',
} as const; } as const;
/** /**
@ -50,6 +53,9 @@ export const ToolDisplayNames = {
LS: 'ListFiles', LS: 'ListFiles',
LSP: 'Lsp', LSP: 'Lsp',
ASK_USER_QUESTION: 'AskUserQuestion', ASK_USER_QUESTION: 'AskUserQuestion',
CRON_CREATE: 'CronCreate',
CRON_LIST: 'CronList',
CRON_DELETE: 'CronDelete',
} as const; } as const;
// Migration from old tool names to new tool names // Migration from old tool names to new tool names

View file

@ -0,0 +1,54 @@
/**
* Human-readable cron display for common recurring patterns.
* Falls back to the raw expression for anything non-trivial.
*/
export function humanReadableCron(cronExpr: string): string {
const parts = cronExpr.trim().split(/\s+/);
if (parts.length !== 5) return cronExpr;
const [min, hour, dom, mon, dow] = parts;
// */N * * * * → Every N minutes
if (
min!.startsWith('*/') &&
hour === '*' &&
dom === '*' &&
mon === '*' &&
dow === '*'
) {
const n = parseInt(min!.slice(2), 10);
if (!isNaN(n)) {
return n === 1 ? 'Every minute' : `Every ${n} minutes`;
}
}
// 0 */N * * * → Every N hours (or single minute with */N hours)
if (
/^\d+$/.test(min!) &&
hour!.startsWith('*/') &&
dom === '*' &&
mon === '*' &&
dow === '*'
) {
const n = parseInt(hour!.slice(2), 10);
if (!isNaN(n)) {
return n === 1 ? 'Every hour' : `Every ${n} hours`;
}
}
// M H */N * * → Every N days
if (
/^\d+$/.test(min!) &&
/^\d+$/.test(hour!) &&
dom!.startsWith('*/') &&
mon === '*' &&
dow === '*'
) {
const n = parseInt(dom!.slice(2), 10);
if (!isNaN(n)) {
return n === 1 ? 'Every day' : `Every ${n} days`;
}
}
return cronExpr;
}

View file

@ -0,0 +1,174 @@
import { describe, it, expect } from 'vitest';
import { matches, nextFireTime, parseCron } from './cronParser.js';
describe('parseCron', () => {
it('parses wildcard fields', () => {
const fields = parseCron('* * * * *');
expect(fields.minute.size).toBe(60);
expect(fields.hour.size).toBe(24);
expect(fields.dayOfMonth.size).toBe(31);
expect(fields.month.size).toBe(12);
expect(fields.dayOfWeek.size).toBe(7);
});
it('parses single values', () => {
const fields = parseCron('5 14 1 6 3');
expect([...fields.minute]).toEqual([5]);
expect([...fields.hour]).toEqual([14]);
expect([...fields.dayOfMonth]).toEqual([1]);
expect([...fields.month]).toEqual([6]);
expect([...fields.dayOfWeek]).toEqual([3]);
});
it('parses ranges', () => {
const fields = parseCron('1-5 * * * *');
expect([...fields.minute].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
});
it('parses comma lists', () => {
const fields = parseCron('0,15,30,45 * * * *');
expect([...fields.minute].sort((a, b) => a - b)).toEqual([0, 15, 30, 45]);
});
it('parses steps', () => {
const fields = parseCron('*/15 * * * *');
expect([...fields.minute].sort((a, b) => a - b)).toEqual([0, 15, 30, 45]);
});
it('parses range with step', () => {
const fields = parseCron('1-10/3 * * * *');
expect([...fields.minute].sort((a, b) => a - b)).toEqual([1, 4, 7, 10]);
});
it('throws on wrong number of fields', () => {
expect(() => parseCron('* * *')).toThrow('must have exactly 5 fields');
expect(() => parseCron('* * * * * *')).toThrow(
'must have exactly 5 fields',
);
});
it('throws on out-of-range values', () => {
expect(() => parseCron('60 * * * *')).toThrow('out of bounds');
expect(() => parseCron('* 25 * * *')).toThrow('out of bounds');
expect(() => parseCron('* * 0 * *')).toThrow('out of bounds');
expect(() => parseCron('* * * 13 *')).toThrow('out of bounds');
expect(() => parseCron('* * * * 8')).toThrow('out of bounds');
});
it('accepts 7 as Sunday and normalizes to 0', () => {
const fields = parseCron('* * * * 7');
expect(fields.dayOfWeek.has(0)).toBe(true);
expect(fields.dayOfWeek.has(7)).toBe(false);
});
it('throws on invalid step', () => {
expect(() => parseCron('*/0 * * * *')).toThrow('Invalid step');
});
});
describe('matches', () => {
it('matches every-minute cron', () => {
const date = new Date(2025, 0, 15, 10, 30); // Jan 15 2025, 10:30
expect(matches('* * * * *', date)).toBe(true);
});
it('matches specific minute', () => {
const date = new Date(2025, 0, 15, 10, 30);
expect(matches('30 * * * *', date)).toBe(true);
expect(matches('31 * * * *', date)).toBe(false);
});
it('matches specific hour and minute', () => {
const date = new Date(2025, 0, 15, 14, 0);
expect(matches('0 14 * * *', date)).toBe(true);
expect(matches('0 13 * * *', date)).toBe(false);
});
it('matches day of week', () => {
// Jan 15 2025 is a Wednesday (day 3)
const date = new Date(2025, 0, 15, 10, 0);
expect(matches('* * * * 3', date)).toBe(true);
expect(matches('* * * * 1', date)).toBe(false);
});
it('uses OR logic when both day-of-month and day-of-week are constrained', () => {
// Jan 15 2025 is a Wednesday (day 3), day-of-month 15
const date = new Date(2025, 0, 15, 10, 0);
// dom=1 (no match), dow=3 (match) → should match via OR
expect(matches('0 10 1 * 3', date)).toBe(true);
// dom=15 (match), dow=1 (no match) → should match via OR
expect(matches('0 10 15 * 1', date)).toBe(true);
// dom=1 (no match), dow=1 (no match) → no match
expect(matches('0 10 1 * 1', date)).toBe(false);
});
it('uses AND logic when only one day field is constrained', () => {
// Jan 15 2025 is a Wednesday (day 3)
const date = new Date(2025, 0, 15, 10, 0);
// dom=1, dow=* → AND, dom doesn't match
expect(matches('0 10 1 * *', date)).toBe(false);
// dom=*, dow=1 → AND, dow doesn't match
expect(matches('0 10 * * 1', date)).toBe(false);
});
it('matches every-N-minutes pattern', () => {
const date0 = new Date(2025, 0, 15, 10, 0);
const date5 = new Date(2025, 0, 15, 10, 5);
const date3 = new Date(2025, 0, 15, 10, 3);
expect(matches('*/5 * * * *', date0)).toBe(true);
expect(matches('*/5 * * * *', date5)).toBe(true);
expect(matches('*/5 * * * *', date3)).toBe(false);
});
});
describe('nextFireTime', () => {
it('finds next minute for * * * * *', () => {
const now = new Date(2025, 0, 15, 10, 30, 15); // 10:30:15
const next = nextFireTime('* * * * *', now);
expect(next.getHours()).toBe(10);
expect(next.getMinutes()).toBe(31);
expect(next.getSeconds()).toBe(0);
});
it('finds next match for specific minute', () => {
const now = new Date(2025, 0, 15, 10, 30, 0);
const next = nextFireTime('45 * * * *', now);
expect(next.getHours()).toBe(10);
expect(next.getMinutes()).toBe(45);
});
it('rolls to next hour when no match in current hour', () => {
const now = new Date(2025, 0, 15, 10, 50, 0);
const next = nextFireTime('15 * * * *', now);
expect(next.getHours()).toBe(11);
expect(next.getMinutes()).toBe(15);
});
it('finds next match for every-5-minutes', () => {
const now = new Date(2025, 0, 15, 10, 31, 0);
const next = nextFireTime('*/5 * * * *', now);
expect(next.getMinutes()).toBe(35);
});
it('finds next match for specific hour', () => {
const now = new Date(2025, 0, 15, 10, 30, 0);
const next = nextFireTime('0 14 * * *', now);
expect(next.getHours()).toBe(14);
expect(next.getMinutes()).toBe(0);
expect(next.getDate()).toBe(15);
});
it('rolls to next day for past time', () => {
const now = new Date(2025, 0, 15, 15, 0, 0); // 3pm
const next = nextFireTime('0 9 * * *', now); // 9am daily
expect(next.getDate()).toBe(16);
expect(next.getHours()).toBe(9);
});
it('returns time strictly after the input', () => {
// Even if `after` is exactly on a match minute, next should be the following match
const now = new Date(2025, 0, 15, 10, 0, 0); // exactly 10:00:00
const next = nextFireTime('*/5 * * * *', now);
expect(next.getTime()).toBeGreaterThan(now.getTime());
});
});

View file

@ -0,0 +1,186 @@
/**
* Minimal 5-field cron expression parser.
*
* Fields: minute (0-59), hour (0-23), day-of-month (1-31), month (1-12), day-of-week (0-7, 0 and 7=Sun)
* Supports: *, single values, steps (asterisk/N), ranges (a-b), comma lists (a,b,c)
* No extended syntax (L, W, ?, name aliases).
*/
interface CronFields {
minute: Set<number>;
hour: Set<number>;
dayOfMonth: Set<number>;
month: Set<number>;
dayOfWeek: Set<number>;
/** True when the day-of-month field was literally '*' (unrestricted). */
domIsWild: boolean;
/** True when the day-of-week field was literally '*' (unrestricted). */
dowIsWild: boolean;
}
const FIELD_RANGES: Array<[number, number]> = [
[0, 59], // minute
[0, 23], // hour
[1, 31], // day of month
[1, 12], // month
[0, 7], // day of week (0 and 7 both mean Sunday)
];
/**
* Parses a single cron field into a set of matching values.
* Supports: star, single values, steps (star/N), ranges (a-b), comma lists.
*/
function parseField(field: string, min: number, max: number): Set<number> {
const values = new Set<number>();
for (const part of field.split(',')) {
const trimmed = part.trim();
if (!trimmed) {
throw new Error(`Empty field segment in "${field}"`);
}
// Handle step: */N or range/N or value/N
const stepParts = trimmed.split('/');
if (stepParts.length > 2) {
throw new Error(`Invalid step expression: "${trimmed}"`);
}
let rangeStart: number;
let rangeEnd: number;
const base = stepParts[0]!;
if (base === '*') {
rangeStart = min;
rangeEnd = max;
} else if (base.includes('-')) {
const [startStr, endStr] = base.split('-');
rangeStart = parseInt(startStr!, 10);
rangeEnd = parseInt(endStr!, 10);
if (isNaN(rangeStart) || isNaN(rangeEnd)) {
throw new Error(`Invalid range: "${base}"`);
}
if (rangeStart < min || rangeEnd > max || rangeStart > rangeEnd) {
throw new Error(`Range ${base} out of bounds [${min}-${max}]`);
}
} else {
const val = parseInt(base, 10);
if (isNaN(val) || val < min || val > max) {
throw new Error(`Value "${base}" out of bounds [${min}-${max}]`);
}
rangeStart = val;
rangeEnd = val;
}
const step = stepParts.length === 2 ? parseInt(stepParts[1]!, 10) : 1;
if (isNaN(step) || step <= 0) {
throw new Error(`Invalid step: "${stepParts[1]}"`);
}
for (let i = rangeStart; i <= rangeEnd; i += step) {
values.add(i);
}
}
return values;
}
/**
* Parses a 5-field cron expression into structured fields.
* Throws on invalid expressions.
*/
export function parseCron(cronExpr: string): CronFields {
const parts = cronExpr.trim().split(/\s+/);
if (parts.length !== 5) {
throw new Error(
`Cron expression must have exactly 5 fields, got ${parts.length}: "${cronExpr}"`,
);
}
// Parse day-of-week with range 0-7, then normalize 7 → 0 (both mean Sunday)
const dayOfWeek = parseField(
parts[4]!,
FIELD_RANGES[4]![0],
FIELD_RANGES[4]![1],
);
if (dayOfWeek.has(7)) {
dayOfWeek.delete(7);
dayOfWeek.add(0);
}
return {
minute: parseField(parts[0]!, FIELD_RANGES[0]![0], FIELD_RANGES[0]![1]),
hour: parseField(parts[1]!, FIELD_RANGES[1]![0], FIELD_RANGES[1]![1]),
dayOfMonth: parseField(parts[2]!, FIELD_RANGES[2]![0], FIELD_RANGES[2]![1]),
month: parseField(parts[3]!, FIELD_RANGES[3]![0], FIELD_RANGES[3]![1]),
dayOfWeek,
domIsWild: parts[2]!.trim() === '*',
dowIsWild: parts[4]!.trim() === '*',
};
}
/**
* Returns true if the given date matches the cron expression.
*
* Follows vixie-cron day semantics: when both day-of-month and day-of-week
* are constrained (neither is `*`), the date matches if EITHER field matches.
* When only one is constrained, it must match.
*/
export function matches(cronExpr: string, date: Date): boolean {
const fields = parseCron(cronExpr);
if (
!fields.minute.has(date.getMinutes()) ||
!fields.hour.has(date.getHours()) ||
!fields.month.has(date.getMonth() + 1)
) {
return false;
}
const domMatch = fields.dayOfMonth.has(date.getDate());
const dowMatch = fields.dayOfWeek.has(date.getDay());
// Vixie-cron: if both day-of-month and day-of-week are restricted,
// match if EITHER is satisfied. Otherwise use AND.
if (!fields.domIsWild && !fields.dowIsWild) {
return domMatch || dowMatch;
}
return domMatch && dowMatch;
}
/**
* Returns the next fire time after `after` for the given cron expression.
* Scans forward minute-by-minute (up to ~4 years) to find the next match.
*/
export function nextFireTime(cronExpr: string, after: Date): Date {
const fields = parseCron(cronExpr);
// Start at the next whole minute after `after`
const candidate = new Date(after.getTime());
candidate.setSeconds(0, 0);
candidate.setMinutes(candidate.getMinutes() + 1);
// Scan up to 4 years (~2.1M minutes) to avoid infinite loops
const maxIterations = 4 * 366 * 24 * 60;
for (let i = 0; i < maxIterations; i++) {
const minuteOk = fields.minute.has(candidate.getMinutes());
const hourOk = fields.hour.has(candidate.getHours());
const monthOk = fields.month.has(candidate.getMonth() + 1);
const domOk = fields.dayOfMonth.has(candidate.getDate());
const dowOk = fields.dayOfWeek.has(candidate.getDay());
// Vixie-cron day semantics: OR when both constrained, AND otherwise
const dayOk =
!fields.domIsWild && !fields.dowIsWild ? domOk || dowOk : domOk && dowOk;
if (minuteOk && hourOk && monthOk && dayOk) {
return candidate;
}
candidate.setMinutes(candidate.getMinutes() + 1);
}
throw new Error(
`No matching fire time found within 4 years for: "${cronExpr}"`,
);
}

View file

@ -1432,9 +1432,15 @@
} }
}, },
"experimental": { "experimental": {
"description": "Setting to enable experimental features", "description": "Settings to enable experimental features.",
"type": "object", "type": "object",
"properties": {} "properties": {
"cron": {
"description": "Enable in-session cron/loop tools (experimental). When enabled, the model can create recurring prompts using cron_create, cron_list, and cron_delete tools. Can also be enabled via QWEN_CODE_ENABLE_CRON=1 environment variable.",
"type": "boolean",
"default": false
}
}
}, },
"$version": { "$version": {
"type": "number", "type": "number",