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
+13
View File
@@ -2779,6 +2779,19 @@ export class RespawnController extends EventEmitter {
return;
}
// Usage-limit pause: Claude can't work and the cycle's /clear would wipe
// the paused conversation — the auto-resume scheduler owns recovery here.
if (this.session.isLimitPaused) {
this.log('Skipping respawn cycle - usage-limit pause active (auto-resume armed)');
this.logAction('health', 'Respawn skipped: usage-limit pause (auto-resume armed)');
this.emit('respawnBlocked', {
reason: 'usage_limit',
details: 'Usage limit reached — waiting for scheduled auto-resume',
});
this.setState('watching');
return;
}
// Start the respawn cycle
this.cycleCount++;
this.log(`Starting respawn cycle #${this.cycleCount}`);
+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');
}
}
+131 -5
View File
@@ -78,6 +78,7 @@ import {
buildShellEnv,
} from './session-cli-builder.js';
import { SessionAutoOps } from './session-auto-ops.js';
import { detectUsageLimitPause } from './usage-limit-patterns.js';
import { SessionTaskCache } from './session-task-cache.js';
export type { BackgroundTask } from './task-tracker.js';
@@ -520,6 +521,9 @@ export class Session extends EventEmitter {
this._totalOutputTokens = 0;
this.emit('autoClear', data);
});
this._autoOps.on('limitPauseScheduled', (data) => this.emit('limitPauseScheduled', data));
this._autoOps.on('limitResume', (data) => this.emit('limitResume', data));
this._autoOps.on('limitResumeCancelled', (data) => this.emit('limitResumeCancelled', data));
}
get status(): SessionStatus {
@@ -825,6 +829,39 @@ export class Session extends EventEmitter {
this._autoOps.setAutoCompact(enabled, threshold, prompt);
}
get autoResumeEnabled(): boolean {
return this._autoOps.autoResumeEnabled;
}
/** When the scheduled usage-limit auto-resume fires (epoch ms), or null. */
get autoResumeAt(): number | null {
return this._autoOps.autoResumeAt;
}
/** True while the session is paused on a Claude usage limit (auto-resume armed). */
get isLimitPaused(): boolean {
return this._autoOps.isLimitPaused;
}
setAutoResume(enabled: boolean): void {
this._autoOps.setAutoResume(enabled);
// Users typically enable this WHILE a session already sits paused — the
// limit footer won't reprint on its own, so scan the recent buffer once.
// Only a future reset time counts: stale scrollback must not arm a resume.
if (enabled && !isExternalCliMode(this.mode)) {
const tail = this._terminalBuffer.value.slice(-8192).replace(ANSI_ESCAPE_PATTERN_FULL, '');
const detection = detectUsageLimitPause(tail);
if (detection && detection.resetAt > Date.now()) {
this._autoOps.processCleanData(tail);
}
}
}
/** Restore auto-resume state (and a pending schedule) after Codeman restart. */
restoreAutoResume(enabled: boolean, resumeAt?: number): void {
this._autoOps.restoreAutoResume(enabled, resumeAt);
}
get imageWatcherEnabled(): boolean {
return this._imageWatcherEnabled;
}
@@ -869,6 +906,8 @@ export class Session extends EventEmitter {
autoCompactEnabled: this._autoOps.autoCompactEnabled,
autoCompactThreshold: this._autoOps.autoCompactThreshold,
autoCompactPrompt: this._autoOps.autoCompactPrompt,
autoResumeEnabled: this._autoOps.autoResumeEnabled,
autoResumeAt: this._autoOps.autoResumeAt ?? undefined,
imageWatcherEnabled: this._imageWatcherEnabled,
totalCost: this._totalCost,
inputTokens: this._totalInputTokens,
@@ -1250,6 +1289,7 @@ export class Session extends EventEmitter {
this._isWorking = true;
this._status = 'busy';
this.emit('working');
this._autoOps.notifyWorking();
}
this._awaitingIdleConfirmation = false;
if (this.activityTimeout) clearTimeout(this.activityTimeout);
@@ -1356,6 +1396,11 @@ export class Session extends EventEmitter {
this._bashToolParser.processCleanData(getCleanData());
}
// Usage-limit pause detection (auto-resume on usage limit)
if (this._autoOps.autoResumeEnabled) {
this._autoOps.processCleanData(getCleanData());
}
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
if (rawData.includes('token')) {
this.parseTokensFromStatusLine(getCleanData());
@@ -1384,6 +1429,7 @@ export class Session extends EventEmitter {
this._isWorking = true;
this._status = 'busy';
this.emit('working');
this._autoOps.notifyWorking();
this._awaitingIdleConfirmation = false;
if (this.activityTimeout) clearTimeout(this.activityTimeout);
}
@@ -1675,6 +1721,12 @@ export class Session extends EventEmitter {
this.activityTimeout = null;
}
// Clear pending cross-device resize refresh
if (this._resizeRefreshTimer) {
clearTimeout(this._resizeRefreshTimer);
this._resizeRefreshTimer = null;
}
// Clear line buffer flush timer
if (this._lineBufferFlushTimer) {
clearTimeout(this._lineBufferFlushTimer);
@@ -2096,9 +2148,48 @@ export class Session extends EventEmitter {
*/
private _desktopSizeClaims = new Set<symbol>();
/**
* A desktop sizing claim only blocks small-viewport resizes while the
* desktop is RECENTLY ACTIVE (claim registration or typed input within this
* window). An abandoned-but-connected desktop tab (left open at home, screen
* locked) must not hold a phone's view hostage: without this, the phone
* renders a desktop-width stream in a narrow xterm — mid-word wraps, tmux
* dot-fill, and Ink overdraw soup (the 0.9.8–0.9.12 mobile regression).
*/
private static readonly DESKTOP_CLAIM_IDLE_MS = 90_000;
/** Last evidence of a live desktop user (claim registered / typed input). */
private _lastDesktopActivityAt = 0;
/** Last desktop-typed dimensions, for re-asserting after a mobile override. */
private _lastDesktopDims: { cols: number; rows: number } | null = null;
/** True while a small viewport reflowed the pane past an idle desktop claim. */
private _mobileSizeOverride = false;
/** Debounce for the post-takeover buffer refresh (see _scheduleResizeRefresh) */
private _resizeRefreshTimer: NodeJS.Timeout | null = null;
/**
* After a CROSS-DEVICE resize (phone takes the pane / desktop re-asserts),
* viewing clients still hold the old-width buffer: Ink's redraw lands below
* the stale frames, stacking ghost footers. Tell every client to reload the
* buffer once the post-SIGWINCH redraw has settled. Debounced so a takeover
* followed by an immediate re-assert produces a single refresh.
*/
private _scheduleResizeRefresh(): void {
if (this._resizeRefreshTimer) clearTimeout(this._resizeRefreshTimer);
this._resizeRefreshTimer = setTimeout(() => {
this._resizeRefreshTimer = null;
if (this._isStopped) return;
this.emit('needsRefresh');
}, 700);
}
/** Register a live desktop sizing claim (see _desktopSizeClaims). */
claimDesktopSizing(token: symbol): void {
this._desktopSizeClaims.add(token);
this._lastDesktopActivityAt = Date.now();
}
/** Release a desktop sizing claim when its connection goes away. */
@@ -2106,23 +2197,53 @@ export class Session extends EventEmitter {
this._desktopSizeClaims.delete(token);
}
/**
* Record desktop user activity (typed input over a claim-holding socket).
* If a phone reflowed the pane while the desktop was idle, the desktop
* layout is restored — "whoever is actively using the session wins".
*/
noteDesktopActivity(): void {
this._lastDesktopActivityAt = Date.now();
if (this._mobileSizeOverride && this._lastDesktopDims) {
// resize()'s desktop branch clears _mobileSizeOverride — leaving it set
// here lets resize() recognize the re-assert and refresh the clients.
this.resize(this._lastDesktopDims.cols, this._lastDesktopDims.rows, { viewportType: 'desktop' });
}
}
/**
* Resizes the PTY terminal dimensions.
* Skips the resize if dimensions haven't changed to avoid triggering
* unnecessary Ink full-screen redraws (visible flicker on tab switch).
*
* Arbitration: while a desktop connection holds a sizing claim, resizes from
* small viewports (mobile/tablet) are ignored entirely — shrink AND grow
* would both reflow the desktop view. Without a desktop connected, small
* viewports control the PTY size freely.
* Arbitration: while a desktop connection holds a sizing claim AND has been
* active within DESKTOP_CLAIM_IDLE_MS, resizes from small viewports
* (mobile/tablet) are ignored — shrink AND grow would both reflow the
* desktop view. Once the desktop goes idle, a phone may take the pane (the
* desktop re-asserts its size on its next typed input via
* noteDesktopActivity). Without a desktop connected, small viewports
* control the PTY size freely.
*
* @param cols - Number of columns (width in characters)
* @param rows - Number of rows (height in lines)
*/
resize(cols: number, rows: number, options: { viewportType?: ResizeViewportType } = {}): void {
const isSmallViewport = options.viewportType === 'mobile' || options.viewportType === 'tablet';
// Cross-device transitions (detected before the flags are updated below):
// a desktop resize arriving while a mobile override is active = re-assert.
const reasserting = options.viewportType === 'desktop' && this._mobileSizeOverride;
let tookOver = false;
if (options.viewportType === 'desktop') {
this._lastDesktopDims = { cols, rows };
this._lastDesktopActivityAt = Date.now();
this._mobileSizeOverride = false;
}
if (isSmallViewport && this._desktopSizeClaims.size > 0) {
return;
if (Date.now() - this._lastDesktopActivityAt < Session.DESKTOP_CLAIM_IDLE_MS) {
return;
}
tookOver = !this._mobileSizeOverride;
this._mobileSizeOverride = true;
}
if (this.ptyProcess && (cols !== this._ptyCols || rows !== this._ptyRows)) {
this._ptyCols = cols;
@@ -2131,6 +2252,11 @@ export class Session extends EventEmitter {
this._mux.resizeWindow?.(this._muxSession.muxName, cols, rows);
}
this.ptyProcess.resize(cols, rows);
// Cross-device reflow: all clients reload the buffer so stale-width
// frames don't stack above the fresh Ink redraw (ghost footers).
if (tookOver || reasserting) {
this._scheduleResizeRefresh();
}
}
}
+4
View File
@@ -133,6 +133,10 @@ export interface SessionState {
autoCompactThreshold?: number;
/** Auto-compact prompt */
autoCompactPrompt?: string;
/** Auto-resume on usage limit enabled */
autoResumeEnabled?: boolean;
/** Pending usage-limit auto-resume fire time (epoch ms), if armed */
autoResumeAt?: number;
/** Image watcher enabled for this session */
imageWatcherEnabled?: boolean;
/** Total cost in USD */
+210
View File
@@ -0,0 +1,210 @@
/**
* @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;
}
+68 -2
View File
@@ -153,6 +153,9 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.SESSION_IDLE, '_onSessionIdle'],
[SSE_EVENTS.SESSION_WORKING, '_onSessionWorking'],
[SSE_EVENTS.SESSION_AUTO_CLEAR, '_onSessionAutoClear'],
[SSE_EVENTS.SESSION_LIMIT_PAUSE_SCHEDULED, '_onSessionLimitPauseScheduled'],
[SSE_EVENTS.SESSION_LIMIT_RESUME, '_onSessionLimitResume'],
[SSE_EVENTS.SESSION_LIMIT_RESUME_CANCELLED, '_onSessionLimitResumeCancelled'],
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
// Scheduled runs
@@ -576,7 +579,6 @@ class CodemanApp {
// Apply keyboard bar mode from settings
const _kbSettings = this.loadAppSettingsFromStorage();
if (_kbSettings.extendedKeyboardBar) KeyboardAccessoryBar.setMode('extended');
this.bindMobileHeaderUtilityToggle?.();
this.applyHeaderVisibilitySettings();
this.applyTabWrapSettings();
this.applyMonitorVisibility();
@@ -1768,6 +1770,33 @@ class CodemanApp {
this._notifySession(data.sessionId, 'info', 'auto-clear', 'Auto-Cleared', `Context reset at ${(data.tokens || 0).toLocaleString()} tokens`);
}
_onSessionLimitPauseScheduled(data) {
const session = this.sessions.get(data.sessionId);
if (session) session.autoResumeAt = data.resumeAt;
const at = new Date(data.resumeAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (data.sessionId === this.activeSessionId) {
this.showToast(`Usage limit reached — auto-resume at ${at}`, 'warning');
}
this._notifySession(data.sessionId, 'warning', 'limit-pause', 'Usage Limit Reached', `Auto-resume scheduled for ${at}`);
this.updateAutoResumeStatus(data.sessionId);
}
_onSessionLimitResume(data) {
const session = this.sessions.get(data.sessionId);
if (session) session.autoResumeAt = undefined;
if (data.sessionId === this.activeSessionId) {
this.showToast('Usage limit reset — work resumed automatically', 'success');
}
this._notifySession(data.sessionId, 'info', 'limit-resume', 'Auto-Resumed', 'Usage limit reset — continuing work');
this.updateAutoResumeStatus(data.sessionId);
}
_onSessionLimitResumeCancelled(data) {
const session = this.sessions.get(data.sessionId);
if (session) session.autoResumeAt = undefined;
this.updateAutoResumeStatus(data.sessionId);
}
_onSessionCliInfo(data) {
const session = this.sessions.get(data.sessionId);
if (session) {
@@ -1843,6 +1872,7 @@ class CodemanApp {
// selectSession's earlier resizes ran before this WS existed, so they
// went over HTTP, which never claims (see ws-routes sizingToken).
this.sendResize(sessionId)?.catch?.(() => {});
this._startMobileResizeRetry(sessionId);
}
};
@@ -1868,6 +1898,7 @@ class CodemanApp {
this._ws = null;
this._wsSessionId = null;
this._wsReady = false;
this._stopMobileResizeRetry();
// Reconnect on unexpected close (server restart, network blip, ping timeout).
// Don't reconnect if we intentionally disconnected (_disconnectWs nulls onclose)
@@ -1893,6 +1924,7 @@ class CodemanApp {
_disconnectWs() {
this._clearTimer('_wsReconnectTimer');
this._wsReconnectAttempts = 0;
this._stopMobileResizeRetry();
if (this._ws) {
this._ws.onclose = null; // Prevent re-entrant cleanup
this._ws.close();
@@ -1902,6 +1934,41 @@ class CodemanApp {
}
}
/**
* Small-viewport claim-idle retry. While a desktop sizing claim is "hot",
* the server ignores this device's resize (Session.DESKTOP_CLAIM_IDLE_MS),
* and the single resize sent on attach is deduped client-side — without a
* retry, a phone that attached under an active desktop would render a
* desktop-width stream forever. Re-send the current dims periodically (a
* server-side no-op once the pane already matches) so the pane reflows to
* this device shortly after the desktop goes idle. Visible-tab only: a
* phone in a pocket must not steal the pane from an active desktop.
*/
_startMobileResizeRetry(sessionId) {
this._stopMobileResizeRetry();
const type =
typeof MobileDetection !== 'undefined' && MobileDetection.getDeviceType
? MobileDetection.getDeviceType()
: 'desktop';
if (type === 'desktop') return;
this._mobileResizeRetryTimer = setInterval(() => {
if (document.visibilityState !== 'visible') return;
if (!this._wsReady || this._wsSessionId !== sessionId) return;
// Same guard as throttledResize: while the virtual keyboard is up, a
// fit()+SIGWINCH at the shrunken row count makes Ink re-render garbage
// and shifts the accessory toolbar mid-typing. Retry after it closes.
if (typeof KeyboardHandler !== 'undefined' && KeyboardHandler.keyboardVisible) return;
this.sendResize(sessionId)?.catch?.(() => {});
}, MOBILE_RESIZE_RETRY_MS);
}
_stopMobileResizeRetry() {
if (this._mobileResizeRetryTimer) {
clearInterval(this._mobileResizeRetryTimer);
this._mobileResizeRetryTimer = null;
}
}
/**
* Send input to server without blocking the keystroke flush cycle.
* Uses a sequential promise chain to preserve character ordering
@@ -2093,7 +2160,6 @@ class CodemanApp {
KeyboardHandler.cleanup();
MobileDetection.init();
KeyboardHandler.init();
this.bindMobileHeaderUtilityToggle?.();
// Clear tab alerts
this.tabAlerts.clear();
// Clear shown completions (used for duplicate notification prevention)
+4
View File
@@ -51,6 +51,7 @@ const GROUPING_TIMEOUT_MS = 5000; // 5 seconds - notification grouping
const NOTIFICATION_LIST_CAP = 100; // Max notifications in list
const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate
const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications
const MOBILE_RESIZE_RETRY_MS = 30000; // Small-viewport resize re-send while a desktop sizing claim is hot
const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications
const THROTTLE_DELAY_MS = 100; // General UI throttle delay
const TERMINAL_CHUNK_SIZE = 32 * 1024; // 32KB chunks for terminal buffer loading
@@ -243,6 +244,9 @@ const SSE_EVENTS = {
SESSION_WORKING: 'session:working',
SESSION_AUTO_CLEAR: 'session:autoClear',
SESSION_AUTO_COMPACT: 'session:autoCompact',
SESSION_LIMIT_PAUSE_SCHEDULED: 'session:limitPauseScheduled',
SESSION_LIMIT_RESUME: 'session:limitResume',
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
SESSION_CLI_INFO: 'session:cliInfo',
SESSION_MESSAGE: 'session:message',
SESSION_INTERACTIVE: 'session:interactive',
+17 -17
View File
@@ -69,10 +69,6 @@
<span class="logo" onclick="app.goHome()" title="Go to main page">Codeman</span>
</div>
<button class="mobile-header-utility-toggle" id="mobileHeaderUtilityToggle" type="button" aria-label="Toggle header utilities" aria-controls="headerRight" aria-expanded="false" title="Header utilities">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>
</button>
<!-- Session Tabs -->
<div class="session-tabs" id="sessionTabs" role="tablist" aria-label="Session tabs">
</div>
@@ -619,6 +615,15 @@
</div>
</div>
<div class="auto-resume-box">
<label class="checkbox-inline">
<input type="checkbox" id="modalAutoResumeEnabled" onchange="app.autoSaveAutoResume()">
<span>Auto-resume when usage limit resets</span>
</label>
<span class="auto-resume-status" id="autoResumeStatus"></span>
<span class="form-hint">If Claude pauses on a usage limit ("limit reached &middot; resets 3pm"), Codeman waits for the reset time and automatically continues the work. Independent of the respawn loop below.</span>
</div>
<div class="form-row">
<label>Duration</label>
<div class="duration-presets">
@@ -658,7 +663,7 @@
<div class="form-section-header">Respawn Cycle</div>
<div class="form-row">
<label>1. Update Prompt</label>
<textarea id="modalRespawnPrompt" rows="3" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 60px;">update all the docs and CLAUDE.md</textarea>
<textarea id="modalRespawnPrompt" rows="1" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;">update all the docs and CLAUDE.md</textarea>
</div>
<div class="respawn-options-row" style="margin: 8px 0;">
@@ -670,22 +675,17 @@
<input type="checkbox" id="modalRespawnSendInit" checked onchange="app.autoSaveRespawnConfig()">
<span>3. Send /init</span>
</label>
</div>
<div class="form-row">
<label>4. Kickstart Prompt</label>
<textarea id="modalRespawnKickstart" rows="2" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 40px;"></textarea>
<span class="form-hint">Sent only when /init completes but Claude stays idle</span>
</div>
<div class="form-section-header">Behavior</div>
<div class="respawn-options-row">
<label class="checkbox-inline">
<label class="checkbox-inline" title="Presses Enter for plan approvals and default question options">
<input type="checkbox" id="modalRespawnAutoAccept" checked onchange="app.autoSaveRespawnConfig()">
<span>Auto-accept prompts</span>
</label>
</div>
<span class="form-hint">Auto-accept presses Enter for plan approvals and default question options</span>
<div class="form-row">
<label>4. Kickstart Prompt</label>
<textarea id="modalRespawnKickstart" rows="1" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()" style="resize: vertical; min-height: 30px;"></textarea>
<span class="form-hint">Sent only when /init completes but Claude stays idle &middot; Auto-accept presses Enter for plan approvals and default options</span>
</div>
</div>
</div><!-- End respawn-tab -->
+10 -2
View File
@@ -304,8 +304,16 @@ const KeyboardHandler = {
// translate up so it sits at the bottom of the visual viewport.
// This formula accounts for iOS scrolling the visual viewport (offsetTop)
// when the user types in xterm's hidden textarea.
const appEl = document.querySelector('.app');
const layoutHeight = appEl?.getBoundingClientRect().bottom || window.innerHeight;
//
// MUST measure against the LAYOUT viewport (window.innerHeight): the
// bars are position:fixed, which anchors to the layout viewport — on
// iOS that keeps its full height while the keyboard is open. Measuring
// the shrunken .app instead (its height tracks --app-height = visual
// viewport) made the offset compute to 0 on iOS, leaving the toolbar
// and accessory bar behind the OS keyboard (0.9.8 regression). On
// Android the layout viewport itself shrinks with the keyboard, so
// innerHeight === visualBottom and the offset is naturally 0 there.
const layoutHeight = window.innerHeight;
const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height;
const keyboardOffset = Math.max(0, layoutHeight - visualBottom);
-43
View File
@@ -94,30 +94,6 @@ html.mobile-init .file-browser-panel {
gap: 0.25rem;
}
.mobile-header-utility-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
margin: -4px 0.2rem -4px 0;
background: transparent;
border: none;
border-radius: 6px;
color: var(--text-muted);
order: -1;
position: relative;
z-index: 2;
flex-shrink: 0;
}
.mobile-header-utility-toggle.active,
.mobile-header-utility-toggle:active {
background: rgba(255, 255, 255, 0.08);
color: var(--text);
}
.header-right {
position: fixed;
top: calc(52px + var(--safe-area-top));
@@ -474,25 +450,6 @@ html.mobile-init .file-browser-panel {
display: none;
}
.mobile-header-utility-toggle {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
padding: 0;
margin: -6px 0.15rem -6px 0;
background: transparent;
border: none;
border-radius: 5px;
color: var(--text-muted);
order: -1;
position: relative;
z-index: 2;
flex-shrink: 0;
border-left: none;
}
/* Smaller header buttons on mobile */
.btn-icon-header {
width: 26px;
+37
View File
@@ -690,6 +690,10 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('modalAutoCompactPrompt').value = session.autoCompactPrompt ?? '';
document.getElementById('modalAutoClearEnabled').checked = session.autoClearEnabled ?? false;
document.getElementById('modalAutoClearThreshold').value = session.autoClearThreshold ?? 140000;
// Populate auto-resume on usage limit (token pause control)
document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false;
this.updateAutoResumeStatus(sessionId);
document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true;
document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false;
@@ -790,6 +794,39 @@ Object.assign(CodemanApp.prototype, {
} catch { /* silent */ }
},
async autoSaveAutoResume() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalAutoResumeEnabled').checked;
try {
await this._apiPost(`/api/sessions/${this.editingSessionId}/auto-resume`, { enabled });
const session = this.sessions.get(this.editingSessionId);
if (session) {
session.autoResumeEnabled = enabled;
if (!enabled) session.autoResumeAt = undefined;
}
this.updateAutoResumeStatus(this.editingSessionId);
this.showToast(`Auto-resume on usage limit ${enabled ? 'enabled' : 'disabled'}`, 'success');
} catch (err) {
this.showToast('Failed to toggle auto-resume: ' + err.message, 'error');
}
},
// Show "resumes at HH:MM" in the session options modal while a usage-limit
// pause is armed for the session being edited
updateAutoResumeStatus(sessionId) {
const el = document.getElementById('autoResumeStatus');
if (!el || this.editingSessionId !== sessionId) return;
const session = this.sessions.get(sessionId);
if (session?.autoResumeAt && session.autoResumeAt > Date.now()) {
const at = new Date(session.autoResumeAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
el.textContent = `Usage limit pause active — resumes at ${at}`;
el.classList.add('active');
} else {
el.textContent = '';
el.classList.remove('active');
}
},
async toggleSessionImageWatcher() {
if (!this.editingSessionId) return;
const enabled = document.getElementById('modalImageWatcherEnabled').checked;
-64
View File
@@ -1711,70 +1711,6 @@ Object.assign(CodemanApp.prototype, {
}
},
toggleMobileHeaderUtilities() {
const tray = document.getElementById('headerRight');
const toggle = document.getElementById('mobileHeaderUtilityToggle');
if (!tray) return;
const expanded = tray.classList.toggle('mobile-collapsed') === false;
if (toggle) {
toggle.classList.toggle('active', expanded);
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
}
},
handleMobileHeaderUtilityToggle(event) {
if (event) {
event.preventDefault?.();
event.stopPropagation?.();
const now = Date.now();
if ((event.type === 'click' || event.type === 'touchend') && this._lastMobileHeaderUtilityPointerAt) {
if (now - this._lastMobileHeaderUtilityPointerAt < 500) return;
}
if (event.type === 'click' && this._lastMobileHeaderUtilityTouchAt) {
if (now - this._lastMobileHeaderUtilityTouchAt < 500) return;
}
if (event.type === 'touchend') {
this._lastMobileHeaderUtilityTouchAt = now;
}
if (event.type === 'pointerup') {
this._lastMobileHeaderUtilityPointerAt = now;
}
}
this.toggleMobileHeaderUtilities();
},
bindMobileHeaderUtilityToggle() {
const toggle = document.getElementById('mobileHeaderUtilityToggle');
if (!toggle || this._mobileHeaderUtilityToggleEl === toggle) return;
if (this._mobileHeaderUtilityToggleEl && this._mobileHeaderUtilityToggleHandler) {
this._mobileHeaderUtilityToggleEl.removeEventListener('click', this._mobileHeaderUtilityToggleHandler);
this._mobileHeaderUtilityToggleEl.removeEventListener('touchend', this._mobileHeaderUtilityToggleHandler);
this._mobileHeaderUtilityToggleEl.removeEventListener('pointerup', this._mobileHeaderUtilityToggleHandler);
}
this._mobileHeaderUtilityToggleEl = toggle;
this._mobileHeaderUtilityToggleHandler = (event) => this.handleMobileHeaderUtilityToggle(event);
toggle.addEventListener('click', this._mobileHeaderUtilityToggleHandler);
toggle.addEventListener('touchend', this._mobileHeaderUtilityToggleHandler, { passive: false });
toggle.addEventListener('pointerup', this._mobileHeaderUtilityToggleHandler);
},
closeMobileHeaderUtilities() {
const tray = document.getElementById('headerRight');
const toggle = document.getElementById('mobileHeaderUtilityToggle');
if (!tray || tray.classList.contains('mobile-collapsed')) return;
tray.classList.add('mobile-collapsed');
if (toggle) {
toggle.classList.remove('active');
toggle.setAttribute('aria-expanded', 'false');
}
},
applyTabWrapSettings() {
const settings = this.loadAppSettingsFromStorage();
const defaults = this.getDefaultSettings();
+33 -6
View File
@@ -190,12 +190,6 @@ body {
filter: brightness(1.1);
}
/* Mobile-only header utility toggle — hidden by default; the mobile/tablet
media queries in mobile.css (max-width: 768px) re-enable it as flex. */
.mobile-header-utility-toggle {
display: none;
}
/* Session Tabs */
.session-tabs {
display: flex;
@@ -4676,6 +4670,39 @@ body.solo-mode .btn-lifecycle-log {
margin-bottom: 0.25rem;
}
/* Auto-resume on usage limit (token pause control) */
.auto-resume-box {
display: flex;
flex-direction: column;
gap: 0.3rem;
margin: 0.75rem 0;
padding: 0.6rem 0.75rem;
background: rgba(59, 130, 246, 0.07);
border: 1px solid rgba(59, 130, 246, 0.35);
border-radius: 6px;
}
.auto-resume-box .checkbox-inline {
font-size: 0.85rem;
font-weight: 500;
color: var(--text);
}
.auto-resume-box .form-hint {
margin-top: 0;
}
.auto-resume-status {
display: none;
font-size: 0.7rem;
font-weight: 600;
color: var(--accent-hover);
}
.auto-resume-status.active {
display: block;
}
/* Respawn actions in modal */
.respawn-actions {
display: flex;
+22
View File
@@ -29,6 +29,7 @@ import {
ResizeSchema,
AutoClearSchema,
AutoCompactSchema,
AutoResumeSchema,
ImageWatcherSchema,
FlickerFilterSchema,
QuickRunSchema,
@@ -1003,6 +1004,27 @@ export function registerSessionRoutes(
};
});
// ========== Auto-Resume (usage-limit pause) ==========
app.post('/api/sessions/:id/auto-resume', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(AutoResumeSchema, req.body, 'Invalid request body');
const session = findSessionOrFail(ctx, id);
session.setAutoResume(body.enabled);
persistAndBroadcastSession(ctx, session);
return {
success: true,
data: {
autoResume: {
enabled: session.autoResumeEnabled,
resumeAt: session.autoResumeAt ?? undefined,
},
},
};
});
// ========== Image Watcher ==========
app.post('/api/sessions/:id/image-watcher', async (req) => {
+6
View File
@@ -114,6 +114,7 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
// can ignore small-viewport resizes only while a desktop is actually
// connected (see Session._desktopSizeClaims).
const sizingToken = Symbol('ws-desktop-sizing');
let holdsDesktopClaim = false;
// Attach message handler synchronously BEFORE any async work
// (@fastify/websocket requirement to avoid dropped messages).
@@ -122,6 +123,9 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
const msg = JSON.parse(String(raw));
if (msg.t === 'i' && typeof msg.d === 'string') {
if (msg.d.length > MAX_INPUT_LENGTH) return;
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
session.write(msg.d);
} else if (
msg.t === 'z' &&
@@ -135,10 +139,12 @@ export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHost
const viewportType = msg.v === 'mobile' || msg.v === 'tablet' || msg.v === 'desktop' ? msg.v : undefined;
if (viewportType === 'desktop') {
session.claimDesktopSizing(sizingToken);
holdsDesktopClaim = true;
} else if (viewportType) {
// The connection's viewport can change (e.g. browser window
// narrowed past the tablet breakpoint) — drop a stale claim.
session.releaseDesktopSizing(sizingToken);
holdsDesktopClaim = false;
}
if (viewportType) {
session.resize(msg.c, msg.r, { viewportType });
+5
View File
@@ -450,6 +450,11 @@ export const AutoCompactSchema = z.object({
prompt: z.string().max(10000).optional(),
});
/** POST /api/sessions/:id/auto-resume */
export const AutoResumeSchema = z.object({
enabled: z.boolean(),
});
/** POST /api/sessions/:id/image-watcher */
export const ImageWatcherSchema = z.object({
enabled: z.boolean(),
+6
View File
@@ -1993,6 +1993,12 @@ export class WebServer extends EventEmitter {
if (savedState.autoClearEnabled !== undefined || savedState.autoClearThreshold !== undefined) {
session.setAutoClear(savedState.autoClearEnabled ?? false, savedState.autoClearThreshold);
}
// Auto-resume on usage limit (re-arms a pending schedule; an
// overdue one fires shortly after boot — the limit footer won't
// reprint on its own, so the pause would otherwise stall)
if (savedState.autoResumeEnabled) {
session.restoreAutoResume(true, savedState.autoResumeAt);
}
// Token tracking
if (
savedState.inputTokens !== undefined ||
+31
View File
@@ -45,6 +45,9 @@ export interface SessionListenerRefs {
taskFailed: (task: BackgroundTask, error: string) => void;
autoClear: (data: { tokens: number; threshold: number }) => void;
autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void;
limitPauseScheduled: (data: { resetAt: number; resumeAt: number; matched: string }) => void;
limitResume: (data: { attempt: number }) => void;
limitResumeCancelled: (data: { reason: string }) => void;
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
ralphLoopUpdate: (state: RalphTrackerState) => void;
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
@@ -243,6 +246,28 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold);
},
/** Broadcasts `session:limitPauseScheduled` — usage-limit pause detected, auto-resume armed.
* Persisted so a pending schedule survives a Codeman restart. */
limitPauseScheduled: (data: { resetAt: number; resumeAt: number; matched: string }) => {
deps.broadcast(SseEvent.SessionLimitPauseScheduled, { sessionId: session.id, ...data });
deps.broadcastSessionStateDebounced(session.id);
deps.persistSessionState(session);
},
/** Broadcasts `session:limitResume` — auto-resume prompt sent after limit reset */
limitResume: (data: { attempt: number }) => {
deps.broadcast(SseEvent.SessionLimitResume, { sessionId: session.id, ...data });
deps.broadcastSessionStateDebounced(session.id);
deps.persistSessionState(session);
},
/** Broadcasts `session:limitResumeCancelled` — pending auto-resume no longer needed */
limitResumeCancelled: (data: { reason: string }) => {
deps.broadcast(SseEvent.SessionLimitResumeCancelled, { sessionId: session.id, ...data });
deps.broadcastSessionStateDebounced(session.id);
deps.persistSessionState(session);
},
// ─── CLI Info ────────────────────────────────────────────
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
@@ -350,6 +375,9 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('taskFailed', refs.taskFailed);
session.on('autoClear', refs.autoClear);
session.on('autoCompact', refs.autoCompact);
session.on('limitPauseScheduled', refs.limitPauseScheduled);
session.on('limitResume', refs.limitResume);
session.on('limitResumeCancelled', refs.limitResumeCancelled);
session.on('cliInfoUpdated', refs.cliInfoUpdated);
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
@@ -379,6 +407,9 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('taskFailed', refs.taskFailed);
session.off('autoClear', refs.autoClear);
session.off('autoCompact', refs.autoCompact);
session.off('limitPauseScheduled', refs.limitPauseScheduled);
session.off('limitResume', refs.limitResume);
session.off('limitResumeCancelled', refs.limitResumeCancelled);
session.off('cliInfoUpdated', refs.cliInfoUpdated);
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
+9
View File
@@ -73,6 +73,12 @@ export const SessionWorking = 'session:working' as const;
export const SessionAutoClear = 'session:autoClear' as const;
/** Auto-compact triggered for the session. */
export const SessionAutoCompact = 'session:autoCompact' as const;
/** Usage-limit pause detected; auto-resume scheduled. */
export const SessionLimitPauseScheduled = 'session:limitPauseScheduled' as const;
/** Auto-resume prompt sent after a usage-limit reset. */
export const SessionLimitResume = 'session:limitResume' as const;
/** Pending usage-limit auto-resume cancelled (session resumed or feature disabled). */
export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as const;
/** CLI version/model info detected from session output. */
export const SessionCliInfo = 'session:cliInfo' as const;
/** General session message (e.g. status text). */
@@ -361,6 +367,9 @@ export const SseEvent = {
SessionWorking,
SessionAutoClear,
SessionAutoCompact,
SessionLimitPauseScheduled,
SessionLimitResume,
SessionLimitResumeCancelled,
SessionCliInfo,
SessionMessage,
SessionInteractive,