@yail259/overnight 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@yail259/overnight",
3
+ "version": "0.1.0",
4
+ "description": "Batch job runner for Claude Code - queue tasks, run overnight, wake up to results",
5
+ "type": "module",
6
+ "bin": {
7
+ "overnight": "./dist/cli.js"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "https://github.com/yail259/overnight.git"
12
+ },
13
+ "keywords": ["claude", "claude-code", "batch", "jobs", "automation", "ai"],
14
+ "author": "yail259",
15
+ "license": "MIT",
16
+ "publishConfig": {
17
+ "access": "public"
18
+ },
19
+ "scripts": {
20
+ "build": "bun build src/cli.ts --outdir dist --target node",
21
+ "compile": "bun build src/cli.ts --compile --outfile overnight",
22
+ "dev": "bun run src/cli.ts"
23
+ },
24
+ "dependencies": {
25
+ "@anthropic-ai/claude-agent-sdk": "^0.1.0",
26
+ "commander": "^12.0.0",
27
+ "yaml": "^2.3.4"
28
+ },
29
+ "devDependencies": {
30
+ "@types/node": "^20.11.0",
31
+ "typescript": "^5.3.0"
32
+ }
33
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,434 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { readFileSync, writeFileSync, existsSync } from "fs";
4
+ import { parse as parseYaml } from "yaml";
5
+ import {
6
+ type JobConfig,
7
+ type JobResult,
8
+ type TasksFile,
9
+ DEFAULT_TIMEOUT,
10
+ DEFAULT_STALL_TIMEOUT,
11
+ DEFAULT_VERIFY_PROMPT,
12
+ DEFAULT_STATE_FILE,
13
+ DEFAULT_NTFY_TOPIC,
14
+ } from "./types.js";
15
+ import {
16
+ runJob,
17
+ runJobsWithState,
18
+ loadState,
19
+ resultsToJson,
20
+ } from "./runner.js";
21
+ import { sendNtfyNotification } from "./notify.js";
22
+ import { generateReport } from "./report.js";
23
+
24
+ const AGENT_HELP = `
25
+ # overnight - Batch Job Runner for Claude Code
26
+
27
+ Queue tasks, run them unattended, get results. Designed for overnight/AFK use.
28
+
29
+ ## Quick Start
30
+
31
+ \`\`\`bash
32
+ # Create a tasks.yaml file
33
+ overnight init
34
+
35
+ # Run all tasks
36
+ overnight run tasks.yaml
37
+
38
+ # Run with notifications and report
39
+ overnight run tasks.yaml --notify -r report.md
40
+ \`\`\`
41
+
42
+ ## Commands
43
+
44
+ | Command | Description |
45
+ |---------|-------------|
46
+ | \`overnight run <file>\` | Run jobs from YAML file |
47
+ | \`overnight resume <file>\` | Resume interrupted run from checkpoint |
48
+ | \`overnight single "<prompt>"\` | Run a single task directly |
49
+ | \`overnight init\` | Create example tasks.yaml |
50
+
51
+ ## tasks.yaml Format
52
+
53
+ \`\`\`yaml
54
+ defaults:
55
+ timeout_seconds: 300 # Per-task timeout (default: 300)
56
+ verify: true # Run verification pass (default: true)
57
+ allowed_tools: # Whitelist tools (default: Read,Edit,Write,Glob,Grep)
58
+ - Read
59
+ - Edit
60
+ - Glob
61
+ - Grep
62
+
63
+ tasks:
64
+ # Simple format
65
+ - "Fix the bug in auth.py"
66
+
67
+ # Detailed format
68
+ - prompt: "Add input validation"
69
+ timeout_seconds: 600
70
+ verify: false
71
+ allowed_tools: [Read, Edit, Bash, Glob, Grep]
72
+ \`\`\`
73
+
74
+ ## Key Options
75
+
76
+ | Option | Description |
77
+ |--------|-------------|
78
+ | \`-o, --output <file>\` | Save results JSON |
79
+ | \`-r, --report <file>\` | Generate markdown report |
80
+ | \`-s, --state-file <file>\` | Custom checkpoint file |
81
+ | \`--notify\` | Send push notification via ntfy.sh |
82
+ | \`--notify-topic <topic>\` | ntfy.sh topic (default: overnight) |
83
+ | \`-q, --quiet\` | Minimal output |
84
+
85
+ ## Features
86
+
87
+ 1. **Crash Recovery**: Auto-checkpoints after each job. Use \`overnight resume\` to continue.
88
+ 2. **Retry Logic**: Auto-retries 3x on API/network errors with exponential backoff.
89
+ 3. **Notifications**: \`--notify\` sends summary to ntfy.sh (free, no signup).
90
+ 4. **Reports**: \`-r report.md\` generates markdown summary with next steps.
91
+ 5. **Security**: No Bash by default. Whitelist tools per-task.
92
+
93
+ ## Example Workflows
94
+
95
+ \`\`\`bash
96
+ # Development: run overnight, check in morning
97
+ nohup overnight run tasks.yaml --notify -r report.md -o results.json > overnight.log 2>&1 &
98
+
99
+ # CI/CD: run and fail if any task fails
100
+ overnight run tasks.yaml -q
101
+
102
+ # Single task with Bash access
103
+ overnight single "Run tests and fix failures" -T Read -T Edit -T Bash -T Glob
104
+
105
+ # Resume after crash/interrupt
106
+ overnight resume tasks.yaml
107
+ \`\`\`
108
+
109
+ ## Exit Codes
110
+
111
+ - 0: All tasks succeeded
112
+ - 1: One or more tasks failed
113
+
114
+ ## Files Created
115
+
116
+ - \`.overnight-state.json\` - Checkpoint file (deleted on success)
117
+ - \`report.md\` - Summary report (if -r used)
118
+ - \`results.json\` - Full results (if -o used)
119
+
120
+ Run \`overnight <command> --help\` for command-specific options.
121
+ `;
122
+
123
+ function parseTasksFile(path: string): JobConfig[] {
124
+ const content = readFileSync(path, "utf-8");
125
+ const data = parseYaml(content) as TasksFile | (string | JobConfig)[];
126
+
127
+ const tasks = Array.isArray(data) ? data : data.tasks ?? [];
128
+ const defaults = Array.isArray(data) ? {} : data.defaults ?? {};
129
+
130
+ return tasks.map((task) => {
131
+ if (typeof task === "string") {
132
+ return {
133
+ prompt: task,
134
+ timeout_seconds: defaults.timeout_seconds ?? DEFAULT_TIMEOUT,
135
+ stall_timeout_seconds:
136
+ defaults.stall_timeout_seconds ?? DEFAULT_STALL_TIMEOUT,
137
+ verify: defaults.verify ?? true,
138
+ verify_prompt: defaults.verify_prompt ?? DEFAULT_VERIFY_PROMPT,
139
+ allowed_tools: defaults.allowed_tools,
140
+ };
141
+ }
142
+ return {
143
+ prompt: task.prompt,
144
+ working_dir: task.working_dir ?? undefined,
145
+ timeout_seconds:
146
+ task.timeout_seconds ?? defaults.timeout_seconds ?? DEFAULT_TIMEOUT,
147
+ stall_timeout_seconds:
148
+ task.stall_timeout_seconds ??
149
+ defaults.stall_timeout_seconds ??
150
+ DEFAULT_STALL_TIMEOUT,
151
+ verify: task.verify ?? defaults.verify ?? true,
152
+ verify_prompt:
153
+ task.verify_prompt ?? defaults.verify_prompt ?? DEFAULT_VERIFY_PROMPT,
154
+ allowed_tools: task.allowed_tools ?? defaults.allowed_tools,
155
+ };
156
+ });
157
+ }
158
+
159
+ function printSummary(results: JobResult[]): void {
160
+ const statusColors: Record<string, string> = {
161
+ success: "\x1b[32m",
162
+ failed: "\x1b[31m",
163
+ timeout: "\x1b[33m",
164
+ stalled: "\x1b[35m",
165
+ verification_failed: "\x1b[33m",
166
+ };
167
+ const reset = "\x1b[0m";
168
+ const bold = "\x1b[1m";
169
+
170
+ console.log(`\n${bold}Job Results${reset}`);
171
+ console.log("─".repeat(70));
172
+
173
+ results.forEach((r, i) => {
174
+ const color = statusColors[r.status] ?? "";
175
+ const task = r.task.length > 40 ? r.task.slice(0, 40) + "..." : r.task;
176
+ const verified = r.verified ? "✓" : "✗";
177
+ console.log(
178
+ `${i + 1}. ${color}${r.status.padEnd(12)}${reset} ${r.duration_seconds.toFixed(1).padStart(6)}s ${verified} ${task}`
179
+ );
180
+ });
181
+
182
+ const succeeded = results.filter((r) => r.status === "success").length;
183
+ console.log(
184
+ `\n${bold}Summary:${reset} ${succeeded}/${results.length} succeeded`
185
+ );
186
+ }
187
+
188
+ const program = new Command();
189
+
190
+ program
191
+ .name("overnight")
192
+ .description("Batch job runner for Claude Code")
193
+ .version("0.1.0")
194
+ .action(() => {
195
+ console.log(AGENT_HELP);
196
+ });
197
+
198
+ program
199
+ .command("run")
200
+ .description("Run jobs from a YAML tasks file")
201
+ .argument("<tasks-file>", "Path to tasks.yaml file")
202
+ .option("-o, --output <file>", "Output file for results JSON")
203
+ .option("-q, --quiet", "Minimal output")
204
+ .option("-s, --state-file <file>", "Custom state file path")
205
+ .option("--notify", "Send push notification via ntfy.sh")
206
+ .option("--notify-topic <topic>", "ntfy.sh topic", DEFAULT_NTFY_TOPIC)
207
+ .option("-r, --report <file>", "Generate markdown report")
208
+ .action(async (tasksFile, opts) => {
209
+ if (!existsSync(tasksFile)) {
210
+ console.error(`Error: File not found: ${tasksFile}`);
211
+ process.exit(1);
212
+ }
213
+
214
+ const configs = parseTasksFile(tasksFile);
215
+ if (configs.length === 0) {
216
+ console.error("No tasks found in file");
217
+ process.exit(1);
218
+ }
219
+
220
+ console.log(`\x1b[1movernight: Running ${configs.length} jobs...\x1b[0m\n`);
221
+
222
+ const log = opts.quiet ? undefined : (msg: string) => console.log(msg);
223
+ const startTime = Date.now();
224
+
225
+ const results = await runJobsWithState(configs, {
226
+ stateFile: opts.stateFile,
227
+ log,
228
+ });
229
+
230
+ const totalDuration = (Date.now() - startTime) / 1000;
231
+
232
+ if (opts.notify) {
233
+ const success = await sendNtfyNotification(
234
+ results,
235
+ totalDuration,
236
+ opts.notifyTopic
237
+ );
238
+ if (success) {
239
+ console.log(`\x1b[2mNotification sent to ntfy.sh/${opts.notifyTopic}\x1b[0m`);
240
+ } else {
241
+ console.log("\x1b[33mWarning: Failed to send notification\x1b[0m");
242
+ }
243
+ }
244
+
245
+ if (opts.report) {
246
+ generateReport(results, totalDuration, opts.report);
247
+ console.log(`\x1b[2mReport saved to ${opts.report}\x1b[0m`);
248
+ }
249
+
250
+ if (!opts.quiet) {
251
+ printSummary(results);
252
+ }
253
+
254
+ if (opts.output) {
255
+ writeFileSync(opts.output, resultsToJson(results));
256
+ console.log(`\n\x1b[2mResults saved to ${opts.output}\x1b[0m`);
257
+ }
258
+
259
+ if (results.some((r) => r.status !== "success")) {
260
+ process.exit(1);
261
+ }
262
+ });
263
+
264
+ program
265
+ .command("resume")
266
+ .description("Resume a previous run from saved state")
267
+ .argument("<tasks-file>", "Path to tasks.yaml file")
268
+ .option("-o, --output <file>", "Output file for results JSON")
269
+ .option("-q, --quiet", "Minimal output")
270
+ .option("-s, --state-file <file>", "Custom state file path")
271
+ .option("--notify", "Send push notification via ntfy.sh")
272
+ .option("--notify-topic <topic>", "ntfy.sh topic", DEFAULT_NTFY_TOPIC)
273
+ .option("-r, --report <file>", "Generate markdown report")
274
+ .action(async (tasksFile, opts) => {
275
+ const stateFile = opts.stateFile ?? DEFAULT_STATE_FILE;
276
+ const state = loadState(stateFile);
277
+
278
+ if (!state) {
279
+ console.error(`No state file found at ${stateFile}`);
280
+ console.error("Run 'overnight run' first to start jobs.");
281
+ process.exit(1);
282
+ }
283
+
284
+ if (!existsSync(tasksFile)) {
285
+ console.error(`Error: File not found: ${tasksFile}`);
286
+ process.exit(1);
287
+ }
288
+
289
+ const configs = parseTasksFile(tasksFile);
290
+ if (configs.length === 0) {
291
+ console.error("No tasks found in file");
292
+ process.exit(1);
293
+ }
294
+
295
+ if (configs.length !== state.total_jobs) {
296
+ console.error(
297
+ `Task file has ${configs.length} jobs but state has ${state.total_jobs}`
298
+ );
299
+ process.exit(1);
300
+ }
301
+
302
+ const startIndex = state.completed_indices.length;
303
+ console.log(
304
+ `\x1b[1movernight: Resuming from job ${startIndex + 1}/${configs.length}...\x1b[0m`
305
+ );
306
+ console.log(`\x1b[2mLast checkpoint: ${state.timestamp}\x1b[0m\n`);
307
+
308
+ const log = opts.quiet ? undefined : (msg: string) => console.log(msg);
309
+ const startTime = Date.now();
310
+
311
+ const results = await runJobsWithState(configs, {
312
+ stateFile,
313
+ log,
314
+ startIndex,
315
+ priorResults: state.results,
316
+ });
317
+
318
+ const totalDuration = (Date.now() - startTime) / 1000;
319
+
320
+ if (opts.notify) {
321
+ const success = await sendNtfyNotification(
322
+ results,
323
+ totalDuration,
324
+ opts.notifyTopic
325
+ );
326
+ if (success) {
327
+ console.log(`\x1b[2mNotification sent to ntfy.sh/${opts.notifyTopic}\x1b[0m`);
328
+ } else {
329
+ console.log("\x1b[33mWarning: Failed to send notification\x1b[0m");
330
+ }
331
+ }
332
+
333
+ if (opts.report) {
334
+ generateReport(results, totalDuration, opts.report);
335
+ console.log(`\x1b[2mReport saved to ${opts.report}\x1b[0m`);
336
+ }
337
+
338
+ if (!opts.quiet) {
339
+ printSummary(results);
340
+ }
341
+
342
+ if (opts.output) {
343
+ writeFileSync(opts.output, resultsToJson(results));
344
+ console.log(`\n\x1b[2mResults saved to ${opts.output}\x1b[0m`);
345
+ }
346
+
347
+ if (results.some((r) => r.status !== "success")) {
348
+ process.exit(1);
349
+ }
350
+ });
351
+
352
+ program
353
+ .command("single")
354
+ .description("Run a single job directly")
355
+ .argument("<prompt>", "The task prompt")
356
+ .option("-t, --timeout <seconds>", "Timeout in seconds", "300")
357
+ .option("--verify", "Run verification pass", true)
358
+ .option("--no-verify", "Skip verification pass")
359
+ .option("-T, --tools <tool...>", "Allowed tools")
360
+ .action(async (prompt, opts) => {
361
+ const config: JobConfig = {
362
+ prompt,
363
+ timeout_seconds: parseInt(opts.timeout, 10),
364
+ verify: opts.verify,
365
+ allowed_tools: opts.tools,
366
+ };
367
+
368
+ const log = (msg: string) => console.log(msg);
369
+ const result = await runJob(config, log);
370
+
371
+ if (result.status === "success") {
372
+ console.log("\n\x1b[32mSuccess\x1b[0m");
373
+ if (result.result) {
374
+ console.log(result.result);
375
+ }
376
+ } else {
377
+ console.log(`\n\x1b[31m${result.status}\x1b[0m`);
378
+ if (result.error) {
379
+ console.log(`\x1b[31m${result.error}\x1b[0m`);
380
+ }
381
+ process.exit(1);
382
+ }
383
+ });
384
+
385
+ program
386
+ .command("init")
387
+ .description("Create an example tasks.yaml file")
388
+ .action(() => {
389
+ const example = `# overnight task file
390
+ # Run with: overnight run tasks.yaml
391
+
392
+ defaults:
393
+ timeout_seconds: 300 # 5 minutes per task
394
+ verify: true # Run verification after each task
395
+ # Secure defaults - no Bash, just file operations
396
+ allowed_tools:
397
+ - Read
398
+ - Edit
399
+ - Write
400
+ - Glob
401
+ - Grep
402
+
403
+ tasks:
404
+ # Simple string format
405
+ - "Find and fix any TODO comments in the codebase"
406
+
407
+ # Dict format with overrides
408
+ - prompt: "Add input validation to all form handlers"
409
+ timeout_seconds: 600 # Allow more time
410
+
411
+ - prompt: "Review code for security issues"
412
+ verify: false # Don't need to verify a review
413
+
414
+ # Can add Bash for specific tasks that need it
415
+ - prompt: "Run the test suite and fix any failures"
416
+ allowed_tools:
417
+ - Read
418
+ - Edit
419
+ - Bash
420
+ - Glob
421
+ - Grep
422
+ `;
423
+
424
+ if (existsSync("tasks.yaml")) {
425
+ console.log("\x1b[33mtasks.yaml already exists\x1b[0m");
426
+ process.exit(1);
427
+ }
428
+
429
+ writeFileSync("tasks.yaml", example);
430
+ console.log("\x1b[32mCreated tasks.yaml\x1b[0m");
431
+ console.log("Edit the file, then run: \x1b[1movernight run tasks.yaml\x1b[0m");
432
+ });
433
+
434
+ program.parse();
package/src/notify.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { type JobResult, DEFAULT_NTFY_TOPIC } from "./types.js";
2
+
3
+ export async function sendNtfyNotification(
4
+ results: JobResult[],
5
+ totalDuration: number,
6
+ topic: string = DEFAULT_NTFY_TOPIC
7
+ ): Promise<boolean> {
8
+ const succeeded = results.filter((r) => r.status === "success").length;
9
+ const failed = results.length - succeeded;
10
+
11
+ // Format duration
12
+ let durationStr: string;
13
+ if (totalDuration >= 3600) {
14
+ const hours = Math.floor(totalDuration / 3600);
15
+ const mins = Math.floor((totalDuration % 3600) / 60);
16
+ durationStr = `${hours}h ${mins}m`;
17
+ } else if (totalDuration >= 60) {
18
+ const mins = Math.floor(totalDuration / 60);
19
+ const secs = Math.floor(totalDuration % 60);
20
+ durationStr = `${mins}m ${secs}s`;
21
+ } else {
22
+ durationStr = `${totalDuration.toFixed(0)}s`;
23
+ }
24
+
25
+ const title =
26
+ failed === 0
27
+ ? `overnight: ${succeeded}/${results.length} succeeded`
28
+ : `overnight: ${failed} failed`;
29
+
30
+ const message = `Completed in ${durationStr}\n${succeeded} succeeded, ${failed} failed`;
31
+
32
+ const priority = failed === 0 ? "default" : "high";
33
+ const tags = failed === 0 ? "white_check_mark" : "warning";
34
+
35
+ try {
36
+ const response = await fetch(`https://ntfy.sh/${topic}`, {
37
+ method: "POST",
38
+ headers: {
39
+ Title: title,
40
+ Priority: priority,
41
+ Tags: tags,
42
+ },
43
+ body: message,
44
+ });
45
+
46
+ return response.ok;
47
+ } catch {
48
+ return false;
49
+ }
50
+ }
package/src/report.ts ADDED
@@ -0,0 +1,115 @@
1
+ import { writeFileSync } from "fs";
2
+ import { type JobResult } from "./types.js";
3
+
4
+ export function generateReport(
5
+ results: JobResult[],
6
+ totalDuration: number,
7
+ outputPath?: string
8
+ ): string {
9
+ const lines: string[] = [];
10
+
11
+ // Header
12
+ lines.push("# Overnight Run Report");
13
+ lines.push("");
14
+ lines.push(`**Generated:** ${new Date().toISOString().replace("T", " ").split(".")[0]}`);
15
+ lines.push("");
16
+
17
+ // Summary
18
+ const succeeded = results.filter((r) => r.status === "success").length;
19
+ const failed = results.length - succeeded;
20
+
21
+ lines.push("## Summary");
22
+ lines.push("");
23
+ lines.push(`- **Jobs:** ${succeeded}/${results.length} succeeded`);
24
+ if (failed > 0) {
25
+ lines.push(`- **Failed:** ${failed}`);
26
+ }
27
+
28
+ // Duration formatting
29
+ let durationStr: string;
30
+ if (totalDuration >= 3600) {
31
+ const hours = Math.floor(totalDuration / 3600);
32
+ const mins = Math.floor((totalDuration % 3600) / 60);
33
+ durationStr = `${hours}h ${mins}m`;
34
+ } else if (totalDuration >= 60) {
35
+ const mins = Math.floor(totalDuration / 60);
36
+ const secs = Math.floor(totalDuration % 60);
37
+ durationStr = `${mins}m ${secs}s`;
38
+ } else {
39
+ durationStr = `${totalDuration.toFixed(1)}s`;
40
+ }
41
+
42
+ lines.push(`- **Total duration:** ${durationStr}`);
43
+ lines.push("");
44
+
45
+ // Job details table
46
+ lines.push("## Job Results");
47
+ lines.push("");
48
+ lines.push("| # | Status | Duration | Task |");
49
+ lines.push("|---|--------|----------|------|");
50
+
51
+ const statusEmoji: Record<string, string> = {
52
+ success: "✅",
53
+ failed: "❌",
54
+ timeout: "⏱️",
55
+ stalled: "🔄",
56
+ verification_failed: "⚠️",
57
+ };
58
+
59
+ results.forEach((r, i) => {
60
+ let taskPreview = r.task.slice(0, 50).replace(/\n/g, " ").trim();
61
+ if (r.task.length > 50) taskPreview += "...";
62
+ const emoji = statusEmoji[r.status] ?? "❓";
63
+ lines.push(
64
+ `| ${i + 1} | ${emoji} ${r.status} | ${r.duration_seconds.toFixed(1)}s | ${taskPreview} |`
65
+ );
66
+ });
67
+
68
+ lines.push("");
69
+
70
+ // Failed jobs details
71
+ const failures = results.filter((r) => r.status !== "success");
72
+ if (failures.length > 0) {
73
+ lines.push("## Failed Jobs");
74
+ lines.push("");
75
+
76
+ failures.forEach((r, i) => {
77
+ const taskPreview = r.task.slice(0, 80).replace(/\n/g, " ").trim();
78
+ lines.push(`### ${i + 1}. ${taskPreview}`);
79
+ lines.push("");
80
+ lines.push(`- **Status:** ${r.status}`);
81
+ if (r.error) {
82
+ lines.push(`- **Error:** ${r.error.slice(0, 200)}`);
83
+ }
84
+ if (r.retries > 0) {
85
+ lines.push(`- **Retries:** ${r.retries}`);
86
+ }
87
+ lines.push("");
88
+ });
89
+ }
90
+
91
+ // Next steps
92
+ lines.push("## Next Steps");
93
+ lines.push("");
94
+ if (failed === 0) {
95
+ lines.push("All jobs completed successfully! No action needed.");
96
+ } else {
97
+ lines.push("The following jobs need attention:");
98
+ lines.push("");
99
+ results.forEach((r, i) => {
100
+ if (r.status !== "success") {
101
+ const taskPreview = r.task.slice(0, 60).replace(/\n/g, " ").trim();
102
+ lines.push(`- [ ] Job ${i + 1}: ${taskPreview} (${r.status})`);
103
+ }
104
+ });
105
+ }
106
+ lines.push("");
107
+
108
+ const content = lines.join("\n");
109
+
110
+ if (outputPath) {
111
+ writeFileSync(outputPath, content);
112
+ }
113
+
114
+ return content;
115
+ }