diff --git a/CLAUDE.md b/CLAUDE.md index 4370d88e..36c5bdb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,6 +153,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Idle detection**: Multi-layer (completion message → AI check → output silence → token stability). See `docs/respawn-state-machine.md`. +**Auto-resume on usage limit** ("token pause" control, opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit ("5-hour limit reached ∙ resets 8pm" and all 1.0.x–2.1.x variants), `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time from cleaned output; `SessionAutoOps` arms a timer for reset+2min, then sends Esc (dismisses the rate-limit dialog) + `continue`. Still-limited responses re-arm the loop (5-min retry on stale times); a `working` transition cancels it. Claude-mode only (detection rides `_processExpensiveParsers`). Persists/recovers via `SessionState.autoResumeEnabled`/`autoResumeAt`; respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected` — prevents `/clear` from wiping the paused conversation). Endpoint: `POST /api/sessions/:id/auto-resume`; SSE: `session:limitPauseScheduled`/`limitResume`/`limitResumeCancelled`. Tests: `test/usage-limit-patterns.test.ts`, `test/session-auto-resume.test.ts`. + **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. **External CLI modes (OpenCode, Codex)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior — Ralph tracker, BashToolParser, token/CLI-info parsing, and ❯-prompt readiness detection are all skipped (these CLIs render their own TUIs; readiness = output stabilization instead). Both modes **require tmux — no direct PTY fallback** — because secrets are injected via `tmux setenv`, never on the spawn command line: OpenCode gets `OPENCODE_CONFIG_CONTENT` etc., Codex gets `OPENAI_API_KEY`/`CODEX_API_KEY`/`CODEX_HOME` (`setCodexEnvVars` in `tmux-manager.ts`). Codex specifics: command built by `buildCodexCommand()` (`--model`, `resume `, `--dangerously-bypass-approvals-and-sandbox` from the `codexConfig` payload / `codexDangerouslyBypassApprovals` app setting; `renderMode` is schema-coerced to `'hybrid'`, the only supported mode); tmux exports `COLORTERM=truecolor` + unsets `NO_COLOR` (other modes unset `COLORTERM`); availability via `GET /api/codex/status` — session/quick-start routes fail with `OPERATION_FAILED` and an install hint (`npm install -g @openai/codex`) when the binary is missing. Frontend: run-mode dropdown → `runCodex()` in `session-ui.js` ("Run CX" label), App Settings → Codex CLI tab; Respawn/Ralph options are Claude-only, so session options open on the Summary tab for external CLI sessions. Tests: `test/run-mode-ui.test.ts` (vm-sandbox harness, no real DOM). @@ -209,7 +211,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L ### API Routes -~135 handlers across 15 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (28), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. +~136 handlers across 15 route files in `src/web/routes/`: system (41, incl. self-update `check`/`status`/`POST /api/system/update`, `POST /api/system/span-displays` → spawns `scripts/span-codeman.sh`, and `GET /api/codex/status`), sessions (29), orchestrator (10), cases (9), ralph (9), plan (8), respawn (7), files (6), mux (5), push (4), scheduled (4), teams (2), hooks (1), clipboard (1), ws (1 WebSocket). Each file has `@fileoverview` with endpoint details. **HTTP contract** (stable since 0.9.x, see `docs/versioning-policy.md`; full envelope/status/error-code/SSE spec in `docs/api-reference.md`): responses use the `ApiResponse` envelope — `{ success: true, data? }` or `{ success: false, error, errorCode }` (`src/types/api.ts`). `/api/v1/*` is a versioned alias of `/api/*` (URL rewrite in `server.ts`). diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index baceef4f..2ac81696 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -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}`); diff --git a/src/session-auto-ops.ts b/src/session-auto-ops.ts index 8bbadb53..28f061c7 100644 --- a/src/session-auto-ops.ts +++ b/src/session-auto-ops.ts @@ -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 { + 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'); } } diff --git a/src/session.ts b/src/session.ts index d0657d4f..f7eace3a 100644 --- a/src/session.ts +++ b/src/session.ts @@ -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(); + /** + * 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(); + } } } diff --git a/src/types/session.ts b/src/types/session.ts index bda7d0d5..61cf2b45 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -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 */ diff --git a/src/usage-limit-patterns.ts b/src/usage-limit-patterns.ts new file mode 100644 index 00000000..90665e1c --- /dev/null +++ b/src/usage-limit-patterns.ts @@ -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 " 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