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>
This commit is contained in:
arkon
2026-06-10 20:41:34 +02:00
co-authored by Claude Fable 5
parent 055f18fb66
commit 68310619a7
27 changed files with 1511 additions and 182 deletions
+210
View File
@@ -0,0 +1,210 @@
/**
* Tests for usage-limit pause detection (auto-resume on usage limit).
*
* Message corpus mirrors real Claude Code output observed across
* 1.0.x–2.1.x (GitHub issues + official error docs). Time expectations are
* computed with the same local-Date APIs the implementation uses, so the
* tests are timezone-independent.
*/
import { describe, it, expect } from 'vitest';
import { detectUsageLimitPause } from '../src/usage-limit-patterns.js';
/** Fixed "now" for deterministic tests: a real timestamp, any value works. */
const NOW = new Date(2026, 5, 10, 14, 0, 0).getTime(); // local Jun 10 2026, 2:00pm
/** Expected epoch ms for the next local occurrence of hour:minute after NOW. */
function nextLocal(hour: number, minute = 0, from = NOW): number {
const d = new Date(from);
let ts = new Date(d.getFullYear(), d.getMonth(), d.getDate(), hour, minute).getTime();
if (ts <= from) ts += 24 * 60 * 60 * 1000;
return ts;
}
describe('detectUsageLimitPause', () => {
describe('era 2 footer forms (v1.0.109+, ∙ separator, no timezone)', () => {
it.each([
['5-hour limit reached ∙ resets 8pm', 20, 0],
['Session limit reached ∙ resets 8pm', 20, 0],
['Weekly limit reached ∙ resets 6pm', 18, 0],
['5-hour limit reached ∙ resets 10:30pm', 22, 30],
['5-hour limit reached ∙ resets 3am', 3, 0],
['5-hour limit reached ∙ resets 12am', 0, 0],
['5-hour limit reached ∙ resets 12pm', 12, 0],
])('parses "%s"', (msg, hour, minute) => {
const det = detectUsageLimitPause(msg, NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBe(nextLocal(hour, minute));
});
it('rolls over to tomorrow when the time already passed today', () => {
// NOW is 2:00pm local; "resets 9am" must be tomorrow 9am
const det = detectUsageLimitPause('5-hour limit reached ∙ resets 9am', NOW);
expect(det!.resetAt).toBe(nextLocal(9, 0));
expect(det!.resetAt).toBeGreaterThan(NOW);
});
});
describe('era 3 forms (v2.0.55+, · separator, IANA timezone, action hints)', () => {
it('parses with timezone and /upgrade suffix', () => {
const det = detectUsageLimitPause(
'5-hour limit reached · resets 3pm (Europe/Stockholm) · /upgrade to Max 20x or turn on /extra-usage',
NOW
);
expect(det).not.toBeNull();
// 3pm in Stockholm (UTC+2 in June) = 13:00 UTC
const d = new Date(det!.resetAt);
expect(d.getUTCHours()).toBe(13);
expect(det!.resetAt).toBeGreaterThan(NOW);
});
it('parses generic "Limit reached" with minutes and timezone', () => {
const det = detectUsageLimitPause(
'Limit reached · resets 11:30am (Asia/Calcutta) · /upgrade to Max or turn on /extra-usage',
NOW
);
expect(det).not.toBeNull();
// 11:30am IST (UTC+5:30) = 06:00 UTC
const d = new Date(det!.resetAt);
expect(d.getUTCHours()).toBe(6);
expect(d.getUTCMinutes()).toBe(0);
});
it('falls back to local time for unresolvable timezone (Etc/Unknown)', () => {
const det = detectUsageLimitPause('Limit reached · resets 5pm (Etc/Unknown)', NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBe(nextLocal(17, 0));
});
it('parses weekly date form "Dec 2, 7pm"', () => {
const det = detectUsageLimitPause('Weekly limit reached · resets Dec 2, 7pm (Europe/Moscow) ·', NOW);
// Dec 2 is >8 days from Jun 10 → implausible horizon → rejected
expect(det).toBeNull();
});
it('parses near-future date form within the horizon', () => {
const det = detectUsageLimitPause('Weekly limit reached ∙ resets Jun 15, 1pm', NOW);
expect(det).not.toBeNull();
const expected = new Date(2026, 5, 15, 13, 0).getTime();
expect(det!.resetAt).toBe(expected);
});
it('parses malformed separator-less render', () => {
const det = detectUsageLimitPause('Limit reached resets 1:30pm (Asia/Calcutta) ·', NOW);
expect(det).not.toBeNull();
});
});
describe('era 4 forms (v2.1.x, "You\'ve hit your limit")', () => {
it.each([
["You've hit your limit · resets 1:40pm (UTC)", 13, 40],
["You've hit your session limit · resets 3:45pm", 15, 45],
["You've hit your Opus limit · resets 3:45pm", 15, 45],
])('parses "%s"', (msg) => {
const det = detectUsageLimitPause(msg, NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBeGreaterThan(NOW);
});
it('parses day-of-week form "resets Mon 12:00am"', () => {
const det = detectUsageLimitPause("You've hit your weekly limit · resets Mon 12:00am", NOW);
expect(det).not.toBeNull();
const d = new Date(det!.resetAt);
expect(d.getDay()).toBe(1); // Monday
expect(d.getHours()).toBe(0);
expect(det!.resetAt).toBeGreaterThan(NOW);
expect(det!.resetAt).toBeLessThanOrEqual(NOW + 8 * 24 * 60 * 60 * 1000);
});
it('parses "May 5 at 9pm" date-with-at form (next-year rollover)', () => {
// NOW is Jun 10 2026 — "May 5" is >2 days past → next year → beyond horizon → null
const det = detectUsageLimitPause("You've hit your limit · resets May 5 at 9pm (UTC)", NOW);
expect(det).toBeNull();
});
it('parses "Jun 12 at 9pm" date-with-at form within horizon', () => {
const det = detectUsageLimitPause("You've hit your limit · resets Jun 12 at 9pm", NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBe(new Date(2026, 5, 12, 21, 0).getTime());
});
it('parses extra-usage form "You\'re out of extra usage"', () => {
const det = detectUsageLimitPause("You're out of extra usage · resets 1pm (UTC)", NOW);
expect(det).not.toBeNull();
const d = new Date(det!.resetAt);
expect(d.getUTCHours()).toBe(13);
});
});
describe('era 1 inline form (v1.0.x)', () => {
it('parses "Your limit will reset at 2pm (America/New_York)"', () => {
const det = detectUsageLimitPause(
'Claude usage limit reached. Your limit will reset at 2pm (America/New_York)',
NOW
);
expect(det).not.toBeNull();
// 2pm EDT (UTC-4 in June) = 18:00 UTC
expect(new Date(det!.resetAt).getUTCHours()).toBe(18);
});
});
describe('raw API epoch form', () => {
it('parses "Claude AI usage limit reached|<epoch>"', () => {
const epoch = Math.floor(NOW / 1000) + 3600; // resets in 1h
const det = detectUsageLimitPause(`Claude AI usage limit reached|${epoch}`, NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBe(epoch * 1000);
});
it('returns past epoch as-is (stale message → caller retries soon)', () => {
const epoch = Math.floor(NOW / 1000) - 600;
const det = detectUsageLimitPause(`Claude AI usage limit reached|${epoch}`, NOW);
expect(det).not.toBeNull();
expect(det!.resetAt).toBeLessThan(NOW);
});
});
describe('transcript and multi-line contexts', () => {
it('detects the ⎿-prefixed transcript echo', () => {
const det = detectUsageLimitPause(
"⎿ You've hit your limit · resets 6pm (Asia/Bangkok)\n /upgrade to increase your usage limit.",
NOW
);
expect(det).not.toBeNull();
});
it('returns the LAST parseable occurrence in a chunk', () => {
const chunk = [
'5-hour limit reached ∙ resets 3pm',
'some scrollback text',
'5-hour limit reached ∙ resets 5pm',
].join('\n');
const det = detectUsageLimitPause(chunk, NOW);
expect(det!.resetAt).toBe(nextLocal(17, 0));
});
});
describe('false-positive guards', () => {
it.each([
'the rate limit reached in tests was 30/min',
'limit reached',
'Usage limit reached', // bare banner: no reset time → not actionable
'we reset at dawn',
'resets 3pm', // time without a limit phrase
'The speed limit reached 120 km/h before it resets',
'5-hour limit reached ∙ resets 25pm', // invalid hour
'limit reached ∙ resets 99:99pm',
])('ignores "%s"', (msg) => {
expect(detectUsageLimitPause(msg, NOW)).toBeNull();
});
it('ignores empty and irrelevant data', () => {
expect(detectUsageLimitPause('', NOW)).toBeNull();
expect(detectUsageLimitPause('compiling project...', NOW)).toBeNull();
});
it('requires the reset time within the window after the phrase', () => {
const farApart = 'limit reached' + ' x'.repeat(200) + ' resets 3pm';
expect(detectUsageLimitPause(farApart, NOW)).toBeNull();
});
});
});