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.
@@ -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 event = parseStreamEvent(buffer.replace(/\r/g, ''));
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 (signals.code === 1 && stderr.trim().length < 50)
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 X:XX AM" / "resets 3am" from stderr text.
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
- * Accepts with or without (UTC) assumes UTC when timezone not specified. */
12
+ * Supports IANA timezones, UTC, and bare times (assumes UTC). */
13
13
  export function parseUsageLimitResetMs(text) {
14
- // Match patterns: "resets 6pm (UTC)", "resets 6:30pm", "resets 18:00 (UTC)", "resets 6 PM", "resets 3am"
15
- const match = text.match(/resets\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?(?:\s*\(UTC\))?/i);
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
- const reset = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), hours, minutes, 0, 0));
29
- // If reset time is in the past, it's tomorrow
30
- if (reset.getTime() <= now.getTime()) {
31
- reset.setUTCDate(reset.getUTCDate() + 1);
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 = reset.getTime() - now.getTime();
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)');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-workspace",
3
- "version": "1.1.79",
3
+ "version": "1.1.81",
4
4
  "description": "Scaffold a project with Claude Code agents for autonomous AI-driven development",
5
5
  "type": "module",
6
6
  "bin": {