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
+184 -1
View File
@@ -1,15 +1,25 @@
/**
* @fileoverview Auto-compact and auto-clear automation for Session.
* @fileoverview Auto-compact, auto-clear, and auto-resume automation for Session.
*
* Monitors token counts and triggers /compact or /clear commands when
* configurable thresholds are reached. Waits for Claude to be idle
* before sending commands, with retry logic and mutual exclusion
* (compact and clear never run simultaneously).
*
* Also implements auto-resume on usage limit ("token pause" control):
* when enabled and Claude stops on a usage-limit message ("5-hour limit
* reached ∙ resets 8pm" and friends — see usage-limit-patterns.ts), a timer
* is armed for the parsed reset time plus a safety buffer, then Escape
* (dismisses the rate-limit options dialog if open) and a "continue" prompt
* are sent so work resumes automatically. If the session is still limited,
* the fresh limit message re-arms the scheduler — that retry loop is the
* safety net for clock skew and parse imprecision.
*
* @module session-auto-ops
*/
import { EventEmitter } from 'node:events';
import { detectUsageLimitPause } from './usage-limit-patterns.js';
// ============================================================================
// Timing Constants
@@ -78,6 +88,28 @@ async function executeWhenIdle(
}
}
// ============================================================================
// Auto-resume (usage-limit pause) constants
// ============================================================================
/** Safety buffer after the stated reset time before resuming (2 minutes) */
const RESUME_BUFFER_MS = 2 * 60_000;
/** Minimum delay before an overdue resume fires (lets output settle) */
const RESUME_MIN_DELAY_MS = 5_000;
/** Retry interval when the reset time is stale/past (5 minutes) */
const RESUME_RETRY_MS = 5 * 60_000;
/** Re-detections scheduling within this window of the current schedule are ignored */
const RESUME_DEDUP_TOLERANCE_MS = 90_000;
/** Delay between Escape (dialog dismiss) and the resume prompt */
const RESUME_ESC_DELAY_MS = 600;
/** Prompt sent to resume work after the limit resets */
const RESUME_PROMPT = 'continue';
/** Minimum valid threshold for auto-clear/compact (1000 tokens) */
const MIN_AUTO_THRESHOLD = 1000;
@@ -131,6 +163,16 @@ export class SessionAutoOps extends EventEmitter {
private _isClearing: boolean = false;
private _autoClearTimer: NodeJS.Timeout | null = null;
// Auto-resume (usage-limit pause) state
private _autoResumeEnabled: boolean = false;
private _autoResumeTimer: NodeJS.Timeout | null = null;
/** Esc→continue gap timer; detections must NOT cancel a resume in flight */
private _resumeFollowupTimer: NodeJS.Timeout | null = null;
/** When the scheduled resume fires (epoch ms), null when not armed */
private _autoResumeAt: number | null = null;
private _limitPaused: boolean = false;
private _resumeAttempts: number = 0;
private readonly callbacks: AutoOpsCallbacks;
constructor(callbacks: AutoOpsCallbacks, config?: { compactThreshold?: number; clearThreshold?: number }) {
@@ -207,6 +249,145 @@ export class SessionAutoOps extends EventEmitter {
}
}
// ============================================================================
// Auto-resume (usage-limit pause) — getters/setters
// ============================================================================
get autoResumeEnabled(): boolean {
return this._autoResumeEnabled;
}
/** When the scheduled resume fires (epoch ms), or null when not armed. */
get autoResumeAt(): number | null {
return this._autoResumeAt;
}
/** True while the session is believed to be paused on a usage limit. */
get isLimitPaused(): boolean {
return this._limitPaused;
}
setAutoResume(enabled: boolean): void {
this._autoResumeEnabled = enabled;
if (!enabled) {
this._cancelAutoResume('disabled');
}
}
/**
* Restore auto-resume state after a Codeman restart. A persisted pending
* schedule is re-armed; an overdue one fires shortly after boot (the limit
* footer won't reprint on its own, so without this the pause would stall).
*/
restoreAutoResume(enabled: boolean, resumeAt?: number): void {
this._autoResumeEnabled = enabled;
if (!enabled || !resumeAt) return;
const now = Date.now();
this._scheduleResume(Math.max(resumeAt, now + RESUME_MIN_DELAY_MS), resumeAt, 'restored');
}
// ============================================================================
// Auto-resume — detection and scheduling
// ============================================================================
/**
* Scan cleaned terminal output for a usage-limit pause message and (re)arm
* the resume schedule. Called from the session's throttled parser path.
*/
processCleanData(cleanData: string): void {
if (!this._autoResumeEnabled || this.callbacks.isStopped()) return;
// A resume is in flight (Esc sent, continue pending): output from our own
// Escape can redraw the stale limit footer — don't let it re-arm and
// cancel the continue. Fresh evidence arrives after the prompt is sent.
if (this._resumeFollowupTimer) return;
const detection = detectUsageLimitPause(cleanData);
if (!detection) return;
const now = Date.now();
const overdue = detection.resetAt <= now;
const fireAt = overdue
? now + RESUME_RETRY_MS // stale reset time → gentle retry loop
: Math.max(detection.resetAt + RESUME_BUFFER_MS, now + RESUME_MIN_DELAY_MS);
if (this._autoResumeTimer && this._autoResumeAt !== null) {
// Already armed: the footer redraws constantly, so ignore re-detections
// that land on (or later than) the current schedule. Only an EARLIER
// parsed time replaces it — an overdue retry never preempts a real one.
if (overdue || fireAt >= this._autoResumeAt - RESUME_DEDUP_TOLERANCE_MS) return;
}
this._scheduleResume(fireAt, detection.resetAt, detection.matched);
}
/**
* Claude started working — the limit is lifted (or the user resumed
* manually), so any pending auto-resume is obsolete.
*/
notifyWorking(): void {
this._resumeAttempts = 0;
if (!this._limitPaused && !this._autoResumeTimer && !this._resumeFollowupTimer) return;
this._cancelAutoResume('working');
}
private _scheduleResume(fireAt: number, resetAt: number, matched: string): void {
if (this._autoResumeTimer) {
clearTimeout(this._autoResumeTimer);
this._autoResumeTimer = null;
}
this._limitPaused = true;
this._autoResumeAt = fireAt;
const delay = Math.max(fireAt - Date.now(), 0);
console.log(
`[SessionAutoOps ${this.callbacks.getSessionId()}] Usage-limit pause detected ("${matched.slice(0, 60)}"), auto-resume in ${Math.round(delay / 60000)}min`
);
this._autoResumeTimer = setTimeout(() => void this._fireResume(), delay);
this.emit('limitPauseScheduled', { resetAt, resumeAt: fireAt, matched });
}
private async _fireResume(): Promise<void> {
this._autoResumeTimer = null;
if (!this._autoResumeEnabled || this.callbacks.isStopped()) return;
if (this.callbacks.isWorking()) {
// Session resumed on its own (or via the user) — nothing to do.
this._cancelAutoResume('working');
return;
}
this._resumeAttempts++;
const attempt = this._resumeAttempts;
this._limitPaused = false; // optimistic: a fresh limit message re-arms us
this._autoResumeAt = null;
// Escape first: dismisses the rate-limit options dialog if Claude opened
// one (harmless at an idle prompt), then the resume prompt after a beat.
await this.callbacks.writeCommand('\x1b');
this._resumeFollowupTimer = setTimeout(() => {
this._resumeFollowupTimer = null;
if (this.callbacks.isStopped()) return;
void this.callbacks.writeCommand(`${RESUME_PROMPT}\r`);
this.emit('limitResume', { attempt });
}, RESUME_ESC_DELAY_MS);
}
private _cancelAutoResume(reason: 'disabled' | 'working' | 'stopped'): void {
const wasArmed = this._autoResumeTimer !== null || this._resumeFollowupTimer !== null || this._limitPaused;
if (this._autoResumeTimer) {
clearTimeout(this._autoResumeTimer);
this._autoResumeTimer = null;
}
if (this._resumeFollowupTimer) {
clearTimeout(this._resumeFollowupTimer);
this._resumeFollowupTimer = null;
}
this._limitPaused = false;
this._autoResumeAt = null;
if (wasArmed && reason !== 'stopped') {
this.emit('limitResumeCancelled', { reason });
}
}
// ============================================================================
// Threshold checks
// ============================================================================
@@ -321,5 +502,7 @@ export class SessionAutoOps extends EventEmitter {
this._autoClearTimer = null;
}
this._isClearing = false;
this._cancelAutoResume('stopped');
}
}