mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +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:
+131
-5
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user