@lazyagent/lazy-agents 0.0.1
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/bin/lazy.js +200 -0
- package/package.json +40 -0
- package/src/cli.test.js +135 -0
- package/src/doctor.js +70 -0
- package/src/launch.js +132 -0
- package/src/launch.test.js +175 -0
- package/src/setup.js +280 -0
- package/src/setup.test.js +218 -0
- package/src/wrapper.integration.test.js +138 -0
- package/src/wrapper.js +266 -0
- package/src/wrapper.test.js +128 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Integration test for wrapper.js restart logic.
|
|
5
|
+
* Uses CLAUDE_BIN to inject a fake "claude" script that exits immediately
|
|
6
|
+
* with a non-zero code, verifying that the wrapper restarts it.
|
|
7
|
+
*
|
|
8
|
+
* Note: node-pty spawns a real PTY so the fake script runs inside a
|
|
9
|
+
* pseudo-terminal — behaviour is identical to the real Claude binary.
|
|
10
|
+
*
|
|
11
|
+
* CLAUDE_BIN is split on spaces: "node /path/to/fake.js" →
|
|
12
|
+
* bin='node', extraArgs=['/path/to/fake.js']
|
|
13
|
+
* pty.spawn('node', ['/path/to/fake.js', 'test-prompt'], ...)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const { execFile } = require('child_process');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const fs = require('fs');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
|
|
21
|
+
const WRAPPER = path.join(__dirname, 'wrapper.js');
|
|
22
|
+
|
|
23
|
+
// Write a tiny fake-claude script to a temp file
|
|
24
|
+
function makeFakeClaude(exitCode, delayMs = 100) {
|
|
25
|
+
const script = `
|
|
26
|
+
const delay = ${delayMs};
|
|
27
|
+
const code = ${exitCode};
|
|
28
|
+
setTimeout(() => process.exit(code), delay);
|
|
29
|
+
`;
|
|
30
|
+
const file = path.join(os.tmpdir(), `fake-claude-${Date.now()}.js`);
|
|
31
|
+
fs.writeFileSync(file, script);
|
|
32
|
+
return file;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Fake claude that prints a rate-limit message then exits after a short delay
|
|
36
|
+
// (simulates the wrapper detecting the message and sending Enter to the pty)
|
|
37
|
+
function makeRateLimitClaude() {
|
|
38
|
+
const script = `
|
|
39
|
+
process.stdout.write('usage limit reached\\n');
|
|
40
|
+
process.stdout.write('What do you want to do?\\n');
|
|
41
|
+
process.stdout.write('1. Wait for rate limit to reset\\n');
|
|
42
|
+
process.stdout.write('2. Exit\\n');
|
|
43
|
+
// Exit cleanly after 2s
|
|
44
|
+
setTimeout(() => process.exit(0), 2000);
|
|
45
|
+
`;
|
|
46
|
+
const file = path.join(os.tmpdir(), `fake-ratelimit-${Date.now()}.js`);
|
|
47
|
+
fs.writeFileSync(file, script);
|
|
48
|
+
return file;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Build CLAUDE_BIN using 'node' (on PATH) to avoid spaces in process.execPath
|
|
52
|
+
function claudeBin(scriptFile) {
|
|
53
|
+
return `node "${scriptFile}"`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
test('wrapper restarts once after non-zero exit', (done) => {
|
|
57
|
+
const fakeClaude = makeFakeClaude(1, 100);
|
|
58
|
+
let startCount = 0;
|
|
59
|
+
|
|
60
|
+
const wrapper = execFile(
|
|
61
|
+
process.execPath,
|
|
62
|
+
[WRAPPER, 'test-prompt'],
|
|
63
|
+
{
|
|
64
|
+
env: { ...process.env, CLAUDE_BIN: claudeBin(fakeClaude) },
|
|
65
|
+
timeout: 10000,
|
|
66
|
+
},
|
|
67
|
+
() => {
|
|
68
|
+
fs.unlinkSync(fakeClaude);
|
|
69
|
+
expect(startCount).toBeGreaterThanOrEqual(2);
|
|
70
|
+
done();
|
|
71
|
+
}
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
wrapper.stderr.on('data', (data) => {
|
|
75
|
+
const text = data.toString();
|
|
76
|
+
if (text.includes('Starting ')) {
|
|
77
|
+
startCount++;
|
|
78
|
+
if (startCount >= 2) wrapper.kill();
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}, 10000);
|
|
82
|
+
|
|
83
|
+
test('wrapper does NOT restart after clean exit (code 0)', (done) => {
|
|
84
|
+
const fakeClaude = makeFakeClaude(0, 100);
|
|
85
|
+
let startCount = 0;
|
|
86
|
+
|
|
87
|
+
const wrapper = execFile(
|
|
88
|
+
process.execPath,
|
|
89
|
+
[WRAPPER, 'test-prompt'],
|
|
90
|
+
{
|
|
91
|
+
env: { ...process.env, CLAUDE_BIN: claudeBin(fakeClaude) },
|
|
92
|
+
timeout: 5000,
|
|
93
|
+
},
|
|
94
|
+
() => {
|
|
95
|
+
fs.unlinkSync(fakeClaude);
|
|
96
|
+
expect(startCount).toBe(1);
|
|
97
|
+
done();
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
wrapper.stderr.on('data', (data) => {
|
|
102
|
+
if (data.toString().includes('Starting ')) startCount++;
|
|
103
|
+
});
|
|
104
|
+
}, 8000);
|
|
105
|
+
|
|
106
|
+
test('wrapper exits with error when no args given', (done) => {
|
|
107
|
+
execFile(process.execPath, [WRAPPER], (err) => {
|
|
108
|
+
expect(err).not.toBeNull();
|
|
109
|
+
expect(err.code).not.toBe(0);
|
|
110
|
+
done();
|
|
111
|
+
});
|
|
112
|
+
}, 5000);
|
|
113
|
+
|
|
114
|
+
test('wrapper auto-selects option 1 when rate-limit menu is detected', (done) => {
|
|
115
|
+
const fakeClaude = makeRateLimitClaude();
|
|
116
|
+
let detectedAutoSelect = false;
|
|
117
|
+
|
|
118
|
+
const wrapper = execFile(
|
|
119
|
+
process.execPath,
|
|
120
|
+
[WRAPPER, 'test-prompt'],
|
|
121
|
+
{
|
|
122
|
+
env: { ...process.env, CLAUDE_BIN: claudeBin(fakeClaude) },
|
|
123
|
+
timeout: 12000,
|
|
124
|
+
},
|
|
125
|
+
(err) => {
|
|
126
|
+
try { fs.unlinkSync(fakeClaude); } catch (_) {}
|
|
127
|
+
expect(detectedAutoSelect).toBe(true);
|
|
128
|
+
done();
|
|
129
|
+
}
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const check = (data) => {
|
|
133
|
+
const text = data.toString();
|
|
134
|
+
if (text.includes('Could not parse reset time') || text.includes('auto-resuming')) detectedAutoSelect = true;
|
|
135
|
+
};
|
|
136
|
+
wrapper.stderr.on('data', check);
|
|
137
|
+
wrapper.stdout.on('data', check);
|
|
138
|
+
}, 15000);
|
package/src/wrapper.js
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Rate-limit-aware Claude Code wrapper.
|
|
6
|
+
*
|
|
7
|
+
* Usage: node wrapper.js "<claude prompt>"
|
|
8
|
+
*
|
|
9
|
+
* Design:
|
|
10
|
+
* node-pty spawns the command inside a real pseudo-terminal via PowerShell so:
|
|
11
|
+
* • Claude Code gets a real PTY — its TUI renders normally.
|
|
12
|
+
* • We can read stdout to detect the rate-limit menu.
|
|
13
|
+
* • We can write to stdin to auto-select "wait for reset".
|
|
14
|
+
* • PowerShell handles quoted multi-word args correctly (cmd.exe mangles them).
|
|
15
|
+
*
|
|
16
|
+
* Rate-limit detection & auto-resume:
|
|
17
|
+
* When Claude prints rate-limit text we auto-select option 1 (wait) after
|
|
18
|
+
* 800 ms. We then parse the reset time (e.g. "8:00 PM", "30 minutes") and
|
|
19
|
+
* schedule an auto-resume 60s after the reset time. Fully hands-free.
|
|
20
|
+
*
|
|
21
|
+
* Restart logic:
|
|
22
|
+
* exit code 0 → clean exit, stop.
|
|
23
|
+
* any other code → restart after RESTART_DELAY_MS.
|
|
24
|
+
* The agent re-registers automatically with its saved agentToken so
|
|
25
|
+
* conversation context and paused tasks are fully preserved.
|
|
26
|
+
*
|
|
27
|
+
* Testing:
|
|
28
|
+
* Override the command via CLAUDE_BIN env var, e.g.:
|
|
29
|
+
* CLAUDE_BIN="node 'C:\\fake.js'" node wrapper.js "prompt"
|
|
30
|
+
* The value is used as-is as the command prefix in PowerShell.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require('fs');
|
|
34
|
+
const pty = require('node-pty');
|
|
35
|
+
|
|
36
|
+
const RESTART_DELAY_MS = 5 * 1000;
|
|
37
|
+
const RATE_LIMIT_RE = /you.{0,10}ve hit your limit|rate limit reached|usage limit reached/i;
|
|
38
|
+
const RESUME_BUFFER_MS = 60 * 1000; // wait 60s past the reset time before resuming
|
|
39
|
+
const POWERSHELL = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Parse a reset time from Claude's rate-limit output.
|
|
43
|
+
* Matches patterns like "2:30 PM", "14:30", "8:00 pm", "resets at 8 PM", "3pm (Asia/Jerusalem)"
|
|
44
|
+
* Returns a Date for today (or tomorrow if the time already passed).
|
|
45
|
+
*/
|
|
46
|
+
function parseResetTime(text) {
|
|
47
|
+
// Strip all ANSI/VT escape sequences (colors, cursor movement, erase, etc.)
|
|
48
|
+
const clean = text
|
|
49
|
+
.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // CSI sequences: ESC [ ... letter
|
|
50
|
+
.replace(/\x1b[()][AB012]/g, '') // character set designations
|
|
51
|
+
.replace(/\x1b[DABC]/g, '') // cursor up/down/forward/back (no bracket)
|
|
52
|
+
.replace(/\x1b[78]/g, '') // save/restore cursor
|
|
53
|
+
.replace(/\r/g, '\n'); // carriage returns → newlines so words don't overwrite
|
|
54
|
+
|
|
55
|
+
// Narrow search to text near "reset" — if not found, no time to parse
|
|
56
|
+
const resetMatch = clean.match(/reset[s]?[^.\n]{0,80}/i);
|
|
57
|
+
if (!resetMatch) return null;
|
|
58
|
+
const searchText = resetMatch[0];
|
|
59
|
+
|
|
60
|
+
// Try "H:MM AM/PM" or "HH:MM AM/PM"
|
|
61
|
+
let m = searchText.match(/(\d{1,2}):(\d{2})\s*(am|pm)/i);
|
|
62
|
+
if (m) {
|
|
63
|
+
let h = parseInt(m[1], 10);
|
|
64
|
+
const min = parseInt(m[2], 10);
|
|
65
|
+
const ampm = m[3].toLowerCase();
|
|
66
|
+
if (ampm === 'pm' && h !== 12) h += 12;
|
|
67
|
+
if (ampm === 'am' && h === 12) h = 0;
|
|
68
|
+
return buildTargetDate(h, min, clean);
|
|
69
|
+
}
|
|
70
|
+
// Try "H AM/PM" without minutes (e.g. "3pm", "8 PM")
|
|
71
|
+
m = searchText.match(/(\d{1,2})\s*(am|pm)/i);
|
|
72
|
+
if (m) {
|
|
73
|
+
let h = parseInt(m[1], 10);
|
|
74
|
+
const ampm = m[2].toLowerCase();
|
|
75
|
+
if (ampm === 'pm' && h !== 12) h += 12;
|
|
76
|
+
if (ampm === 'am' && h === 12) h = 0;
|
|
77
|
+
return buildTargetDate(h, 0, clean);
|
|
78
|
+
}
|
|
79
|
+
// Try 24-hour "HH:MM"
|
|
80
|
+
m = searchText.match(/(\d{1,2}):(\d{2})/);
|
|
81
|
+
if (m) {
|
|
82
|
+
const h = parseInt(m[1], 10);
|
|
83
|
+
const min = parseInt(m[2], 10);
|
|
84
|
+
if (h >= 0 && h <= 23 && min >= 0 && min <= 59) {
|
|
85
|
+
return buildTargetDate(h, min, clean);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
// Try "X minutes" or "X mins"
|
|
89
|
+
m = searchText.match(/(\d+)\s*min/i);
|
|
90
|
+
if (m) {
|
|
91
|
+
return new Date(Date.now() + parseInt(m[1], 10) * 60 * 1000);
|
|
92
|
+
}
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildTargetDate(hours, minutes, fullText) {
|
|
97
|
+
// Extract timezone if present, e.g. "(Asia/Jerusalem)"
|
|
98
|
+
const tzMatch = fullText && fullText.match(/\(([A-Za-z_]+\/[A-Za-z_]+)\)/);
|
|
99
|
+
const tz = tzMatch ? tzMatch[1] : Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
100
|
+
|
|
101
|
+
// Get current date components in the target timezone
|
|
102
|
+
const now = new Date();
|
|
103
|
+
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
104
|
+
timeZone: tz,
|
|
105
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
106
|
+
}).formatToParts(now);
|
|
107
|
+
const p = Object.fromEntries(parts.map(({ type, value }) => [type, value]));
|
|
108
|
+
|
|
109
|
+
// Build an ISO string that looks like local time in `tz`, then find its UTC equivalent
|
|
110
|
+
// by asking Intl what UTC instant maps back to that local time.
|
|
111
|
+
const localStr = `${p.year}-${p.month}-${p.day}T${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
|
|
112
|
+
|
|
113
|
+
// Binary-search the UTC instant whose wall-clock in `tz` equals localStr
|
|
114
|
+
const targetLocal = new Date(localStr); // treat as UTC epoch anchor (value doesn't matter, just ms)
|
|
115
|
+
// Compute offset: difference between UTC and tz wall-clock at `now`
|
|
116
|
+
const formatter = new Intl.DateTimeFormat('en-CA', {
|
|
117
|
+
timeZone: tz,
|
|
118
|
+
year: 'numeric', month: '2-digit', day: '2-digit',
|
|
119
|
+
hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
|
|
120
|
+
});
|
|
121
|
+
// Format `now` in tz, parse it back as if it were UTC to get the offset
|
|
122
|
+
const tzParts = formatter.formatToParts(now);
|
|
123
|
+
const q = Object.fromEntries(tzParts.map(({ type, value }) => [type, value]));
|
|
124
|
+
const tzWall = new Date(`${q.year}-${q.month}-${q.day}T${q.hour === '24' ? '00' : q.hour}:${q.minute}:${q.second}Z`);
|
|
125
|
+
const offsetMs = now.getTime() - tzWall.getTime();
|
|
126
|
+
|
|
127
|
+
let target = new Date(new Date(localStr + 'Z').getTime() + offsetMs);
|
|
128
|
+
|
|
129
|
+
if (target <= now) target = new Date(target.getTime() + 24 * 60 * 60 * 1000);
|
|
130
|
+
return target;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const rawArgs = process.argv.slice(2);
|
|
134
|
+
if (rawArgs.length === 0) {
|
|
135
|
+
console.error('[lazy] Usage: node wrapper.js "<claude prompt>"');
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// If the single arg is a path to a .txt file, read the prompt from it
|
|
140
|
+
// (used by launch.js to avoid PowerShell splitting on spaces/commas)
|
|
141
|
+
let args;
|
|
142
|
+
if (rawArgs.length === 1 && rawArgs[0].endsWith('.txt') && fs.existsSync(rawArgs[0])) {
|
|
143
|
+
const prompt = fs.readFileSync(rawArgs[0], 'utf8').trim();
|
|
144
|
+
try { fs.unlinkSync(rawArgs[0]); } catch (_) {}
|
|
145
|
+
args = [prompt];
|
|
146
|
+
} else {
|
|
147
|
+
args = rawArgs;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Allow overriding the claude binary for testing.
|
|
151
|
+
// Value is used as-is as the command prefix inside PowerShell's & operator.
|
|
152
|
+
// Example: CLAUDE_BIN="node 'C:\\fake.js'"
|
|
153
|
+
const CLAUDE_BIN = process.env.CLAUDE_BIN || 'claude';
|
|
154
|
+
|
|
155
|
+
let term = null;
|
|
156
|
+
let stopping = false;
|
|
157
|
+
|
|
158
|
+
// Forward user keystrokes to the pty (set up once; survives restarts via shared `term` ref)
|
|
159
|
+
if (process.stdin.isTTY) process.stdin.setRawMode(true);
|
|
160
|
+
process.stdin.resume();
|
|
161
|
+
process.stdin.on('data', (d) => {
|
|
162
|
+
if (term) term.write(d.toString());
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Resize pty whenever outer terminal resizes (set up once)
|
|
166
|
+
process.stdout.on('resize', () => {
|
|
167
|
+
if (term) term.resize(process.stdout.columns || 220, process.stdout.rows || 50);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
function run() {
|
|
171
|
+
const cols = process.stdout.columns || 220;
|
|
172
|
+
const rows = process.stdout.rows || 50;
|
|
173
|
+
|
|
174
|
+
// Build PowerShell command — single-quote args to preserve spaces/commas
|
|
175
|
+
const quotedArgs = args.map(a => `'${a.replace(/'/g, "''")}'`).join(' ');
|
|
176
|
+
const cmdStr = `& ${CLAUDE_BIN} ${quotedArgs}`;
|
|
177
|
+
|
|
178
|
+
console.error(`[lazy] Starting ${CLAUDE_BIN}...`);
|
|
179
|
+
|
|
180
|
+
term = pty.spawn(POWERSHELL, ['-NoProfile', '-Command', cmdStr], {
|
|
181
|
+
name: 'xterm-256color',
|
|
182
|
+
cols,
|
|
183
|
+
rows,
|
|
184
|
+
cwd: process.cwd(),
|
|
185
|
+
env: process.env,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
let rateLimitDetected = false;
|
|
189
|
+
let menuShown = false;
|
|
190
|
+
let resumeTimer = null;
|
|
191
|
+
let savedResetTime = null;
|
|
192
|
+
let recentOutput = '';
|
|
193
|
+
|
|
194
|
+
term.onData((data) => {
|
|
195
|
+
process.stdout.write(data); // forward to outer terminal — TUI renders normally
|
|
196
|
+
|
|
197
|
+
// Always buffer output while we're in rate-limit detection mode
|
|
198
|
+
if (rateLimitDetected) recentOutput += data;
|
|
199
|
+
|
|
200
|
+
// Step 1: detect rate limit message
|
|
201
|
+
if (!rateLimitDetected && RATE_LIMIT_RE.test(data)) {
|
|
202
|
+
rateLimitDetected = true;
|
|
203
|
+
recentOutput += data;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Step 1b: scan every chunk for reset time until we find one
|
|
207
|
+
if (rateLimitDetected && !menuShown && !savedResetTime) {
|
|
208
|
+
const candidate = parseResetTime(data);
|
|
209
|
+
if (candidate && candidate.getTime() - Date.now() > 5 * 60 * 1000) {
|
|
210
|
+
savedResetTime = candidate;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Step 2: once rate limit seen, wait for the actual menu before sending "1"
|
|
215
|
+
if (rateLimitDetected && !menuShown && /Stop and wait|rate.limit.option|What do you want to do/i.test(data)) {
|
|
216
|
+
menuShown = true;
|
|
217
|
+
setTimeout(() => { if (term) term.write('\r'); }, 200);
|
|
218
|
+
|
|
219
|
+
const resetTime = savedResetTime;
|
|
220
|
+
if (resetTime) {
|
|
221
|
+
const delayMs = resetTime.getTime() - Date.now() + RESUME_BUFFER_MS;
|
|
222
|
+
const resumeAt = new Date(resetTime.getTime() + RESUME_BUFFER_MS);
|
|
223
|
+
setTimeout(() => {
|
|
224
|
+
process.stderr.write(`\n[lazy] Resets at ${resetTime.toLocaleTimeString()}, auto-resuming at ${resumeAt.toLocaleTimeString()} (${Math.ceil(delayMs / 60000)} min), will send "continue" after reset\n`);
|
|
225
|
+
}, 500);
|
|
226
|
+
resumeTimer = setTimeout(() => {
|
|
227
|
+
if (term) {
|
|
228
|
+
process.stderr.write(`\n[lazy] Resuming...\n`);
|
|
229
|
+
term.write('\r');
|
|
230
|
+
setTimeout(() => { if (term) term.write('continue\r'); }, 1000);
|
|
231
|
+
rateLimitDetected = false;
|
|
232
|
+
menuShown = false;
|
|
233
|
+
savedResetTime = null;
|
|
234
|
+
recentOutput = '';
|
|
235
|
+
}
|
|
236
|
+
}, Math.max(delayMs, 0));
|
|
237
|
+
} else {
|
|
238
|
+
process.stderr.write('\n[lazy] Could not parse reset time — waiting for Claude to resume.\n');
|
|
239
|
+
rateLimitDetected = false;
|
|
240
|
+
menuShown = false;
|
|
241
|
+
recentOutput = '';
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
term.onExit(({ exitCode }) => {
|
|
247
|
+
if (resumeTimer) { clearTimeout(resumeTimer); resumeTimer = null; }
|
|
248
|
+
term = null;
|
|
249
|
+
if (stopping) return;
|
|
250
|
+
if (exitCode === 0) {
|
|
251
|
+
console.error('[lazy] claude exited cleanly.');
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
console.error(`\n[lazy] claude exited (code ${exitCode}) — restarting in ${RESTART_DELAY_MS / 1000}s...`);
|
|
255
|
+
console.error('[lazy] Context is preserved — agent will re-register with saved token.');
|
|
256
|
+
setTimeout(run, RESTART_DELAY_MS);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
process.on('SIGINT', () => {
|
|
261
|
+
stopping = true;
|
|
262
|
+
if (term) term.kill();
|
|
263
|
+
process.exit(0);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
run();
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Wrapper is a thin restart-on-exit script — the testable unit is the
|
|
5
|
+
* argument validation and the restart logic. We test these via the exported
|
|
6
|
+
* constants and by smoke-testing the module loads without errors.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
describe('wrapper constants', () => {
|
|
10
|
+
let src;
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
src = require('fs').readFileSync(require('path').join(__dirname, 'wrapper.js'), 'utf8');
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('RESTART_DELAY_MS is a positive number', () => {
|
|
16
|
+
const m = src.match(/RESTART_DELAY_MS\s*=\s*(\d+)/);
|
|
17
|
+
expect(m).not.toBeNull();
|
|
18
|
+
const val = parseInt(m[1], 10);
|
|
19
|
+
expect(val).toBeGreaterThan(0);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('uses node-pty for PTY spawning', () => {
|
|
23
|
+
expect(src).toMatch(/require\(['"]node-pty['"]\)/);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('uses PowerShell instead of cmd.exe', () => {
|
|
27
|
+
expect(src).toMatch(/POWERSHELL/);
|
|
28
|
+
expect(src).toMatch(/powershell\.exe/);
|
|
29
|
+
expect(src).not.toMatch(/CMD_EXE/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('forwards output to process.stdout', () => {
|
|
33
|
+
expect(src).toMatch(/process\.stdout\.write/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('auto-selects option 1 on rate-limit detection', () => {
|
|
37
|
+
expect(src).toMatch(/RATE_LIMIT_RE/);
|
|
38
|
+
expect(src).toMatch(/term\.write\(['"]\\r['"]\)/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('schedules auto-resume after parsing reset time', () => {
|
|
42
|
+
expect(src).toMatch(/parseResetTime/);
|
|
43
|
+
expect(src).toMatch(/RESUME_BUFFER_MS/);
|
|
44
|
+
expect(src).toMatch(/auto-resume/i);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('resets rateLimitHandled so subsequent limits are caught', () => {
|
|
48
|
+
expect(src).toMatch(/rateLimitDetected\s*=\s*false/);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('CLAUDE_BIN env var is supported for binary override', () => {
|
|
52
|
+
expect(src).toMatch(/CLAUDE_BIN/);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('parseResetTime', () => {
|
|
57
|
+
// Extract parseResetTime and buildTargetDate from wrapper source
|
|
58
|
+
let parseResetTime;
|
|
59
|
+
beforeAll(() => {
|
|
60
|
+
const src = require('fs').readFileSync(require('path').join(__dirname, 'wrapper.js'), 'utf8');
|
|
61
|
+
// Extract the two functions from source and eval them
|
|
62
|
+
const buildTargetMatch = src.match(/function buildTargetDate[\s\S]*?^}/m);
|
|
63
|
+
const parseMatch = src.match(/function parseResetTime[\s\S]*?^}/m);
|
|
64
|
+
// eslint-disable-next-line no-eval
|
|
65
|
+
eval(buildTargetMatch[0]);
|
|
66
|
+
// eslint-disable-next-line no-eval
|
|
67
|
+
parseResetTime = eval(`(${parseMatch[0]})`);
|
|
68
|
+
// Inject buildTargetDate into parseResetTime's scope
|
|
69
|
+
const combined = `(function() { ${buildTargetMatch[0]}; return ${parseMatch[0].replace('function parseResetTime', 'function parseResetTime')}; })()`;
|
|
70
|
+
// eslint-disable-next-line no-eval
|
|
71
|
+
parseResetTime = eval(combined);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('parses "8:00 PM" format', () => {
|
|
75
|
+
const result = parseResetTime('Rate limit resets at 8:00 PM');
|
|
76
|
+
expect(result).toBeInstanceOf(Date);
|
|
77
|
+
expect(result.getHours()).toBe(20);
|
|
78
|
+
expect(result.getMinutes()).toBe(0);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('parses "2:30 pm" format', () => {
|
|
82
|
+
const result = parseResetTime('resets at 2:30 pm');
|
|
83
|
+
expect(result).toBeInstanceOf(Date);
|
|
84
|
+
expect(result.getHours()).toBe(14);
|
|
85
|
+
expect(result.getMinutes()).toBe(30);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('parses "8 PM" without minutes', () => {
|
|
89
|
+
const result = parseResetTime('limit resets at 8 PM');
|
|
90
|
+
expect(result).toBeInstanceOf(Date);
|
|
91
|
+
expect(result.getHours()).toBe(20);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('parses "12:00 AM" as midnight', () => {
|
|
95
|
+
const result = parseResetTime('resets at 12:00 AM');
|
|
96
|
+
expect(result).toBeInstanceOf(Date);
|
|
97
|
+
expect(result.getHours()).toBe(0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('parses "12:30 PM" as 12:30', () => {
|
|
101
|
+
const result = parseResetTime('resets at 12:30 PM');
|
|
102
|
+
expect(result).toBeInstanceOf(Date);
|
|
103
|
+
expect(result.getHours()).toBe(12);
|
|
104
|
+
expect(result.getMinutes()).toBe(30);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('parses "30 minutes" relative format', () => {
|
|
108
|
+
const before = Date.now();
|
|
109
|
+
const result = parseResetTime('rate limit resets in 30 minutes');
|
|
110
|
+
expect(result).toBeInstanceOf(Date);
|
|
111
|
+
const diffMin = (result.getTime() - before) / 60000;
|
|
112
|
+
expect(diffMin).toBeGreaterThan(29);
|
|
113
|
+
expect(diffMin).toBeLessThan(31);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('parses "5 min" shorthand', () => {
|
|
117
|
+
const before = Date.now();
|
|
118
|
+
const result = parseResetTime('resets in 5 min');
|
|
119
|
+
const diffMin = (result.getTime() - before) / 60000;
|
|
120
|
+
expect(diffMin).toBeGreaterThan(4);
|
|
121
|
+
expect(diffMin).toBeLessThan(6);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('returns null for unparseable text', () => {
|
|
125
|
+
const result = parseResetTime('some random text with no time');
|
|
126
|
+
expect(result).toBeNull();
|
|
127
|
+
});
|
|
128
|
+
});
|