@yail259/overnight 0.1.0 → 0.2.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yail259/overnight",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Batch job runner for Claude Code - queue tasks, run overnight, wake up to results",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.ts CHANGED
@@ -6,17 +6,22 @@ import {
6
6
  type JobConfig,
7
7
  type JobResult,
8
8
  type TasksFile,
9
+ type SecurityConfig,
9
10
  DEFAULT_TIMEOUT,
10
11
  DEFAULT_STALL_TIMEOUT,
11
12
  DEFAULT_VERIFY_PROMPT,
12
13
  DEFAULT_STATE_FILE,
13
14
  DEFAULT_NTFY_TOPIC,
15
+ DEFAULT_MAX_TURNS,
16
+ DEFAULT_DENY_PATTERNS,
14
17
  } from "./types.js";
18
+ import { validateSecurityConfig } from "./security.js";
15
19
  import {
16
20
  runJob,
17
21
  runJobsWithState,
18
22
  loadState,
19
23
  resultsToJson,
24
+ taskKey,
20
25
  } from "./runner.js";
21
26
  import { sendNtfyNotification } from "./notify.js";
22
27
  import { generateReport } from "./report.js";
@@ -120,14 +125,38 @@ overnight resume tasks.yaml
120
125
  Run \`overnight <command> --help\` for command-specific options.
121
126
  `;
122
127
 
123
- function parseTasksFile(path: string): JobConfig[] {
128
+ interface ParsedConfig {
129
+ configs: JobConfig[];
130
+ security?: SecurityConfig;
131
+ }
132
+
133
+ function parseTasksFile(path: string, cliSecurity?: Partial<SecurityConfig>): ParsedConfig {
124
134
  const content = readFileSync(path, "utf-8");
125
- const data = parseYaml(content) as TasksFile | (string | JobConfig)[];
135
+ let data: TasksFile | (string | JobConfig)[];
136
+ try {
137
+ data = parseYaml(content) as TasksFile | (string | JobConfig)[];
138
+ } catch (e) {
139
+ const error = e as Error;
140
+ console.error(`\x1b[31mError parsing ${path}:\x1b[0m`);
141
+ console.error(` ${error.message.split('\n')[0]}`);
142
+ process.exit(1);
143
+ }
126
144
 
127
145
  const tasks = Array.isArray(data) ? data : data.tasks ?? [];
128
146
  const defaults = Array.isArray(data) ? {} : data.defaults ?? {};
129
147
 
130
- return tasks.map((task) => {
148
+ // Merge CLI security options with file security options (CLI takes precedence)
149
+ const fileSecurity = (!Array.isArray(data) && data.defaults?.security) || {};
150
+ const security: SecurityConfig | undefined = (cliSecurity || Object.keys(fileSecurity).length > 0)
151
+ ? {
152
+ ...fileSecurity,
153
+ ...cliSecurity,
154
+ // Use default deny patterns if none specified
155
+ deny_patterns: cliSecurity?.deny_patterns ?? fileSecurity.deny_patterns ?? DEFAULT_DENY_PATTERNS,
156
+ }
157
+ : undefined;
158
+
159
+ const configs = tasks.map((task) => {
131
160
  if (typeof task === "string") {
132
161
  return {
133
162
  prompt: task,
@@ -137,9 +166,12 @@ function parseTasksFile(path: string): JobConfig[] {
137
166
  verify: defaults.verify ?? true,
138
167
  verify_prompt: defaults.verify_prompt ?? DEFAULT_VERIFY_PROMPT,
139
168
  allowed_tools: defaults.allowed_tools,
169
+ security,
140
170
  };
141
171
  }
142
172
  return {
173
+ id: task.id ?? undefined,
174
+ depends_on: task.depends_on ?? undefined,
143
175
  prompt: task.prompt,
144
176
  working_dir: task.working_dir ?? undefined,
145
177
  timeout_seconds:
@@ -152,8 +184,11 @@ function parseTasksFile(path: string): JobConfig[] {
152
184
  verify_prompt:
153
185
  task.verify_prompt ?? defaults.verify_prompt ?? DEFAULT_VERIFY_PROMPT,
154
186
  allowed_tools: task.allowed_tools ?? defaults.allowed_tools,
187
+ security: task.security ?? security,
155
188
  };
156
189
  });
190
+
191
+ return { configs, security };
157
192
  }
158
193
 
159
194
  function printSummary(results: JobResult[]): void {
@@ -190,7 +225,7 @@ const program = new Command();
190
225
  program
191
226
  .name("overnight")
192
227
  .description("Batch job runner for Claude Code")
193
- .version("0.1.0")
228
+ .version("0.2.0")
194
229
  .action(() => {
195
230
  console.log(AGENT_HELP);
196
231
  });
@@ -205,26 +240,58 @@ program
205
240
  .option("--notify", "Send push notification via ntfy.sh")
206
241
  .option("--notify-topic <topic>", "ntfy.sh topic", DEFAULT_NTFY_TOPIC)
207
242
  .option("-r, --report <file>", "Generate markdown report")
243
+ .option("--sandbox <dir>", "Sandbox directory (restrict file access)")
244
+ .option("--max-turns <n>", "Max agent iterations per task", String(DEFAULT_MAX_TURNS))
245
+ .option("--audit-log <file>", "Audit log file path")
246
+ .option("--no-security", "Disable default security (deny patterns)")
208
247
  .action(async (tasksFile, opts) => {
209
248
  if (!existsSync(tasksFile)) {
210
249
  console.error(`Error: File not found: ${tasksFile}`);
211
250
  process.exit(1);
212
251
  }
213
252
 
214
- const configs = parseTasksFile(tasksFile);
253
+ // Build CLI security config
254
+ const cliSecurity: Partial<SecurityConfig> | undefined = opts.security === false
255
+ ? undefined
256
+ : {
257
+ ...(opts.sandbox && { sandbox_dir: opts.sandbox }),
258
+ ...(opts.maxTurns && { max_turns: parseInt(opts.maxTurns, 10) }),
259
+ ...(opts.auditLog && { audit_log: opts.auditLog }),
260
+ };
261
+
262
+ const { configs, security } = parseTasksFile(tasksFile, cliSecurity);
215
263
  if (configs.length === 0) {
216
264
  console.error("No tasks found in file");
217
265
  process.exit(1);
218
266
  }
219
267
 
220
- console.log(`\x1b[1movernight: Running ${configs.length} jobs...\x1b[0m\n`);
268
+ // Check if resuming from existing state
269
+ const existingState = loadState(opts.stateFile ?? DEFAULT_STATE_FILE);
270
+ if (existingState) {
271
+ const done = Object.keys(existingState.completed).length;
272
+ const pending = configs.filter(c => !(taskKey(c) in existingState.completed)).length;
273
+ console.log(`\x1b[1movernight: Resuming — ${done} done, ${pending} remaining\x1b[0m`);
274
+ console.log(`\x1b[2mLast checkpoint: ${existingState.timestamp}\x1b[0m`);
275
+ } else {
276
+ console.log(`\x1b[1movernight: Running ${configs.length} jobs...\x1b[0m`);
277
+ }
278
+
279
+ // Show security config if enabled
280
+ if (security && !opts.quiet) {
281
+ console.log("\x1b[2mSecurity:\x1b[0m");
282
+ validateSecurityConfig(security);
283
+ }
284
+ console.log("");
221
285
 
222
286
  const log = opts.quiet ? undefined : (msg: string) => console.log(msg);
223
287
  const startTime = Date.now();
224
288
 
289
+ const reloadConfigs = () => parseTasksFile(tasksFile, cliSecurity).configs;
290
+
225
291
  const results = await runJobsWithState(configs, {
226
292
  stateFile: opts.stateFile,
227
293
  log,
294
+ reloadConfigs,
228
295
  });
229
296
 
230
297
  const totalDuration = (Date.now() - startTime) / 1000;
@@ -271,6 +338,10 @@ program
271
338
  .option("--notify", "Send push notification via ntfy.sh")
272
339
  .option("--notify-topic <topic>", "ntfy.sh topic", DEFAULT_NTFY_TOPIC)
273
340
  .option("-r, --report <file>", "Generate markdown report")
341
+ .option("--sandbox <dir>", "Sandbox directory (restrict file access)")
342
+ .option("--max-turns <n>", "Max agent iterations per task", String(DEFAULT_MAX_TURNS))
343
+ .option("--audit-log <file>", "Audit log file path")
344
+ .option("--no-security", "Disable default security (deny patterns)")
274
345
  .action(async (tasksFile, opts) => {
275
346
  const stateFile = opts.stateFile ?? DEFAULT_STATE_FILE;
276
347
  const state = loadState(stateFile);
@@ -286,33 +357,43 @@ program
286
357
  process.exit(1);
287
358
  }
288
359
 
289
- const configs = parseTasksFile(tasksFile);
360
+ // Build CLI security config
361
+ const cliSecurity: Partial<SecurityConfig> | undefined = opts.security === false
362
+ ? undefined
363
+ : {
364
+ ...(opts.sandbox && { sandbox_dir: opts.sandbox }),
365
+ ...(opts.maxTurns && { max_turns: parseInt(opts.maxTurns, 10) }),
366
+ ...(opts.auditLog && { audit_log: opts.auditLog }),
367
+ };
368
+
369
+ const { configs, security } = parseTasksFile(tasksFile, cliSecurity);
290
370
  if (configs.length === 0) {
291
371
  console.error("No tasks found in file");
292
372
  process.exit(1);
293
373
  }
294
374
 
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;
375
+ const completedCount = Object.keys(state.completed).length;
376
+ const pendingCount = configs.filter(c => !(taskKey(c) in state.completed)).length;
303
377
  console.log(
304
- `\x1b[1movernight: Resuming from job ${startIndex + 1}/${configs.length}...\x1b[0m`
378
+ `\x1b[1movernight: Resuming ${completedCount} done, ${pendingCount} remaining\x1b[0m`
305
379
  );
306
- console.log(`\x1b[2mLast checkpoint: ${state.timestamp}\x1b[0m\n`);
380
+ console.log(`\x1b[2mLast checkpoint: ${state.timestamp}\x1b[0m`);
381
+
382
+ // Show security config if enabled
383
+ if (security && !opts.quiet) {
384
+ console.log("\x1b[2mSecurity:\x1b[0m");
385
+ validateSecurityConfig(security);
386
+ }
387
+ console.log("");
307
388
 
308
389
  const log = opts.quiet ? undefined : (msg: string) => console.log(msg);
309
390
  const startTime = Date.now();
391
+ const reloadConfigs = () => parseTasksFile(tasksFile, cliSecurity).configs;
310
392
 
311
393
  const results = await runJobsWithState(configs, {
312
394
  stateFile,
313
395
  log,
314
- startIndex,
315
- priorResults: state.results,
396
+ reloadConfigs,
316
397
  });
317
398
 
318
399
  const totalDuration = (Date.now() - startTime) / 1000;
@@ -357,12 +438,25 @@ program
357
438
  .option("--verify", "Run verification pass", true)
358
439
  .option("--no-verify", "Skip verification pass")
359
440
  .option("-T, --tools <tool...>", "Allowed tools")
441
+ .option("--sandbox <dir>", "Sandbox directory (restrict file access)")
442
+ .option("--max-turns <n>", "Max agent iterations", String(DEFAULT_MAX_TURNS))
443
+ .option("--no-security", "Disable default security (deny patterns)")
360
444
  .action(async (prompt, opts) => {
445
+ // Build security config
446
+ const security: SecurityConfig | undefined = opts.security === false
447
+ ? undefined
448
+ : {
449
+ ...(opts.sandbox && { sandbox_dir: opts.sandbox }),
450
+ ...(opts.maxTurns && { max_turns: parseInt(opts.maxTurns, 10) }),
451
+ deny_patterns: DEFAULT_DENY_PATTERNS,
452
+ };
453
+
361
454
  const config: JobConfig = {
362
455
  prompt,
363
456
  timeout_seconds: parseInt(opts.timeout, 10),
364
457
  verify: opts.verify,
365
458
  allowed_tools: opts.tools,
459
+ security,
366
460
  };
367
461
 
368
462
  const log = (msg: string) => console.log(msg);
@@ -392,6 +486,7 @@ program
392
486
  defaults:
393
487
  timeout_seconds: 300 # 5 minutes per task
394
488
  verify: true # Run verification after each task
489
+
395
490
  # Secure defaults - no Bash, just file operations
396
491
  allowed_tools:
397
492
  - Read
@@ -400,6 +495,15 @@ defaults:
400
495
  - Glob
401
496
  - Grep
402
497
 
498
+ # Security settings (optional - deny_patterns enabled by default)
499
+ security:
500
+ sandbox_dir: "." # Restrict to current directory
501
+ max_turns: 100 # Prevent runaway agents
502
+ # audit_log: "overnight-audit.log" # Uncomment to enable
503
+ # deny_patterns: # Default patterns block .env, .key, .pem, etc.
504
+ # - "**/.env*"
505
+ # - "**/*.key"
506
+
403
507
  tasks:
404
508
  # Simple string format
405
509
  - "Find and fix any TODO comments in the codebase"