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
+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,