@yail259/overnight 0.1.0 → 0.3.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/src/runner.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { query, type Options as ClaudeCodeOptions } from "@anthropic-ai/claude-agent-sdk";
2
2
  import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
3
+ import { execSync } from "child_process";
4
+ import { createHash } from "crypto";
3
5
  import {
4
6
  type JobConfig,
5
7
  type JobResult,
@@ -10,9 +12,100 @@ import {
10
12
  DEFAULT_RETRY_DELAY,
11
13
  DEFAULT_VERIFY_PROMPT,
12
14
  DEFAULT_STATE_FILE,
15
+ DEFAULT_MAX_TURNS,
13
16
  } from "./types.js";
17
+ import { createSecurityHooks } from "./security.js";
14
18
 
15
19
  type LogCallback = (msg: string) => void;
20
+ type ProgressCallback = (activity: string) => void;
21
+
22
+ // Progress display
23
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
24
+
25
+ class ProgressDisplay {
26
+ private interval: ReturnType<typeof setInterval> | null = null;
27
+ private frame = 0;
28
+ private startTime = Date.now();
29
+ private currentActivity = "Working";
30
+ private lastToolUse = "";
31
+
32
+ start(activity: string): void {
33
+ this.currentActivity = activity;
34
+ this.startTime = Date.now();
35
+ this.frame = 0;
36
+
37
+ if (this.interval) return;
38
+
39
+ this.interval = setInterval(() => {
40
+ const elapsed = Math.floor((Date.now() - this.startTime) / 1000);
41
+ const toolInfo = this.lastToolUse ? ` → ${this.lastToolUse}` : "";
42
+ process.stdout.write(
43
+ `\r\x1b[K${SPINNER_FRAMES[this.frame]} ${this.currentActivity} (${elapsed}s)${toolInfo}`
44
+ );
45
+ this.frame = (this.frame + 1) % SPINNER_FRAMES.length;
46
+ }, 100);
47
+ }
48
+
49
+ updateActivity(activity: string): void {
50
+ this.currentActivity = activity;
51
+ }
52
+
53
+ updateTool(toolName: string, detail?: string): void {
54
+ this.lastToolUse = detail ? `${toolName}: ${detail}` : toolName;
55
+ }
56
+
57
+ stop(finalMessage?: string): void {
58
+ if (this.interval) {
59
+ clearInterval(this.interval);
60
+ this.interval = null;
61
+ }
62
+ process.stdout.write("\r\x1b[K"); // Clear line
63
+ if (finalMessage) {
64
+ console.log(finalMessage);
65
+ }
66
+ }
67
+
68
+ getElapsed(): number {
69
+ return (Date.now() - this.startTime) / 1000;
70
+ }
71
+ }
72
+
73
+ // Cache the claude executable path
74
+ let claudeExecutablePath: string | undefined;
75
+
76
+ function findClaudeExecutable(): string | undefined {
77
+ if (claudeExecutablePath !== undefined) return claudeExecutablePath;
78
+
79
+ // Check environment variable first
80
+ if (process.env.CLAUDE_CODE_PATH) {
81
+ claudeExecutablePath = process.env.CLAUDE_CODE_PATH;
82
+ return claudeExecutablePath;
83
+ }
84
+
85
+ // Try to find claude using which/where
86
+ try {
87
+ const cmd = process.platform === "win32" ? "where claude" : "which claude";
88
+ claudeExecutablePath = execSync(cmd, { encoding: "utf-8" }).trim().split("\n")[0];
89
+ return claudeExecutablePath;
90
+ } catch {
91
+ // Fall back to common locations
92
+ const commonPaths = [
93
+ "/usr/local/bin/claude",
94
+ "/opt/homebrew/bin/claude",
95
+ `${process.env.HOME}/.local/bin/claude`,
96
+ `${process.env.HOME}/.nvm/versions/node/v22.12.0/bin/claude`,
97
+ ];
98
+
99
+ for (const p of commonPaths) {
100
+ if (existsSync(p)) {
101
+ claudeExecutablePath = p;
102
+ return claudeExecutablePath;
103
+ }
104
+ }
105
+ }
106
+
107
+ return undefined;
108
+ }
16
109
 
17
110
  function isRetryableError(error: Error): boolean {
18
111
  const errorStr = error.message.toLowerCase();
@@ -39,7 +132,7 @@ async function runWithTimeout<T>(
39
132
  promise: Promise<T>,
40
133
  timeoutMs: number
41
134
  ): Promise<T> {
42
- let timeoutId: Timer;
135
+ let timeoutId: ReturnType<typeof setTimeout>;
43
136
  const timeoutPromise = new Promise<never>((_, reject) => {
44
137
  timeoutId = setTimeout(() => reject(new Error("TIMEOUT")), timeoutMs);
45
138
  });
@@ -54,28 +147,93 @@ async function runWithTimeout<T>(
54
147
  }
55
148
  }
56
149
 
57
- async function collectResult(
150
+ // Extract useful info from tool input for display
151
+ function getToolDetail(toolName: string, toolInput: Record<string, unknown>): string {
152
+ switch (toolName) {
153
+ case "Read":
154
+ case "Write":
155
+ case "Edit":
156
+ const filePath = toolInput.file_path as string;
157
+ if (filePath) {
158
+ // Show just filename, not full path
159
+ return filePath.split("/").pop() || filePath;
160
+ }
161
+ break;
162
+ case "Glob":
163
+ return (toolInput.pattern as string) || "";
164
+ case "Grep":
165
+ return (toolInput.pattern as string)?.slice(0, 20) || "";
166
+ case "Bash":
167
+ const cmd = (toolInput.command as string) || "";
168
+ return cmd.slice(0, 30) + (cmd.length > 30 ? "..." : "");
169
+ }
170
+ return "";
171
+ }
172
+
173
+ async function collectResultWithProgress(
58
174
  prompt: string,
59
- options: ClaudeCodeOptions
60
- ): Promise<{ sessionId?: string; result?: string }> {
175
+ options: ClaudeCodeOptions,
176
+ progress: ProgressDisplay,
177
+ onSessionId?: (sessionId: string) => void
178
+ ): Promise<{ sessionId?: string; result?: string; error?: string }> {
61
179
  let sessionId: string | undefined;
62
180
  let result: string | undefined;
181
+ let lastError: string | undefined;
182
+
183
+ try {
184
+ const conversation = query({ prompt, options });
63
185
 
64
- const conversation = query({ prompt, options });
186
+ for await (const message of conversation) {
187
+ // Debug logging
188
+ if (process.env.OVERNIGHT_DEBUG) {
189
+ console.error(`\n[DEBUG] message.type=${message.type}, keys=${Object.keys(message).join(",")}`);
190
+ }
65
191
 
66
- for await (const message of conversation) {
67
- if (message.type === "result") {
68
- result = message.result;
69
- sessionId = message.session_id;
192
+ // Handle different message types
193
+ if (message.type === "result") {
194
+ sessionId = message.session_id;
195
+ if (message.subtype === "success") {
196
+ result = message.result;
197
+ }
198
+ } else if (message.type === "assistant" && "message" in message) {
199
+ // Assistant message with tool use - SDK nests content in message.message
200
+ const assistantMsg = message.message as { content?: Array<{ type: string; name?: string; input?: Record<string, unknown> }> };
201
+ if (assistantMsg.content) {
202
+ for (const block of assistantMsg.content) {
203
+ if (process.env.OVERNIGHT_DEBUG) {
204
+ console.error(`[DEBUG] content block: type=${block.type}, name=${block.name}`);
205
+ }
206
+ if (block.type === "tool_use" && block.name) {
207
+ const detail = block.input ? getToolDetail(block.name, block.input) : "";
208
+ progress.updateTool(block.name, detail);
209
+ }
210
+ }
211
+ }
212
+ } else if (message.type === "system" && "subtype" in message) {
213
+ // System messages
214
+ if (message.subtype === "init") {
215
+ sessionId = message.session_id;
216
+ if (sessionId && onSessionId) {
217
+ onSessionId(sessionId);
218
+ }
219
+ }
220
+ }
70
221
  }
222
+ } catch (e) {
223
+ lastError = (e as Error).message;
224
+ throw e;
71
225
  }
72
226
 
73
- return { sessionId, result };
227
+ return { sessionId, result, error: lastError };
74
228
  }
75
229
 
76
230
  export async function runJob(
77
231
  config: JobConfig,
78
- log?: LogCallback
232
+ log?: LogCallback,
233
+ options?: {
234
+ resumeSessionId?: string; // Resume from a previous session
235
+ onSessionId?: (id: string) => void; // Called when session ID is available
236
+ }
79
237
  ): Promise<JobResult> {
80
238
  const startTime = Date.now();
81
239
  const tools = config.allowed_tools ?? DEFAULT_TOOLS;
@@ -84,41 +242,96 @@ export async function runJob(
84
242
  const retryDelay = config.retry_delay ?? DEFAULT_RETRY_DELAY;
85
243
  const verifyPrompt = config.verify_prompt ?? DEFAULT_VERIFY_PROMPT;
86
244
  let retriesUsed = 0;
245
+ let resumeSessionId = options?.resumeSessionId;
87
246
 
88
247
  const logMsg = (msg: string) => log?.(msg);
89
- logMsg(`Starting: ${config.prompt.slice(0, 60)}...`);
248
+ const progress = new ProgressDisplay();
249
+
250
+ // Find claude executable once at start
251
+ const claudePath = findClaudeExecutable();
252
+ if (!claudePath) {
253
+ logMsg("\x1b[31m✗ Error: Could not find 'claude' CLI.\x1b[0m");
254
+ logMsg("\x1b[33m Install it with:\x1b[0m");
255
+ logMsg(" curl -fsSL https://claude.ai/install.sh | bash");
256
+ logMsg("\x1b[33m Or set CLAUDE_CODE_PATH environment variable.\x1b[0m");
257
+ return {
258
+ task: config.prompt,
259
+ status: "failed",
260
+ error: "Claude CLI not found. Install with: curl -fsSL https://claude.ai/install.sh | bash",
261
+ duration_seconds: 0,
262
+ verified: false,
263
+ retries: 0,
264
+ };
265
+ }
266
+
267
+ if (process.env.OVERNIGHT_DEBUG) {
268
+ logMsg(`\x1b[2mDebug: Claude path = ${claudePath}\x1b[0m`);
269
+ }
270
+
271
+ // Show task being started
272
+ const taskPreview = config.prompt.slice(0, 60) + (config.prompt.length > 60 ? "..." : "");
273
+ if (resumeSessionId) {
274
+ logMsg(`\x1b[36m▶\x1b[0m Resuming: ${taskPreview}`);
275
+ } else {
276
+ logMsg(`\x1b[36m▶\x1b[0m ${taskPreview}`);
277
+ }
278
+
279
+ let sessionId: string | undefined;
90
280
 
91
281
  for (let attempt = 0; attempt <= retryCount; attempt++) {
92
282
  try {
93
- const options: ClaudeCodeOptions = {
283
+ // Build security hooks if security config provided
284
+ const securityHooks = config.security ? createSecurityHooks(config.security) : undefined;
285
+
286
+ const sdkOptions: ClaudeCodeOptions = {
94
287
  allowedTools: tools,
95
288
  permissionMode: "acceptEdits",
289
+ ...(claudePath && { pathToClaudeCodeExecutable: claudePath }),
96
290
  ...(config.working_dir && { cwd: config.working_dir }),
291
+ ...(config.security?.max_turns && { maxTurns: config.security.max_turns }),
292
+ ...(securityHooks && { hooks: securityHooks }),
293
+ ...(resumeSessionId && { resume: resumeSessionId }),
97
294
  };
98
295
 
99
- let sessionId: string | undefined;
100
296
  let result: string | undefined;
101
297
 
298
+ // Prompt: if resuming, ask to continue; otherwise use original prompt
299
+ const prompt = resumeSessionId
300
+ ? "Continue where you left off. Complete the original task."
301
+ : config.prompt;
302
+
303
+ // Start progress display
304
+ progress.start(resumeSessionId ? "Resuming" : "Working");
305
+
102
306
  try {
103
307
  const collected = await runWithTimeout(
104
- collectResult(config.prompt, options),
308
+ collectResultWithProgress(prompt, sdkOptions, progress, (id) => {
309
+ sessionId = id;
310
+ options?.onSessionId?.(id);
311
+ }),
105
312
  timeout
106
313
  );
107
314
  sessionId = collected.sessionId;
108
315
  result = collected.result;
316
+ progress.stop();
109
317
  } catch (e) {
318
+ progress.stop();
110
319
  if ((e as Error).message === "TIMEOUT") {
111
320
  if (attempt < retryCount) {
112
321
  retriesUsed = attempt + 1;
322
+ // On timeout, if we have a session ID, use it for the retry
323
+ if (sessionId) {
324
+ resumeSessionId = sessionId;
325
+ }
113
326
  const delay = retryDelay * Math.pow(2, attempt);
114
327
  logMsg(
115
- `Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s, retrying in ${delay}s (attempt ${attempt + 1}/${retryCount})...`
328
+ `\x1b[33m⚠ Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s, retrying in ${delay}s (${attempt + 1}/${retryCount})\x1b[0m`
116
329
  );
117
330
  await sleep(delay * 1000);
118
331
  continue;
119
332
  }
120
333
  logMsg(
121
- `Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s (exhausted retries)`
334
+ `\x1b[31m✗ Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s (exhausted retries)\x1b[0m`
122
335
  );
123
336
  return {
124
337
  task: config.prompt,
@@ -132,42 +345,60 @@ export async function runJob(
132
345
  throw e;
133
346
  }
134
347
 
135
- // Verification pass if enabled
348
+ // Verification pass if enabled — verify and fix issues
136
349
  if (config.verify !== false && sessionId) {
137
- logMsg("Running verification...");
350
+ progress.start("Verifying");
138
351
 
139
352
  const verifyOptions: ClaudeCodeOptions = {
353
+ allowedTools: tools,
140
354
  resume: sessionId,
141
355
  permissionMode: "acceptEdits",
356
+ ...(claudePath && { pathToClaudeCodeExecutable: claudePath }),
357
+ ...(config.working_dir && { cwd: config.working_dir }),
358
+ ...(config.security?.max_turns && { maxTurns: config.security.max_turns }),
142
359
  };
143
360
 
361
+ const fixPrompt = verifyPrompt +
362
+ " If you find any issues, fix them now. Only report issues you cannot fix.";
363
+
144
364
  try {
145
365
  const verifyResult = await runWithTimeout(
146
- collectResult(verifyPrompt, verifyOptions),
366
+ collectResultWithProgress(fixPrompt, verifyOptions, progress, (id) => {
367
+ sessionId = id;
368
+ options?.onSessionId?.(id);
369
+ }),
147
370
  timeout / 2
148
371
  );
372
+ progress.stop();
373
+
374
+ // Update result with verification output
375
+ if (verifyResult.result) {
376
+ result = verifyResult.result;
377
+ }
149
378
 
150
- const issueWords = ["issue", "error", "fail", "incorrect", "missing"];
379
+ // Only mark as failed if there are issues that couldn't be fixed
380
+ const unfixableWords = ["cannot fix", "unable to", "blocked by", "requires manual"];
151
381
  if (
152
382
  verifyResult.result &&
153
- issueWords.some((word) =>
383
+ unfixableWords.some((word) =>
154
384
  verifyResult.result!.toLowerCase().includes(word)
155
385
  )
156
386
  ) {
157
- logMsg("Verification found potential issues");
387
+ logMsg(`\x1b[33m⚠ Verification found unfixable issues\x1b[0m`);
158
388
  return {
159
389
  task: config.prompt,
160
390
  status: "verification_failed",
161
391
  result,
162
- error: `Verification issues: ${verifyResult.result}`,
392
+ error: `Unfixable issues: ${verifyResult.result}`,
163
393
  duration_seconds: (Date.now() - startTime) / 1000,
164
394
  verified: false,
165
395
  retries: retriesUsed,
166
396
  };
167
397
  }
168
398
  } catch (e) {
399
+ progress.stop();
169
400
  if ((e as Error).message === "TIMEOUT") {
170
- logMsg("Verification timed out - continuing anyway");
401
+ logMsg("\x1b[33m⚠ Verification timed out - continuing anyway\x1b[0m");
171
402
  } else {
172
403
  throw e;
173
404
  }
@@ -175,7 +406,7 @@ export async function runJob(
175
406
  }
176
407
 
177
408
  const duration = (Date.now() - startTime) / 1000;
178
- logMsg(`Completed in ${duration.toFixed(1)}s`);
409
+ logMsg(`\x1b[32m✓ Completed in ${duration.toFixed(1)}s\x1b[0m`);
179
410
 
180
411
  return {
181
412
  task: config.prompt,
@@ -186,19 +417,24 @@ export async function runJob(
186
417
  retries: retriesUsed,
187
418
  };
188
419
  } catch (e) {
420
+ progress.stop();
189
421
  const error = e as Error;
190
422
  if (isRetryableError(error) && attempt < retryCount) {
191
423
  retriesUsed = attempt + 1;
424
+ // Preserve session for resumption on retry
425
+ if (sessionId) {
426
+ resumeSessionId = sessionId;
427
+ }
192
428
  const delay = retryDelay * Math.pow(2, attempt);
193
429
  logMsg(
194
- `Retryable error: ${error.message}, retrying in ${delay}s (attempt ${attempt + 1}/${retryCount})...`
430
+ `\x1b[33m⚠ ${error.message}, retrying in ${delay}s (${attempt + 1}/${retryCount})\x1b[0m`
195
431
  );
196
432
  await sleep(delay * 1000);
197
433
  continue;
198
434
  }
199
435
 
200
436
  const duration = (Date.now() - startTime) / 1000;
201
- logMsg(`Failed: ${error.message}`);
437
+ logMsg(`\x1b[31m✗ Failed: ${error.message}\x1b[0m`);
202
438
  return {
203
439
  task: config.prompt,
204
440
  status: "failed",
@@ -221,6 +457,63 @@ export async function runJob(
221
457
  };
222
458
  }
223
459
 
460
+ export function taskKey(config: JobConfig): string {
461
+ if (config.id) return config.id;
462
+ return createHash("sha256").update(config.prompt).digest("hex").slice(0, 12);
463
+ }
464
+
465
+ /** @deprecated Use taskKey(config) instead — kept for CLI backward compat */
466
+ export function taskHash(prompt: string): string {
467
+ return createHash("sha256").update(prompt).digest("hex").slice(0, 12);
468
+ }
469
+
470
+ function validateDag(configs: JobConfig[]): string | null {
471
+ const ids = new Set(configs.map(c => c.id).filter((id): id is string => Boolean(id)));
472
+ // Check all depends_on references exist
473
+ for (const c of configs) {
474
+ for (const dep of c.depends_on ?? []) {
475
+ if (!ids.has(dep)) {
476
+ return `Task "${c.id ?? c.prompt.slice(0, 40)}" depends on unknown id "${dep}"`;
477
+ }
478
+ }
479
+ }
480
+ // Check for cycles via DFS
481
+ const visited = new Set<string>();
482
+ const inStack = new Set<string>();
483
+ const idToConfig = new Map(configs.filter(c => c.id).map(c => [c.id!, c]));
484
+
485
+ function hasCycle(id: string): boolean {
486
+ if (inStack.has(id)) return true;
487
+ if (visited.has(id)) return false;
488
+ visited.add(id);
489
+ inStack.add(id);
490
+ const config = idToConfig.get(id);
491
+ for (const dep of config?.depends_on ?? []) {
492
+ if (hasCycle(dep)) return true;
493
+ }
494
+ inStack.delete(id);
495
+ return false;
496
+ }
497
+
498
+ for (const id of ids) {
499
+ if (hasCycle(id)) return `Dependency cycle detected involving "${id}"`;
500
+ }
501
+ return null;
502
+ }
503
+
504
+ function depsReady(
505
+ config: JobConfig,
506
+ completed: Record<string, JobResult>,
507
+ ): "ready" | "waiting" | "blocked" {
508
+ if (!config.depends_on || config.depends_on.length === 0) return "ready";
509
+ for (const dep of config.depends_on) {
510
+ const result = completed[dep];
511
+ if (!result) return "waiting";
512
+ if (result.status !== "success") return "blocked";
513
+ }
514
+ return "ready";
515
+ }
516
+
224
517
  export function saveState(state: RunState, stateFile: string): void {
225
518
  writeFileSync(stateFile, JSON.stringify(state, null, 2));
226
519
  }
@@ -239,39 +532,126 @@ export async function runJobsWithState(
239
532
  options: {
240
533
  stateFile?: string;
241
534
  log?: LogCallback;
242
- startIndex?: number;
243
- priorResults?: JobResult[];
535
+ reloadConfigs?: () => JobConfig[]; // Called between jobs to pick up new tasks
244
536
  } = {}
245
537
  ): Promise<JobResult[]> {
246
538
  const stateFile = options.stateFile ?? DEFAULT_STATE_FILE;
247
- const results: JobResult[] = options.priorResults
248
- ? [...options.priorResults]
249
- : [];
250
- const startIndex = options.startIndex ?? 0;
251
539
 
252
- for (let i = 0; i < configs.length; i++) {
253
- if (i < startIndex) continue;
540
+ // Validate DAG if any tasks have dependencies
541
+ const dagError = validateDag(configs);
542
+ if (dagError) {
543
+ options.log?.(`\x1b[31m✗ DAG error: ${dagError}\x1b[0m`);
544
+ return [];
545
+ }
546
+
547
+ // Load existing state or start fresh
548
+ const state: RunState = loadState(stateFile) ?? {
549
+ completed: {},
550
+ timestamp: new Date().toISOString(),
551
+ };
552
+
553
+ let currentConfigs = configs;
554
+
555
+ while (true) {
556
+ // Find tasks not yet completed
557
+ const notDone = currentConfigs.filter(c => !(taskKey(c) in state.completed));
558
+ if (notDone.length === 0) break;
559
+
560
+ // Among not-done tasks, find those whose dependencies are satisfied
561
+ const ready = notDone.filter(c => depsReady(c, state.completed) === "ready");
562
+
563
+ // Find tasks blocked by failed dependencies
564
+ const blocked = notDone.filter(c => depsReady(c, state.completed) === "blocked");
565
+
566
+ // Mark blocked tasks as failed without running them
567
+ for (const bc of blocked) {
568
+ const key = taskKey(bc);
569
+ if (key in state.completed) continue; // already recorded
570
+ const failedDeps = (bc.depends_on ?? []).filter(
571
+ dep => state.completed[dep] && state.completed[dep].status !== "success"
572
+ );
573
+ const label = bc.id ?? bc.prompt.slice(0, 40);
574
+ options.log?.(`\n\x1b[31m✗ Skipping "${label}" — dependency failed: ${failedDeps.join(", ")}\x1b[0m`);
575
+ state.completed[key] = {
576
+ task: bc.prompt,
577
+ status: "failed",
578
+ error: `Blocked by failed dependencies: ${failedDeps.join(", ")}`,
579
+ duration_seconds: 0,
580
+ verified: false,
581
+ retries: 0,
582
+ };
583
+ state.timestamp = new Date().toISOString();
584
+ saveState(state, stateFile);
585
+ }
254
586
 
255
- options.log?.(`\n[${i + 1}/${configs.length}] Running job...`);
587
+ // If nothing is ready and nothing is blocked, everything remaining is waiting
588
+ // on something that will never complete — break to avoid infinite loop
589
+ if (ready.length === 0) break;
256
590
 
257
- const result = await runJob(configs[i], options.log);
258
- results.push(result);
591
+ const config = ready[0];
592
+ const key = taskKey(config);
259
593
 
260
- // Save state after each job
261
- const state: RunState = {
262
- completed_indices: Array.from({ length: results.length }, (_, i) => i),
263
- results,
264
- timestamp: new Date().toISOString(),
265
- total_jobs: configs.length,
266
- };
594
+ const totalNotDone = notDone.length - blocked.length;
595
+ const totalDone = Object.keys(state.completed).length;
596
+ const label = config.id ? `${config.id}` : "";
597
+ options.log?.(`\n\x1b[1m[${totalDone + 1}/${totalDone + totalNotDone}]${label ? ` ${label}` : ""}\x1b[0m`);
598
+
599
+ // Check if this task was previously in-progress (crashed mid-task)
600
+ const resumeSessionId = (state.inProgress?.hash === key)
601
+ ? state.inProgress.sessionId
602
+ : undefined;
603
+
604
+ if (resumeSessionId) {
605
+ options.log?.(`\x1b[2mResuming session ${resumeSessionId.slice(0, 8)}...\x1b[0m`);
606
+ }
607
+
608
+ // Mark task as in-progress before starting
609
+ state.inProgress = { hash: key, prompt: config.prompt, startedAt: new Date().toISOString() };
267
610
  saveState(state, stateFile);
268
611
 
612
+ const result = await runJob(config, options.log, {
613
+ resumeSessionId,
614
+ onSessionId: (id) => {
615
+ // Checkpoint the session ID so we can resume on crash
616
+ state.inProgress = { hash: key, prompt: config.prompt, sessionId: id, startedAt: state.inProgress!.startedAt };
617
+ saveState(state, stateFile);
618
+ },
619
+ });
620
+
621
+ // Task done — save result and clear in-progress
622
+ state.completed[key] = result;
623
+ state.inProgress = undefined;
624
+ state.timestamp = new Date().toISOString();
625
+ saveState(state, stateFile);
626
+
627
+ // Re-read YAML to pick up new tasks added while running
628
+ if (options.reloadConfigs) {
629
+ try {
630
+ currentConfigs = options.reloadConfigs();
631
+ // Re-validate DAG with new configs
632
+ const newDagError = validateDag(currentConfigs);
633
+ if (newDagError) {
634
+ options.log?.(`\x1b[33m⚠ DAG error in updated YAML, ignoring reload: ${newDagError}\x1b[0m`);
635
+ currentConfigs = configs; // revert to original
636
+ }
637
+ } catch {
638
+ // If reload fails (e.g. YAML syntax error mid-edit), keep current list
639
+ }
640
+ }
641
+
269
642
  // Brief pause between jobs
270
- if (i < configs.length - 1) {
643
+ const nextNotDone = currentConfigs.filter(c => !(taskKey(c) in state.completed));
644
+ const nextReady = nextNotDone.filter(c => depsReady(c, state.completed) === "ready");
645
+ if (nextReady.length > 0) {
271
646
  await sleep(1000);
272
647
  }
273
648
  }
274
649
 
650
+ // Collect results in original order
651
+ const results = currentConfigs
652
+ .map(c => state.completed[taskKey(c)])
653
+ .filter((r): r is JobResult => r !== undefined);
654
+
275
655
  // Clean up state file on completion
276
656
  clearState(stateFile);
277
657