create-claude-workspace 1.1.79 → 1.1.81
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/dist/scripts/lib/claude-runner.mjs +21 -2
- package/dist/scripts/lib/errors.mjs +4 -1
- package/dist/scripts/lib/errors.spec.js +5 -2
- package/dist/scripts/lib/loop-integration.spec.js +6 -3
- package/dist/scripts/lib/utils.mjs +46 -11
- package/dist/scripts/lib/utils.spec.js +16 -0
- package/package.json +1 -1
|
@@ -249,9 +249,18 @@ export function runClaude(opts, log, runOpts = {}) {
|
|
|
249
249
|
if (AUTH_SERVER_RE.test(msg) && /auth/i.test(msg))
|
|
250
250
|
isAuthServerError = true;
|
|
251
251
|
}
|
|
252
|
+
let usageLimitKilled = false;
|
|
252
253
|
function detectTextSignals(lower) {
|
|
253
|
-
if (USAGE_LIMIT_RE.test(lower))
|
|
254
|
+
if (USAGE_LIMIT_RE.test(lower)) {
|
|
254
255
|
isUsageLimit = true;
|
|
256
|
+
// Kill immediately on usage limit — Claude CLI retries internally and spams
|
|
257
|
+
// the message dozens of times. No point waiting for it to exit on its own.
|
|
258
|
+
if (!usageLimitKilled && !killed) {
|
|
259
|
+
usageLimitKilled = true;
|
|
260
|
+
log.warn('Usage limit detected — killing process to prevent spam.');
|
|
261
|
+
killChild('process_timeout');
|
|
262
|
+
}
|
|
263
|
+
}
|
|
255
264
|
if (RATE_LIMIT_RE.test(lower))
|
|
256
265
|
isRateLimit = true;
|
|
257
266
|
if (AUTH_ERROR_RE.test(lower))
|
|
@@ -407,7 +416,8 @@ export function runClaude(opts, log, runOpts = {}) {
|
|
|
407
416
|
}
|
|
408
417
|
// Flush remaining buffer
|
|
409
418
|
if (buffer.trim()) {
|
|
410
|
-
const
|
|
419
|
+
const cleaned = buffer.replace(/\r/g, '');
|
|
420
|
+
const event = parseStreamEvent(cleaned);
|
|
411
421
|
if (event) {
|
|
412
422
|
if (event.session_id && !sessionId)
|
|
413
423
|
sessionId = event.session_id;
|
|
@@ -416,6 +426,14 @@ export function runClaude(opts, log, runOpts = {}) {
|
|
|
416
426
|
resultReceived = true;
|
|
417
427
|
}
|
|
418
428
|
}
|
|
429
|
+
else {
|
|
430
|
+
// Non-JSON buffer (e.g. "You've hit your limit" without trailing newline)
|
|
431
|
+
const lower = cleaned.toLowerCase();
|
|
432
|
+
detectTextSignals(lower);
|
|
433
|
+
if (USAGE_LIMIT_RE.test(lower) || RATE_LIMIT_RE.test(lower) || AUTH_ERROR_RE.test(lower)) {
|
|
434
|
+
stderr += cleaned + '\n';
|
|
435
|
+
}
|
|
436
|
+
}
|
|
419
437
|
}
|
|
420
438
|
// Final stderr-based detection
|
|
421
439
|
detectTextSignals(stderr.toLowerCase());
|
|
@@ -431,6 +449,7 @@ export function runClaude(opts, log, runOpts = {}) {
|
|
|
431
449
|
timedOut: killReason === 'process_timeout',
|
|
432
450
|
activityTimedOut: killReason === 'activity_timeout',
|
|
433
451
|
hasResult: resultReceived,
|
|
452
|
+
durationMs,
|
|
434
453
|
});
|
|
435
454
|
if (resolved)
|
|
436
455
|
return;
|
|
@@ -49,7 +49,10 @@ export function classifyError(signals) {
|
|
|
49
49
|
return category;
|
|
50
50
|
}
|
|
51
51
|
// Exit code 1 with no error indicators → likely max-turns
|
|
52
|
-
if
|
|
52
|
+
// But only if process ran long enough (>10s). Instant exit (e.g. usage limit printed
|
|
53
|
+
// as plain text that wasn't caught) should NOT be classified as max-turns to prevent
|
|
54
|
+
// infinite restart loops with no backoff.
|
|
55
|
+
if (signals.code === 1 && stderr.trim().length < 50 && (signals.durationMs ?? 60_000) > 10_000)
|
|
53
56
|
return 'max_turns';
|
|
54
57
|
// Non-zero exit with content
|
|
55
58
|
if (signals.code !== null && signals.code !== 0)
|
|
@@ -56,8 +56,11 @@ describe('classifyError', () => {
|
|
|
56
56
|
it('detects context exhaustion from stderr', () => {
|
|
57
57
|
expect(classifyError({ ...base, code: 1, stderr: 'context_length_exceeded' })).toBe('context_exhausted');
|
|
58
58
|
});
|
|
59
|
-
it('classifies exit code 1 with minimal stderr as max_turns', () => {
|
|
60
|
-
expect(classifyError({ ...base, code: 1, stderr: '' })).toBe('max_turns');
|
|
59
|
+
it('classifies exit code 1 with minimal stderr as max_turns when process ran >10s', () => {
|
|
60
|
+
expect(classifyError({ ...base, code: 1, stderr: '', durationMs: 60_000 })).toBe('max_turns');
|
|
61
|
+
});
|
|
62
|
+
it('classifies instant exit (code 1, <10s, no stderr) as cli_crash not max_turns', () => {
|
|
63
|
+
expect(classifyError({ ...base, code: 1, stderr: '', durationMs: 1_000 })).toBe('cli_crash');
|
|
61
64
|
});
|
|
62
65
|
it('classifies exit code 1 with content as cli_crash', () => {
|
|
63
66
|
expect(classifyError({ ...base, code: 1, stderr: 'Some unexpected internal error happened during execution blah blah' })).toBe('cli_crash');
|
|
@@ -674,9 +674,12 @@ describe('Loop integration: classifyError → getErrorAction → checkpoint', ()
|
|
|
674
674
|
expect(classifyError({ ...base, code: 1, stderr: 'ENOTFOUND' })).toBe('network_error');
|
|
675
675
|
expect(classifyError({ ...base, code: 1, stderr: 'context_length_exceeded' })).toBe('context_exhausted');
|
|
676
676
|
});
|
|
677
|
-
it('exit 1 with minimal stderr → max_turns', () => {
|
|
678
|
-
expect(classifyError({ ...base, code: 1, stderr: '' })).toBe('max_turns');
|
|
679
|
-
expect(classifyError({ ...base, code: 1, stderr: 'short' })).toBe('max_turns');
|
|
677
|
+
it('exit 1 with minimal stderr and long duration → max_turns', () => {
|
|
678
|
+
expect(classifyError({ ...base, code: 1, stderr: '', durationMs: 60_000 })).toBe('max_turns');
|
|
679
|
+
expect(classifyError({ ...base, code: 1, stderr: 'short', durationMs: 60_000 })).toBe('max_turns');
|
|
680
|
+
});
|
|
681
|
+
it('exit 1 with minimal stderr and instant exit → cli_crash (not max_turns)', () => {
|
|
682
|
+
expect(classifyError({ ...base, code: 1, stderr: '', durationMs: 2_000 })).toBe('cli_crash');
|
|
680
683
|
});
|
|
681
684
|
it('exit 1 with long stderr → cli_crash', () => {
|
|
682
685
|
expect(classifyError({ ...base, code: 1, stderr: 'a'.repeat(60) })).toBe('cli_crash');
|
|
@@ -7,17 +7,18 @@ import { createInterface } from 'node:readline';
|
|
|
7
7
|
// Node.js runtime accepts `true` for shell but @types expects string
|
|
8
8
|
const SHELL = process.platform === 'win32' ? 'cmd.exe' : '/bin/sh';
|
|
9
9
|
// ─── Usage limit reset time parser ───
|
|
10
|
-
/** Parse "resets Xpm (UTC)" / "resets
|
|
10
|
+
/** Parse "resets Xpm (UTC)" / "resets 12pm (Europe/Prague)" / "resets 3am" from stderr text.
|
|
11
11
|
* Returns milliseconds to wait, or null if unparseable.
|
|
12
|
-
*
|
|
12
|
+
* Supports IANA timezones, UTC, and bare times (assumes UTC). */
|
|
13
13
|
export function parseUsageLimitResetMs(text) {
|
|
14
|
-
// Match
|
|
15
|
-
const match = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s*\(
|
|
14
|
+
// Match: "resets 6pm (UTC)", "resets 12pm (Europe/Prague)", "resets 6:30pm", "resets 18:00 (UTC)", "resets 3am"
|
|
15
|
+
const match = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s*\(([^)]+)\))?/i);
|
|
16
16
|
if (!match)
|
|
17
17
|
return null;
|
|
18
18
|
let hours = parseInt(match[1], 10);
|
|
19
19
|
const minutes = match[2] ? parseInt(match[2], 10) : 0;
|
|
20
20
|
const ampm = match[3]?.toLowerCase();
|
|
21
|
+
const tz = match[4]?.trim() || 'UTC';
|
|
21
22
|
if (ampm === 'pm' && hours < 12)
|
|
22
23
|
hours += 12;
|
|
23
24
|
if (ampm === 'am' && hours === 12)
|
|
@@ -25,14 +26,48 @@ export function parseUsageLimitResetMs(text) {
|
|
|
25
26
|
if (hours > 23 || minutes > 59)
|
|
26
27
|
return null;
|
|
27
28
|
const now = new Date();
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
reset
|
|
29
|
+
let resetMs;
|
|
30
|
+
try {
|
|
31
|
+
// Resolve timezone offset using Intl (works for UTC, IANA like "Europe/Prague", etc.)
|
|
32
|
+
// Strategy: find the UTC offset for the given timezone at "now", then compute reset time
|
|
33
|
+
const tzName = tz === 'UTC' ? 'UTC' : tz;
|
|
34
|
+
// Get current time in target timezone to determine today's date there
|
|
35
|
+
const dtf = new Intl.DateTimeFormat('en-CA', {
|
|
36
|
+
timeZone: tzName, year: 'numeric', month: '2-digit', day: '2-digit',
|
|
37
|
+
hour: '2-digit', minute: '2-digit', hour12: false,
|
|
38
|
+
});
|
|
39
|
+
const parts = dtf.formatToParts(now);
|
|
40
|
+
const pv = (t) => parts.find(p => p.type === t).value;
|
|
41
|
+
const tzDate = `${pv('year')}-${pv('month')}-${pv('day')}`;
|
|
42
|
+
// Construct the reset datetime as if it were UTC, then find the real offset
|
|
43
|
+
const timeStr = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:00`;
|
|
44
|
+
const resetAsUtc = new Date(`${tzDate}T${timeStr}Z`);
|
|
45
|
+
// Find what hour resetAsUtc maps to in the target TZ
|
|
46
|
+
const inTz = new Intl.DateTimeFormat('en-CA', {
|
|
47
|
+
timeZone: tzName, hour: '2-digit', minute: '2-digit', hour12: false,
|
|
48
|
+
}).format(resetAsUtc);
|
|
49
|
+
const [tzH, tzM] = inTz.split(':').map(Number);
|
|
50
|
+
const offsetMin = (tzH * 60 + tzM) - (resetAsUtc.getUTCHours() * 60 + resetAsUtc.getUTCMinutes());
|
|
51
|
+
// Handle day wrap (e.g. tzH=23 but utcH=1 → offset should be negative, not +1320)
|
|
52
|
+
const correctedOffset = offsetMin > 720 ? offsetMin - 1440 : offsetMin < -720 ? offsetMin + 1440 : offsetMin;
|
|
53
|
+
// Reset time in UTC = local time - offset
|
|
54
|
+
const resetUtc = new Date(resetAsUtc.getTime() - correctedOffset * 60_000);
|
|
55
|
+
if (resetUtc.getTime() <= now.getTime()) {
|
|
56
|
+
resetUtc.setUTCDate(resetUtc.getUTCDate() + 1);
|
|
57
|
+
}
|
|
58
|
+
resetMs = resetUtc.getTime();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// Invalid timezone — fall back to treating as UTC
|
|
62
|
+
const reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hours, minutes, 0, 0));
|
|
63
|
+
if (reset.getTime() <= now.getTime()) {
|
|
64
|
+
reset.setUTCDate(reset.getUTCDate() + 1);
|
|
65
|
+
}
|
|
66
|
+
resetMs = reset.getTime();
|
|
32
67
|
}
|
|
33
|
-
const waitMs =
|
|
34
|
-
// Sanity: max 24 hours
|
|
35
|
-
if (waitMs > 24 * 60 * 60_000)
|
|
68
|
+
const waitMs = resetMs - now.getTime();
|
|
69
|
+
// Sanity: max 24 hours, min 0
|
|
70
|
+
if (waitMs > 24 * 60 * 60_000 || waitMs < 0)
|
|
36
71
|
return null;
|
|
37
72
|
return waitMs;
|
|
38
73
|
}
|
|
@@ -139,6 +139,22 @@ describe('parseUsageLimitResetMs', () => {
|
|
|
139
139
|
expect(ms).toBeTypeOf('number');
|
|
140
140
|
expect(ms).toBeGreaterThan(0);
|
|
141
141
|
});
|
|
142
|
+
it('parses "resets 12pm (Europe/Prague)" IANA timezone', () => {
|
|
143
|
+
const ms = parseUsageLimitResetMs("You've hit your limit · resets 12pm (Europe/Prague)");
|
|
144
|
+
expect(ms).toBeTypeOf('number');
|
|
145
|
+
expect(ms).toBeGreaterThan(0);
|
|
146
|
+
expect(ms).toBeLessThanOrEqual(24 * 60 * 60_000);
|
|
147
|
+
});
|
|
148
|
+
it('parses "resets 3am (America/New_York)" IANA timezone', () => {
|
|
149
|
+
const ms = parseUsageLimitResetMs("resets 3am (America/New_York)");
|
|
150
|
+
expect(ms).toBeTypeOf('number');
|
|
151
|
+
expect(ms).toBeGreaterThan(0);
|
|
152
|
+
});
|
|
153
|
+
it('falls back to UTC for invalid timezone', () => {
|
|
154
|
+
const ms = parseUsageLimitResetMs("resets 6pm (Invalid/Zone)");
|
|
155
|
+
expect(ms).toBeTypeOf('number');
|
|
156
|
+
expect(ms).toBeGreaterThan(0);
|
|
157
|
+
});
|
|
142
158
|
it('result is always positive (wraps to next day if time passed)', () => {
|
|
143
159
|
// Use a time that's definitely in the past today (1am UTC if test runs after 1am)
|
|
144
160
|
const ms = parseUsageLimitResetMs('resets 1am (UTC)');
|