@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/src/runner.ts ADDED
@@ -0,0 +1,283 @@
1
+ import { query, type Options as ClaudeCodeOptions } from "@anthropic-ai/claude-agent-sdk";
2
+ import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
3
+ import {
4
+ type JobConfig,
5
+ type JobResult,
6
+ type RunState,
7
+ DEFAULT_TOOLS,
8
+ DEFAULT_TIMEOUT,
9
+ DEFAULT_RETRY_COUNT,
10
+ DEFAULT_RETRY_DELAY,
11
+ DEFAULT_VERIFY_PROMPT,
12
+ DEFAULT_STATE_FILE,
13
+ } from "./types.js";
14
+
15
+ type LogCallback = (msg: string) => void;
16
+
17
+ function isRetryableError(error: Error): boolean {
18
+ const errorStr = error.message.toLowerCase();
19
+ const retryablePatterns = [
20
+ "api",
21
+ "timeout",
22
+ "connection",
23
+ "network",
24
+ "rate limit",
25
+ "503",
26
+ "502",
27
+ "500",
28
+ "unavailable",
29
+ "overloaded",
30
+ ];
31
+ return retryablePatterns.some((pattern) => errorStr.includes(pattern));
32
+ }
33
+
34
+ async function sleep(ms: number): Promise<void> {
35
+ return new Promise((resolve) => setTimeout(resolve, ms));
36
+ }
37
+
38
+ async function runWithTimeout<T>(
39
+ promise: Promise<T>,
40
+ timeoutMs: number
41
+ ): Promise<T> {
42
+ let timeoutId: Timer;
43
+ const timeoutPromise = new Promise<never>((_, reject) => {
44
+ timeoutId = setTimeout(() => reject(new Error("TIMEOUT")), timeoutMs);
45
+ });
46
+
47
+ try {
48
+ const result = await Promise.race([promise, timeoutPromise]);
49
+ clearTimeout(timeoutId!);
50
+ return result;
51
+ } catch (e) {
52
+ clearTimeout(timeoutId!);
53
+ throw e;
54
+ }
55
+ }
56
+
57
+ async function collectResult(
58
+ prompt: string,
59
+ options: ClaudeCodeOptions
60
+ ): Promise<{ sessionId?: string; result?: string }> {
61
+ let sessionId: string | undefined;
62
+ let result: string | undefined;
63
+
64
+ const conversation = query({ prompt, options });
65
+
66
+ for await (const message of conversation) {
67
+ if (message.type === "result") {
68
+ result = message.result;
69
+ sessionId = message.session_id;
70
+ }
71
+ }
72
+
73
+ return { sessionId, result };
74
+ }
75
+
76
+ export async function runJob(
77
+ config: JobConfig,
78
+ log?: LogCallback
79
+ ): Promise<JobResult> {
80
+ const startTime = Date.now();
81
+ const tools = config.allowed_tools ?? DEFAULT_TOOLS;
82
+ const timeout = (config.timeout_seconds ?? DEFAULT_TIMEOUT) * 1000;
83
+ const retryCount = config.retry_count ?? DEFAULT_RETRY_COUNT;
84
+ const retryDelay = config.retry_delay ?? DEFAULT_RETRY_DELAY;
85
+ const verifyPrompt = config.verify_prompt ?? DEFAULT_VERIFY_PROMPT;
86
+ let retriesUsed = 0;
87
+
88
+ const logMsg = (msg: string) => log?.(msg);
89
+ logMsg(`Starting: ${config.prompt.slice(0, 60)}...`);
90
+
91
+ for (let attempt = 0; attempt <= retryCount; attempt++) {
92
+ try {
93
+ const options: ClaudeCodeOptions = {
94
+ allowedTools: tools,
95
+ permissionMode: "acceptEdits",
96
+ ...(config.working_dir && { cwd: config.working_dir }),
97
+ };
98
+
99
+ let sessionId: string | undefined;
100
+ let result: string | undefined;
101
+
102
+ try {
103
+ const collected = await runWithTimeout(
104
+ collectResult(config.prompt, options),
105
+ timeout
106
+ );
107
+ sessionId = collected.sessionId;
108
+ result = collected.result;
109
+ } catch (e) {
110
+ if ((e as Error).message === "TIMEOUT") {
111
+ if (attempt < retryCount) {
112
+ retriesUsed = attempt + 1;
113
+ const delay = retryDelay * Math.pow(2, attempt);
114
+ logMsg(
115
+ `Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s, retrying in ${delay}s (attempt ${attempt + 1}/${retryCount})...`
116
+ );
117
+ await sleep(delay * 1000);
118
+ continue;
119
+ }
120
+ logMsg(
121
+ `Timeout after ${config.timeout_seconds ?? DEFAULT_TIMEOUT}s (exhausted retries)`
122
+ );
123
+ return {
124
+ task: config.prompt,
125
+ status: "timeout",
126
+ error: `Timed out after ${config.timeout_seconds ?? DEFAULT_TIMEOUT} seconds`,
127
+ duration_seconds: (Date.now() - startTime) / 1000,
128
+ verified: false,
129
+ retries: retriesUsed,
130
+ };
131
+ }
132
+ throw e;
133
+ }
134
+
135
+ // Verification pass if enabled
136
+ if (config.verify !== false && sessionId) {
137
+ logMsg("Running verification...");
138
+
139
+ const verifyOptions: ClaudeCodeOptions = {
140
+ resume: sessionId,
141
+ permissionMode: "acceptEdits",
142
+ };
143
+
144
+ try {
145
+ const verifyResult = await runWithTimeout(
146
+ collectResult(verifyPrompt, verifyOptions),
147
+ timeout / 2
148
+ );
149
+
150
+ const issueWords = ["issue", "error", "fail", "incorrect", "missing"];
151
+ if (
152
+ verifyResult.result &&
153
+ issueWords.some((word) =>
154
+ verifyResult.result!.toLowerCase().includes(word)
155
+ )
156
+ ) {
157
+ logMsg("Verification found potential issues");
158
+ return {
159
+ task: config.prompt,
160
+ status: "verification_failed",
161
+ result,
162
+ error: `Verification issues: ${verifyResult.result}`,
163
+ duration_seconds: (Date.now() - startTime) / 1000,
164
+ verified: false,
165
+ retries: retriesUsed,
166
+ };
167
+ }
168
+ } catch (e) {
169
+ if ((e as Error).message === "TIMEOUT") {
170
+ logMsg("Verification timed out - continuing anyway");
171
+ } else {
172
+ throw e;
173
+ }
174
+ }
175
+ }
176
+
177
+ const duration = (Date.now() - startTime) / 1000;
178
+ logMsg(`Completed in ${duration.toFixed(1)}s`);
179
+
180
+ return {
181
+ task: config.prompt,
182
+ status: "success",
183
+ result,
184
+ duration_seconds: duration,
185
+ verified: config.verify !== false,
186
+ retries: retriesUsed,
187
+ };
188
+ } catch (e) {
189
+ const error = e as Error;
190
+ if (isRetryableError(error) && attempt < retryCount) {
191
+ retriesUsed = attempt + 1;
192
+ const delay = retryDelay * Math.pow(2, attempt);
193
+ logMsg(
194
+ `Retryable error: ${error.message}, retrying in ${delay}s (attempt ${attempt + 1}/${retryCount})...`
195
+ );
196
+ await sleep(delay * 1000);
197
+ continue;
198
+ }
199
+
200
+ const duration = (Date.now() - startTime) / 1000;
201
+ logMsg(`Failed: ${error.message}`);
202
+ return {
203
+ task: config.prompt,
204
+ status: "failed",
205
+ error: error.message,
206
+ duration_seconds: duration,
207
+ verified: false,
208
+ retries: retriesUsed,
209
+ };
210
+ }
211
+ }
212
+
213
+ // Should not reach here
214
+ return {
215
+ task: config.prompt,
216
+ status: "failed",
217
+ error: "Exhausted all retries",
218
+ duration_seconds: (Date.now() - startTime) / 1000,
219
+ verified: false,
220
+ retries: retriesUsed,
221
+ };
222
+ }
223
+
224
+ export function saveState(state: RunState, stateFile: string): void {
225
+ writeFileSync(stateFile, JSON.stringify(state, null, 2));
226
+ }
227
+
228
+ export function loadState(stateFile: string): RunState | null {
229
+ if (!existsSync(stateFile)) return null;
230
+ return JSON.parse(readFileSync(stateFile, "utf-8"));
231
+ }
232
+
233
+ export function clearState(stateFile: string): void {
234
+ if (existsSync(stateFile)) unlinkSync(stateFile);
235
+ }
236
+
237
+ export async function runJobsWithState(
238
+ configs: JobConfig[],
239
+ options: {
240
+ stateFile?: string;
241
+ log?: LogCallback;
242
+ startIndex?: number;
243
+ priorResults?: JobResult[];
244
+ } = {}
245
+ ): Promise<JobResult[]> {
246
+ const stateFile = options.stateFile ?? DEFAULT_STATE_FILE;
247
+ const results: JobResult[] = options.priorResults
248
+ ? [...options.priorResults]
249
+ : [];
250
+ const startIndex = options.startIndex ?? 0;
251
+
252
+ for (let i = 0; i < configs.length; i++) {
253
+ if (i < startIndex) continue;
254
+
255
+ options.log?.(`\n[${i + 1}/${configs.length}] Running job...`);
256
+
257
+ const result = await runJob(configs[i], options.log);
258
+ results.push(result);
259
+
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
+ };
267
+ saveState(state, stateFile);
268
+
269
+ // Brief pause between jobs
270
+ if (i < configs.length - 1) {
271
+ await sleep(1000);
272
+ }
273
+ }
274
+
275
+ // Clean up state file on completion
276
+ clearState(stateFile);
277
+
278
+ return results;
279
+ }
280
+
281
+ export function resultsToJson(results: JobResult[]): string {
282
+ return JSON.stringify(results, null, 2);
283
+ }
package/src/types.ts ADDED
@@ -0,0 +1,48 @@
1
+ export interface JobConfig {
2
+ prompt: string;
3
+ working_dir?: string;
4
+ timeout_seconds?: number;
5
+ stall_timeout_seconds?: number;
6
+ verify?: boolean;
7
+ verify_prompt?: string;
8
+ allowed_tools?: string[];
9
+ retry_count?: number;
10
+ retry_delay?: number;
11
+ }
12
+
13
+ export interface JobResult {
14
+ task: string;
15
+ status: "success" | "failed" | "timeout" | "stalled" | "verification_failed";
16
+ result?: string;
17
+ error?: string;
18
+ duration_seconds: number;
19
+ verified: boolean;
20
+ retries: number;
21
+ }
22
+
23
+ export interface RunState {
24
+ completed_indices: number[];
25
+ results: JobResult[];
26
+ timestamp: string;
27
+ total_jobs: number;
28
+ }
29
+
30
+ export interface TasksFile {
31
+ defaults?: {
32
+ timeout_seconds?: number;
33
+ stall_timeout_seconds?: number;
34
+ verify?: boolean;
35
+ verify_prompt?: string;
36
+ allowed_tools?: string[];
37
+ };
38
+ tasks: (string | JobConfig)[];
39
+ }
40
+
41
+ export const DEFAULT_TOOLS = ["Read", "Edit", "Write", "Glob", "Grep"];
42
+ export const DEFAULT_TIMEOUT = 300;
43
+ export const DEFAULT_STALL_TIMEOUT = 120;
44
+ export const DEFAULT_RETRY_COUNT = 3;
45
+ export const DEFAULT_RETRY_DELAY = 5;
46
+ export const DEFAULT_VERIFY_PROMPT = "Verify this is complete and correct. If there are issues, list them.";
47
+ export const DEFAULT_STATE_FILE = ".overnight-state.json";
48
+ export const DEFAULT_NTFY_TOPIC = "overnight";
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "outDir": "dist",
10
+ "rootDir": "src",
11
+ "declaration": true,
12
+ "types": ["node"]
13
+ },
14
+ "include": ["src/**/*"]
15
+ }