feat(v2): add pre-task script hook for scheduled tasks

Scheduled tasks can now carry a bash script that runs inside the container
before the agent is invoked. The script prints `{wakeAgent, data?}` on its
last stdout line; if `wakeAgent: false` (or the script errors) the task
row is marked completed and the agent is never queried, saving API calls
on no-op checks. On wake, the script's `data` is injected into the task
prompt. Semantics mirror V1: 30s bash timeout, 1MB buffer, last-line JSON,
error == skip.

Also blocks the Claude SDK's built-in scheduling tools (CronCreate,
CronDelete, CronList, ScheduleWakeup) via `disallowedTools` so tasks
actually flow through `mcp__nanoclaw__schedule_task` and get the script
gate. CLAUDE.md gains a soft pointer explaining why `schedule_task` is
the right path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
exe.dev user
2026-04-16 14:57:14 +00:00
committed by Daniel
parent 82422d2077
commit b9f95df340
4 changed files with 149 additions and 4 deletions

View File

@@ -4,6 +4,7 @@ import { writeMessageOut } from './db/messages-out.js';
import { touchHeartbeat, clearStaleProcessingAcks } from './db/connection.js';
import { getStoredSessionId, setStoredSessionId, clearStoredSessionId } from './db/session-state.js';
import { formatMessages, extractRouting, categorizeMessage, type RoutingContext } from './formatter.js';
import { applyPreTaskScripts } from './task-script.js';
import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js';
const POLL_INTERVAL_MS = 1000;
@@ -152,11 +153,25 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
continue;
}
// Pre-task scripts: for any task rows with a `script`, run it before the
// provider call. Scripts returning wakeAgent=false (or erroring) gate
// their own task row only — surviving messages still go to the agent.
const { keep, skipped } = await applyPreTaskScripts(normalMessages);
if (skipped.length > 0) {
markCompleted(skipped);
log(`Pre-task script skipped ${skipped.length} task(s): ${skipped.join(', ')}`);
}
if (keep.length === 0) {
log(`All ${normalMessages.length} non-command message(s) gated by script, skipping query`);
continue;
}
// Format messages: passthrough commands get raw text (only if the
// provider natively handles slash commands), others get XML.
const prompt = formatMessagesWithCommands(normalMessages, config.provider.supportsNativeSlashCommands);
const prompt = formatMessagesWithCommands(keep, config.provider.supportsNativeSlashCommands);
log(`Processing ${normalMessages.length} message(s), kinds: ${[...new Set(normalMessages.map((m) => m.kind))].join(',')}`);
log(`Processing ${keep.length} message(s), kinds: ${[...new Set(keep.map((m) => m.kind))].join(',')}`);
const query = config.provider.query({
prompt,
@@ -166,7 +181,8 @@ export async function runPollLoop(config: PollLoopConfig): Promise<void> {
});
// Process the query while concurrently polling for new messages
const processingIds = ids.filter((id) => !commandIds.includes(id));
const skippedSet = new Set(skipped);
const processingIds = ids.filter((id) => !commandIds.includes(id) && !skippedSet.has(id));
try {
const result = await processQuery(query, routing, config, processingIds);
if (result.continuation && result.continuation !== continuation) {