Merge branch 'main' into fix/diagnostics-read-directive

This commit is contained in:
gavrielc
2026-03-25 17:29:44 +02:00
committed by GitHub
11 changed files with 160 additions and 12 deletions

View File

@@ -16,6 +16,7 @@
import fs from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import { query, HookCallback, PreCompactHookInput } from '@anthropic-ai/claude-agent-sdk';
import { fileURLToPath } from 'url';
@@ -27,6 +28,7 @@ interface ContainerInput {
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
script?: string;
}
interface ContainerOutput {
@@ -464,6 +466,55 @@ async function runQuery(
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> {
let containerInput: ContainerInput;
@@ -505,6 +556,26 @@ async function main(): Promise<void> {
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
let resumeAt: string | undefined;
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!)'),
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.'),
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) => {
// Validate schedule_value before writing IPC
@@ -136,6 +137,7 @@ SCHEDULE VALUE FORMAT (all times are LOCAL timezone):
type: 'schedule_task',
taskId,
prompt: args.prompt,
script: args.script || undefined,
schedule_type: args.schedule_type,
schedule_value: args.schedule_value,
context_mode: args.context_mode || 'group',
@@ -255,6 +257,7 @@ server.tool(
prompt: z.string().optional().describe('New prompt for the task'),
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)'),
script: z.string().optional().describe('New script for the task. Set to empty string to remove the script.'),
},
async (args) => {
// Validate schedule_value if provided
@@ -288,6 +291,7 @@ server.tool(
timestamp: new Date().toISOString(),
};
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_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")`
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

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "nanoclaw",
"version": "1.2.27",
"version": "1.2.29",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "nanoclaw",
"version": "1.2.27",
"version": "1.2.29",
"dependencies": {
"@onecli-sh/sdk": "^0.2.0",
"better-sqlite3": "11.10.0",

View File

@@ -1,6 +1,6 @@
{
"name": "nanoclaw",
"version": "1.2.27",
"version": "1.2.29",
"description": "Personal Claude assistant. Lightweight, secure, customizable.",
"type": "module",
"main": "dist/index.js",

View File

@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="40.1k tokens, 20% of context window">
<title>40.1k tokens, 20% of context window</title>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="97" height="20" role="img" aria-label="41.0k tokens, 20% of context window">
<title>41.0k tokens, 20% of context window</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,8 +15,8 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11">
<text aria-hidden="true" x="26" y="15" fill="#010101" fill-opacity=".3">tokens</text>
<text x="26" y="14">tokens</text>
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">40.1k</text>
<text x="74" y="14">40.1k</text>
<text aria-hidden="true" x="74" y="15" fill="#010101" fill-opacity=".3">41.0k</text>
<text x="74" y="14">41.0k</text>
</g>
</g>
</a>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -42,6 +42,7 @@ export interface ContainerInput {
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
script?: string;
}
export interface ContainerOutput {
@@ -191,9 +192,18 @@ function buildVolumeMounts(
group.folder,
'agent-runner-src',
);
if (!fs.existsSync(groupAgentRunnerDir) && fs.existsSync(agentRunnerSrc)) {
if (fs.existsSync(agentRunnerSrc)) {
const srcIndex = path.join(agentRunnerSrc, 'index.ts');
const cachedIndex = path.join(groupAgentRunnerDir, 'index.ts');
const needsCopy =
!fs.existsSync(groupAgentRunnerDir) ||
!fs.existsSync(cachedIndex) ||
(fs.existsSync(srcIndex) &&
fs.statSync(srcIndex).mtimeMs > fs.statSync(cachedIndex).mtimeMs);
if (needsCopy) {
fs.cpSync(agentRunnerSrc, groupAgentRunnerDir, { recursive: true });
}
}
mounts.push({
hostPath: groupAgentRunnerDir,
containerPath: '/app/src',
@@ -667,6 +677,7 @@ export function writeTasksSnapshot(
id: string;
groupFolder: string;
prompt: string;
script?: string | null;
schedule_type: string;
schedule_value: string;
status: string;

View File

@@ -93,6 +93,13 @@ function createSchema(database: Database.Database): void {
/* 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)
try {
database.exec(
@@ -368,14 +375,15 @@ export function createTask(
): void {
db.prepare(
`
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, schedule_type, schedule_value, context_mode, next_run, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO scheduled_tasks (id, group_folder, chat_jid, prompt, script, schedule_type, schedule_value, context_mode, next_run, status, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
task.id,
task.group_folder,
task.chat_jid,
task.prompt,
task.script || null,
task.schedule_type,
task.schedule_value,
task.context_mode || 'isolated',
@@ -410,7 +418,12 @@ export function updateTask(
updates: Partial<
Pick<
ScheduledTask,
'prompt' | 'schedule_type' | 'schedule_value' | 'next_run' | 'status'
| 'prompt'
| 'script'
| 'schedule_type'
| 'schedule_value'
| 'next_run'
| 'status'
>
>,
): void {
@@ -421,6 +434,10 @@ export function updateTask(
fields.push('prompt = ?');
values.push(updates.prompt);
}
if (updates.script !== undefined) {
fields.push('script = ?');
values.push(updates.script || null);
}
if (updates.schedule_type !== undefined) {
fields.push('schedule_type = ?');
values.push(updates.schedule_type);

View File

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

View File

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

View File

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