Merge pull request #1232 from gabi-simons/feat/scheduled-task-scripts-clean

feat: add script support to scheduled tasks
This commit is contained in:
gavrielc
2026-03-25 17:28:10 +02:00
committed by GitHub
8 changed files with 142 additions and 3 deletions

View File

@@ -16,6 +16,7 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { execFile } from 'child_process';
import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk'; import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@@ -27,6 +28,7 @@ interface ContainerInput {
isMain: boolean; isMain: boolean;
isScheduledTask?: boolean; isScheduledTask?: boolean;
assistantName?: string; assistantName?: string;
script?: string;
} }
interface ContainerOutput { interface ContainerOutput {
@@ -464,6 +466,55 @@ async function runQuery(
return { newSessionId, lastAssistantUuid, closedDuringQuery }; return { newSessionId, lastAssistantUuid, closedDuringQuery };
} }
interface ScriptResult {
wakeAgent: boolean;
data?: unknown;
}
const SCRIPT_TIMEOUT_MS = 30_000;
async function runScript(script: string): Promise<ScriptResult | null> {
const scriptPath = '/tmp/task-script.sh';
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
return new Promise((resolve) => {
execFile('bash', [scriptPath], {
timeout: SCRIPT_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
env: process.env,
}, (error, stdout, stderr) => {
if (stderr) {
log(`Script stderr: ${stderr.slice(0, 500)}`);
}
if (error) {
log(`Script error: ${error.message}`);
return resolve(null);
}
// Parse last non-empty line of stdout as JSON
const lines = stdout.trim().split('\n');
const lastLine = lines[lines.length - 1];
if (!lastLine) {
log('Script produced no output');
return resolve(null);
}
try {
const result = JSON.parse(lastLine);
if (typeof result.wakeAgent !== 'boolean') {
log(`Script output missing wakeAgent boolean: ${lastLine.slice(0, 200)}`);
return resolve(null);
}
resolve(result as ScriptResult);
} catch {
log(`Script output is not valid JSON: ${lastLine.slice(0, 200)}`);
resolve(null);
}
});
});
}
async function main(): Promise<void> { async function main(): Promise<void> {
let containerInput: ContainerInput; let containerInput: ContainerInput;
@@ -505,6 +556,26 @@ async function main(): Promise<void> {
prompt += '\n' + pending.join('\n'); prompt += '\n' + pending.join('\n');
} }
// Script phase: run script before waking agent
if (containerInput.script && containerInput.isScheduledTask) {
log('Running task script...');
const scriptResult = await runScript(containerInput.script);
if (!scriptResult || !scriptResult.wakeAgent) {
const reason = scriptResult ? 'wakeAgent=false' : 'script error/no output';
log(`Script decided not to wake agent: ${reason}`);
writeOutput({
status: 'success',
result: null,
});
return;
}
// Script says wake agent — enrich prompt with script data
log(`Script wakeAgent=true, enriching prompt with data`);
prompt = `[SCHEDULED TASK]\n\nScript output:\n${JSON.stringify(scriptResult.data, null, 2)}\n\nInstructions:\n${containerInput.prompt}`;
}
// Query loop: run query → wait for IPC message → run new query → repeat // Query loop: run query → wait for IPC message → run new query → repeat
let resumeAt: string | undefined; let resumeAt: string | undefined;
try { try {

View File

@@ -91,6 +91,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'), schedule_value: z.string().describe('cron: "*/5 * * * *" | interval: milliseconds like "300000" | once: local timestamp like "2026-02-01T15:30:00" (no Z suffix!)'),
context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'), context_mode: z.enum(['group', 'isolated']).default('group').describe('group=runs with chat history and memory, isolated=fresh session (include context in prompt)'),
target_group_jid: z.string().optional().describe('(Main group only) JID of the group to schedule the task for. Defaults to the current group.'), target_group_jid: z.string().optional().describe('(Main group only) JID of the group to schedule the task for. Defaults to the current group.'),
script: z.string().optional().describe('Optional bash script to run before waking the agent. Script must output JSON on the last line of stdout: { "wakeAgent": boolean, "data"?: any }. If wakeAgent is false, the agent is not called. Test your script with bash -c "..." before scheduling.'),
}, },
async (args) => { async (args) => {
// Validate schedule_value before writing IPC // Validate schedule_value before writing IPC
@@ -136,6 +137,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
type: 'schedule_task', type: 'schedule_task',
taskId, taskId,
prompt: args.prompt, prompt: args.prompt,
script: args.script || undefined,
schedule_type: args.schedule_type, schedule_type: args.schedule_type,
schedule_value: args.schedule_value, schedule_value: args.schedule_value,
context_mode: args.context_mode || 'group', context_mode: args.context_mode || 'group',
@@ -255,6 +257,7 @@ server.tool(
prompt: z.string().optional().describe('New prompt for the task'), prompt: z.string().optional().describe('New prompt for the task'),
schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'), schedule_type: z.enum(['cron', 'interval', 'once']).optional().describe('New schedule type'),
schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'), schedule_value: z.string().optional().describe('New schedule value (see schedule_task for format)'),
script: z.string().optional().describe('New script for the task. Set to empty string to remove the script.'),
}, },
async (args) => { async (args) => {
// Validate schedule_value if provided // Validate schedule_value if provided
@@ -288,6 +291,7 @@ server.tool(
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
}; };
if (args.prompt !== undefined) data.prompt = args.prompt; if (args.prompt !== undefined) data.prompt = args.prompt;
if (args.script !== undefined) data.script = args.script;
if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type; if (args.schedule_type !== undefined) data.schedule_type = args.schedule_type;
if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value; if (args.schedule_value !== undefined) data.schedule_value = args.schedule_value;

View File

@@ -262,3 +262,42 @@ When scheduling tasks for other groups, use the `target_group_jid` parameter wit
- `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")` - `schedule_task(prompt: "...", schedule_type: "cron", schedule_value: "0 9 * * 1", target_group_jid: "120363336345536173@g.us")`
The task will run in that group's context with access to their files and memory. The task will run in that group's context with access to their files and memory.
---
## Task Scripts
When scheduling tasks that check a condition before acting (new PRs, website changes, API status), use the `script` parameter. The script runs first — if there's nothing to do, you don't wake up.
### How it works
1. You provide a bash `script` alongside the `prompt` when scheduling
2. When the task fires, the script runs first (30-second timeout)
3. Script prints JSON to stdout: `{ "wakeAgent": true/false, "data": {...} }`
4. If `wakeAgent: false` — nothing happens, task waits for next run
5. If `wakeAgent: true` — you wake up and receive the script's data + prompt
### Always test your script first
Before scheduling, run the script in your sandbox to verify it works:
```bash
bash -c 'node --input-type=module -e "
const r = await fetch(\"https://api.github.com/repos/owner/repo/pulls?state=open\");
const prs = await r.json();
console.log(JSON.stringify({ wakeAgent: prs.length > 0, data: prs.slice(0, 5) }));
"'
```
### When NOT to use scripts
If a task requires your judgment every time (daily briefings, reminders, reports), skip the script — just use a regular prompt.
### Frequent task guidance
If a user wants tasks running more than ~2x daily and a script can't reduce agent wake-ups:
- Explain that each wake-up uses API credits and risks rate limits
- Suggest restructuring with a script that checks the condition first
- If the user needs an LLM to evaluate data, suggest using an API key with direct Anthropic API calls inside the script
- Help the user find the minimum viable frequency

View File

@@ -42,6 +42,7 @@ export interface ContainerInput {
isMain: boolean; isMain: boolean;
isScheduledTask?: boolean; isScheduledTask?: boolean;
assistantName?: string; assistantName?: string;
script?: string;
} }
export interface ContainerOutput { export interface ContainerOutput {
@@ -676,6 +677,7 @@ export function writeTasksSnapshot(
id: string; id: string;
groupFolder: string; groupFolder: string;
prompt: string; prompt: string;
script?: string | null;
schedule_type: string; schedule_type: string;
schedule_value: string; schedule_value: string;
status: string; status: string;

View File

@@ -93,6 +93,13 @@ function createSchema(database: Database.Database): void {
/* column already exists */ /* column already exists */
} }
// Add script column if it doesn't exist (migration for existing DBs)
try {
database.exec(`ALTER TABLE scheduled_tasks ADD COLUMN script TEXT`);
} catch {
/* column already exists */
}
// Add is_bot_message column if it doesn't exist (migration for existing DBs) // Add is_bot_message column if it doesn't exist (migration for existing DBs)
try { try {
database.exec( database.exec(
@@ -368,14 +375,15 @@ export function createTask(
): void { ): void {
db.prepare( db.prepare(
` `
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at) INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, `,
).run( ).run(
task.id, task.id,
task.group_folder, task.group_folder,
task.chat_jid, task.chat_jid,
task.prompt, task.prompt,
task.script || null,
task.schedule_type, task.schedule_type,
task.schedule_value, task.schedule_value,
task.context_mode || 'isolated', task.context_mode || 'isolated',
@@ -410,7 +418,12 @@ export function updateTask(
updates: Partial< updates: Partial<
Pick< Pick<
ScheduledTask, ScheduledTask,
'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status' | 'prompt'
| 'script'
| 'schedule_type'
| 'schedule_value'
| 'next_run'
| 'status'
> >
>, >,
): void { ): void {
@@ -421,6 +434,10 @@ export function updateTask(
fields.push('prompt = ?'); fields.push('prompt = ?');
values.push(updates.prompt); values.push(updates.prompt);
} }
if (updates.script !== undefined) {
fields.push('script = ?');
values.push(updates.script || null);
}
if (updates.schedule_type !== undefined) { if (updates.schedule_type !== undefined) {
fields.push('schedule_type = ?'); fields.push('schedule_type = ?');
values.push(updates.schedule_type); values.push(updates.schedule_type);

View File

@@ -162,6 +162,7 @@ export async function processTaskIpc(
schedule_type?: string; schedule_type?: string;
schedule_value?: string; schedule_value?: string;
context_mode?: string; context_mode?: string;
script?: string;
groupFolder?: string; groupFolder?: string;
chatJid?: string; chatJid?: string;
targetJid?: string; targetJid?: string;
@@ -260,6 +261,7 @@ export async function processTaskIpc(
group_folder: targetFolder, group_folder: targetFolder,
chat_jid: targetJid, chat_jid: targetJid,
prompt: data.prompt, prompt: data.prompt,
script: data.script || null,
schedule_type: scheduleType, schedule_type: scheduleType,
schedule_value: data.schedule_value, schedule_value: data.schedule_value,
context_mode: contextMode, context_mode: contextMode,
@@ -352,6 +354,7 @@ export async function processTaskIpc(
const updates: Parameters<typeof updateTask>[1] = {}; const updates: Parameters<typeof updateTask>[1] = {};
if (data.prompt !== undefined) updates.prompt = data.prompt; if (data.prompt !== undefined) updates.prompt = data.prompt;
if (data.script !== undefined) updates.script = data.script || null;
if (data.schedule_type !== undefined) if (data.schedule_type !== undefined)
updates.schedule_type = data.schedule_type as updates.schedule_type = data.schedule_type as
| 'cron' | 'cron'

View File

@@ -139,6 +139,7 @@ async function runTask(
id: t.id, id: t.id,
groupFolder: t.group_folder, groupFolder: t.group_folder,
prompt: t.prompt, prompt: t.prompt,
script: t.script,
schedule_type: t.schedule_type, schedule_type: t.schedule_type,
schedule_value: t.schedule_value, schedule_value: t.schedule_value,
status: t.status, status: t.status,
@@ -179,6 +180,7 @@ async function runTask(
isMain, isMain,
isScheduledTask: true, isScheduledTask: true,
assistantName: ASSISTANT_NAME, assistantName: ASSISTANT_NAME,
script: task.script || undefined,
}, },
(proc, containerName) => (proc, containerName) =>
deps.onProcess(task.chat_jid, proc, containerName, task.group_folder), deps.onProcess(task.chat_jid, proc, containerName, task.group_folder),

View File

@@ -58,6 +58,7 @@ export interface ScheduledTask {
group_folder: string; group_folder: string;
chat_jid: string; chat_jid: string;
prompt: string; prompt: string;
script?: string | null;
schedule_type: 'cron' | 'interval' | 'once'; schedule_type: 'cron' | 'interval' | 'once';
schedule_value: string; schedule_value: string;
context_mode: 'group' | 'isolated'; context_mode: 'group' | 'isolated';