Files
Codeman/src/usage-limit-patterns.ts
T
arkonandClaude Fable 5 68310619a7 feat(session,mobile): auto-resume on usage limit + mobile view fixes
Auto-resume on usage limit ("token pause" control, opt-in checkbox at the
top of the Respawn tab, off by default):
- usage-limit-patterns.ts (new, pure): detects all Claude Code limit
  messages (1.0.x-2.1.x eras incl. "5-hour limit reached - resets 8pm",
  "You've hit your limit - resets 1:40pm (TZ)", weekly date forms, raw
  "usage limit reached|<epoch>") and parses the reset time. Conservative:
  no parseable future reset time, no action.
- SessionAutoOps: arms a timer at reset+2min, sends Esc (dismisses the
  rate-limit dialog) + "continue"; dedups footer redraws, retries every
  5min on stale times, cancels when Claude starts working, persists and
  re-arms across Codeman restarts (SessionState.autoResumeEnabled/At).
- Respawn guard: cycles are blocked while limit-paused so /clear cannot
  wipe the paused conversation (respawnBlocked reason 'usage_limit').
- POST /api/sessions/:id/auto-resume; SSE session:limitPauseScheduled/
  limitResume/limitResumeCancelled; toasts + status line in the modal.
- Respawn tab tidied: single-row prompt fields, merged behavior row.

Mobile fixes (0.9.8 regressions, user-reported):
- Resize arbitration is now activity-based: a desktop sizing claim only
  blocks phone resizes while the desktop typed within 90s
  (Session.DESKTOP_CLAIM_IDLE_MS). Idle desktop -> phone takes the pane;
  next desktop keystroke re-asserts the desktop layout server-side
  (noteDesktopActivity via ws-routes input). Phones re-send dims every
  30s (visible tab only, skipped while the keyboard is open) so attaching
  under a hot claim self-corrects. Fixes the desktop-width-stream-in-
  narrow-xterm soup (mid-word wraps, tmux dot fill, Ink overdraw).
- Cross-device reflows (takeover/re-assert) emit a debounced needsRefresh
  so all clients reload the buffer instead of stacking ghost Ink frames.
- Keyboard accessory/toolbar lift restored: measure keyboardOffset
  against window.innerHeight (layout viewport), not the shrunken .app -
  on iOS the offset computed to 0, leaving both bars hidden behind the
  OS keyboard with a dead gap above.
- Removed the mobile header utility ("three dots") toggle entirely;
  the headerRight tray stays collapsed on small viewports.

Tests: usage-limit-patterns (36), session-auto-resume (21), resize
arbitration (+6), session routes (+4), respawn guard (+2); MockSession
auto-resume/sizing stubs; mobile tabs test updated for toggle removal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:41:34 +02:00

211 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Pure detection of Claude Code usage-limit pause messages.
*
* When a Claude subscription limit (5-hour rolling window, weekly, Opus weekly,
* or extra-usage balance) is hit, the Claude Code TUI stops working and prints a
* status line with the reset time. These helpers detect that state in cleaned
* (ANSI-stripped) terminal output and parse the reset time, so the session
* auto-resume feature (SessionAutoOps) can schedule a "continue" nudge.
*
* Message shapes covered (observed across Claude Code 1.0.x–2.1.x, 2025–2026):
* - `5-hour limit reached ∙ resets 8pm` (v1.0.109+ footer)
* - `Session limit reached ∙ resets 8pm`
* - `Weekly limit reached ∙ resets 6pm`
* - `Opus weekly limit reached ∙ resets Oct 6, 1pm`
* - `Limit reached · resets 1pm (America/Chicago) · /upgrade to Max…` (v2.0.55+)
* - `You've hit your limit · resets 1:40pm (America/New_York)` (v2.1.x)
* - `You've hit your weekly limit · resets Mon 12:00am`
* - `You've hit your limit · resets May 5 at 9pm (America/New_York)`
* - `You're out of extra usage · resets 1pm (America/Los_Angeles)`
* - `Claude usage limit reached. Your limit will reset at 2pm (America/New_York)` (v1.0.x inline)
* - `Claude AI usage limit reached|1755309600` (raw API, epoch seconds)
*
* Deliberately conservative: a limit phrase WITHOUT a parseable reset time is
* ignored (returns null) so ordinary conversation text mentioning "limit
* reached" can't arm the scheduler. The downstream retry loop (re-detection
* after each resume attempt) compensates for any parsing imprecision.
*
* All functions are pure (caller passes `now`) for testability.
*
* @module usage-limit-patterns
*/
/** Result of scanning terminal output for a usage-limit pause. */
export interface UsageLimitDetection {
/**
* Epoch ms when the limit resets. May be in the past when the matched
* message is stale (caller should treat past values as "retry soon").
*/
resetAt: number;
/** Matched message snippet (for logging and UI). */
matched: string;
}
/**
* Limit phrases that indicate Claude stopped on a usage limit.
* `\blimit reached` covers all "<X> limit reached" footer variants.
*/
const LIMIT_PHRASE_PATTERN =
/(?:\blimit\s+reached\b|you'?ve\s+hit\s+your\s+(?:\w+\s+)?limit\b|you'?re\s+out\s+of\s+extra\s+usage\b)/gi;
/**
* Reset-time spec following a limit phrase. Captures:
* 1 month (weekly resets >1 day out: "Oct 6, 1pm" / "May 5 at 9pm")
* 2 day-of-month
* 3 day-of-week ("Mon 12:00am")
* 4 hour (12h) 5 minutes 6 am/pm 7 IANA timezone in parens (optional)
* `resets?` + optional `at` also covers the v1.0.x "will reset at 2pm" form.
*/
const RESET_TIME_PATTERN =
/\bresets?\s+(?:at\s+)?(?:(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\s+(\d{1,2})(?:\s*,\s*|\s+at\s+)|(sun|mon|tue|wed|thu|fri|sat)[a-z]*\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b(?:\s*\(([^()\n]{1,64})\))?/i;
/** Raw API form: `Claude AI usage limit reached|1755309600` (epoch seconds). */
const EPOCH_LIMIT_PATTERN = /\busage\s+limit\s+reached\|(\d{9,11})\b/gi;
/** How far after a limit phrase the reset-time spec may appear (chars). */
const RESET_TIME_WINDOW = 160;
/** Parsed reset spec must not be further out than this (weekly max ≈ 7 days). */
const MAX_RESET_HORIZON_MS = 8 * 24 * 60 * 60 * 1000;
const MONTHS = ['jan', 'feb', 'mar', 'apr', 'may', 'jun', 'jul', 'aug', 'sep', 'oct', 'nov', 'dec'];
const WEEKDAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
const DAY_MS = 24 * 60 * 60 * 1000;
/**
* Current UTC offset of an IANA timezone in ms, or null if unresolvable
* (e.g. the `(Etc/Unknown)` failure variant Claude Code can print).
* DST transitions inside the wait window can skew the result by an hour;
* the auto-resume retry loop absorbs that.
*/
function zoneOffsetMs(timeZone: string, at: number): number | null {
try {
const dtf = new Intl.DateTimeFormat('en-US', { timeZone, timeZoneName: 'longOffset' });
const name = dtf.formatToParts(at).find((p) => p.type === 'timeZoneName')?.value;
if (!name) return null;
const m = /^GMT(?:([+-])(\d{1,2})(?::(\d{2}))?)?$/.exec(name);
if (!m) return null;
if (!m[1]) return 0; // plain "GMT"
const sign = m[1] === '-' ? -1 : 1;
return sign * (parseInt(m[2], 10) * 60 + (m[3] ? parseInt(m[3], 10) : 0)) * 60_000;
} catch {
return null;
}
}
interface ResetSpec {
month?: number; // 0-11
dayOfMonth?: number; // 1-31
dayOfWeek?: number; // 0-6 (Sun-Sat)
hour: number; // 0-23
minute: number; // 0-59
timeZone?: string;
}
/**
* Compute the epoch ms for a parsed reset spec. Times are wall-clock in the
* given IANA timezone when present (and resolvable), otherwise server-local —
* Claude CLI runs on the same host as Codeman, so local time is the right
* default. Returns null when the spec is implausible (> ~8 days out).
*/
function resolveResetSpec(spec: ResetSpec, now: number): number | null {
const offset = spec.timeZone ? zoneOffsetMs(spec.timeZone, now) : null;
// Wall-clock view of "now": shifted-UTC when a zone offset is known,
// server-local otherwise. Read/build components with the matching API.
const useZone = offset !== null;
const wallNow = useZone ? new Date(now + offset) : new Date(now);
const get = {
year: () => (useZone ? wallNow.getUTCFullYear() : wallNow.getFullYear()),
month: () => (useZone ? wallNow.getUTCMonth() : wallNow.getMonth()),
date: () => (useZone ? wallNow.getUTCDate() : wallNow.getDate()),
day: () => (useZone ? wallNow.getUTCDay() : wallNow.getDay()),
};
const build = (y: number, mo: number, d: number): number => {
const wall = useZone
? Date.UTC(y, mo, d, spec.hour, spec.minute)
: new Date(y, mo, d, spec.hour, spec.minute).getTime();
return useZone ? wall - offset : wall;
};
let ts: number;
if (spec.month !== undefined && spec.dayOfMonth !== undefined) {
// Explicit date ("Oct 6, 1pm"). More than 2 days in the past → assume year
// rollover (message seen near New Year); slightly past → stale, keep as-is.
ts = build(get.year(), spec.month, spec.dayOfMonth);
if (ts < now - 2 * DAY_MS) {
ts = build(get.year() + 1, spec.month, spec.dayOfMonth);
}
} else if (spec.dayOfWeek !== undefined) {
// Day-of-week ("Mon 12:00am") → next occurrence.
const delta = (spec.dayOfWeek - get.day() + 7) % 7;
ts = build(get.year(), get.month(), get.date() + delta);
if (ts <= now) ts += 7 * DAY_MS;
} else {
// Time-only ("resets 8pm") → next occurrence within 24h.
ts = build(get.year(), get.month(), get.date());
if (ts <= now) ts += DAY_MS;
}
if (ts > now + MAX_RESET_HORIZON_MS) return null;
return ts;
}
/** Parse the reset-time spec found within `window`, or null. */
function parseResetTime(window: string, now: number): number | null {
const m = RESET_TIME_PATTERN.exec(window);
if (!m) return null;
const hour12 = parseInt(m[4], 10);
const minute = m[5] ? parseInt(m[5], 10) : 0;
if (hour12 < 1 || hour12 > 12 || minute > 59) return null;
const pm = m[6].toLowerCase() === 'pm';
const hour = (hour12 % 12) + (pm ? 12 : 0);
const spec: ResetSpec = { hour, minute };
if (m[1] && m[2]) {
spec.month = MONTHS.indexOf(m[1].toLowerCase());
spec.dayOfMonth = parseInt(m[2], 10);
if (spec.dayOfMonth < 1 || spec.dayOfMonth > 31) return null;
} else if (m[3]) {
spec.dayOfWeek = WEEKDAYS.indexOf(m[3].toLowerCase());
}
if (m[7]) spec.timeZone = m[7].trim();
return resolveResetSpec(spec, now);
}
/**
* Scan cleaned (ANSI-stripped) terminal output for a usage-limit pause message
* with a parseable reset time. Returns the LAST parseable occurrence in the
* chunk (most recent on screen), or null when none is found.
*/
export function detectUsageLimitPause(cleanData: string, now: number = Date.now()): UsageLimitDetection | null {
if (!cleanData || !/limit|extra usage/i.test(cleanData)) return null;
let result: UsageLimitDetection | null = null;
// Raw API epoch form
EPOCH_LIMIT_PATTERN.lastIndex = 0;
let em: RegExpExecArray | null;
while ((em = EPOCH_LIMIT_PATTERN.exec(cleanData)) !== null) {
const resetAt = parseInt(em[1], 10) * 1000;
if (resetAt > now + MAX_RESET_HORIZON_MS) continue;
result = { resetAt, matched: em[0] };
}
// TUI phrase + "resets <time>" forms
LIMIT_PHRASE_PATTERN.lastIndex = 0;
let pm: RegExpExecArray | null;
while ((pm = LIMIT_PHRASE_PATTERN.exec(cleanData)) !== null) {
const window = cleanData.slice(pm.index, pm.index + RESET_TIME_WINDOW);
const resetAt = parseResetTime(window, now);
if (resetAt !== null) {
result = { resetAt, matched: window.slice(0, 80).trim() };
}
}
return result;
}