mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-08 00:19:42 +02:00
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:
+184
-1
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user