From e5aff372e80919de225b1aee8edc2c5ccb1665f3 Mon Sep 17 00:00:00 2001 From: arkon Date: Thu, 22 Jan 2026 20:49:09 +0100 Subject: [PATCH] feat: auto-save session options and restore on modal open - Add GET /api/sessions/:id/respawn/config endpoint for reading saved config - PUT /api/sessions/:id/respawn/config now works without running controller (saves pre-config to screens.json for when respawn starts) - Respawn start/enable merges pre-saved config from screens.json - Frontend auto-saves respawn config, auto-compact, auto-clear on field change - Frontend loads saved config when opening session options modal - Updated CLAUDE.md with state persistence docs, new API endpoint, SessionState fields table, state lifecycle, and session setting guide Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 43 ++++++++++++++++---- src/web/public/app.js | 84 +++++++++++++++++++++++++++++++++++++++ src/web/public/index.html | 18 ++++----- src/web/server.ts | 73 ++++++++++++++++++++++++++-------- 4 files changed, 185 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 120e0f60..f746ac43 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -153,7 +153,8 @@ claudeman reset # Reset all state 1. **Session** spawns `claude -p --dangerously-skip-permissions` via `node-pty` 2. PTY output is buffered, ANSI stripped, and parsed for JSON messages 3. **WebServer** broadcasts events to SSE clients at `/api/events` -4. State persists to `~/.claudeman/state.json` via **StateStore** +4. Full session state (settings, tokens, respawn config, Ralph state) persists to `~/.claudeman/state.json` via **StateStore** +5. Screen metadata persists separately to `~/.claudeman/screens.json` for session recovery ### Respawn State Machine @@ -387,7 +388,24 @@ Vanilla JS + xterm.js. Key functions: ### State Store -Writes debounced to `~/.claudeman/state.json`. Batches rapid changes. +Writes debounced (500ms) to `~/.claudeman/state.json`. The web server persists full session state via `persistSessionState()` on every meaningful change: + +**Persistence triggers**: session create, delete, rename, settings change (auto-compact, auto-clear, Ralph config), respawn start/stop/update/expire, completion, exit. + +**Per-session fields stored** (`SessionState` in `types.ts`): + +| Field | Description | +|-------|-------------| +| `id`, `pid`, `status` | Core session identity | +| `name`, `mode` | Display name, 'claude' or 'shell' | +| `workingDir`, `createdAt`, `lastActivityAt` | Location and timestamps | +| `autoClearEnabled`, `autoClearThreshold` | Auto-clear settings | +| `autoCompactEnabled`, `autoCompactThreshold`, `autoCompactPrompt` | Auto-compact settings | +| `ralphEnabled`, `ralphCompletionPhrase` | Ralph / Todo tracker state | +| `respawnConfig` | Full respawn config including `durationMinutes` | +| `totalCost`, `inputTokens`, `outputTokens` | Token and cost tracking | + +**CLI visibility**: The `claudeman status` and `claudeman session list` commands read from `state.json` to display web server-managed sessions, even though they run in a separate process. ### Timing Constants @@ -430,6 +448,7 @@ TUI uses React JSX (`jsxImportSource: react`) for Ink components. - **API endpoint**: Add types in `types.ts`, route in `server.ts:buildServer()`, use `createErrorResponse()` for errors - **SSE event**: Emit via `broadcast()` in server.ts, handle in `app.js:handleSSEEvent()` switch - **Session event**: Add to `SessionEvents` interface in `session.ts`, emit via `this.emit()`, subscribe in server.ts, handle in frontend +- **Session setting**: Add field to `SessionState` in `types.ts`, include in `session.toState()`, call `this.persistSessionState(session)` in server.ts after the change - **New test file**: Create `test/.test.ts`, pick unique port (next available: 3127+), add to port allocation comment above ### API Error Codes @@ -450,7 +469,8 @@ Use `createErrorResponse(code, details?)` from `types.ts`: - **Limit**: Web server: `MAX_CONCURRENT_SESSIONS = 50` (`server.ts:56`), UI tab limit: 20, CLI default: 5 (`types.ts:DEFAULT_CONFIG`) - **Kill** (`killScreen()`): child PIDs → process group → screen quit → SIGKILL - **Ghost discovery**: `reconcileScreens()` finds orphaned screens on startup -- **Cleanup** (`cleanupSession()`): stops respawn, clears buffers/timers, kills screen +- **Cleanup** (`cleanupSession()`): stops respawn, clears buffers/timers, kills screen, removes from `state.json` +- **State sync**: Every session create/delete/update calls `persistSessionState()` which writes full state (including respawn config from controller) to `state.json` ## TUI Architecture (Ink/React) @@ -506,10 +526,11 @@ Long-running sessions are supported with automatic trimming: | POST | `/api/sessions/:id/input` | Send input to session PTY | | POST | `/api/sessions/:id/resize` | Resize terminal (cols, rows) | | POST | `/api/sessions/:id/interactive` | Start interactive mode | -| POST | `/api/sessions/:id/respawn/start` | Start respawn controller | +| POST | `/api/sessions/:id/respawn/start` | Start respawn controller (merges pre-saved config) | | POST | `/api/sessions/:id/respawn/stop` | Stop respawn controller | | POST | `/api/sessions/:id/respawn/enable` | Enable respawn with config + optional timer | -| PUT | `/api/sessions/:id/respawn/config` | Update config on running respawn | +| GET | `/api/sessions/:id/respawn/config` | Get current or pre-saved respawn config | +| PUT | `/api/sessions/:id/respawn/config` | Update config (works with or without running controller) | | POST | `/api/sessions/:id/ralph-config` | Configure Ralph / Todo Tracker settings | | GET | `/api/sessions/:id/ralph-state` | Get Ralph loop state + todos | | POST | `/api/sessions/:id/auto-compact` | Configure auto-compact threshold | @@ -574,9 +595,17 @@ Long-running sessions are supported with automatic trimming: | File | Purpose | |------|---------| -| `~/.claudeman/state.json` | Sessions, tasks, config | +| `~/.claudeman/state.json` | Full session state (all settings, tokens, respawn config, Ralph state), tasks, app config | | `~/.claudeman/state-inner.json` | Ralph loop/todo state per session (separate to reduce writes) | -| `~/.claudeman/screens.json` | Screen session metadata | +| `~/.claudeman/screens.json` | Screen session metadata (for recovery after restart) | +| `~/.claudeman/settings.json` | User preferences (lastUsedCase, custom template path) | + +**State lifecycle**: +- Web server creates → session added to `state.json` + `screens.json` +- Settings change → `state.json` updated (debounced 500ms) +- Session deleted → removed from `state.json` (screen may survive if `killScreen: false`) +- Server shutdown → all sessions cleaned up from `state.json`, screens preserved for recovery +- Server restart → sessions restored from `screens.json`, state rebuilt in `state.json` Cases created in `~/claudeman-cases/` by default. diff --git a/src/web/public/app.js b/src/web/public/app.js index 8d313ab4..b833b3d7 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1608,6 +1608,16 @@ class ClaudemanApp { // Reset duration presets to default (unlimited) this.selectDurationPreset(''); + // Populate respawn config from saved state + this.loadSavedRespawnConfig(sessionId); + + // Populate auto-compact/clear from session state + document.getElementById('modalAutoCompactEnabled').checked = session.autoClear?.autoCompact?.enabled ?? false; + document.getElementById('modalAutoCompactThreshold').value = session.autoClear?.autoCompact?.threshold ?? 110000; + document.getElementById('modalAutoCompactPrompt').value = session.autoClear?.autoCompact?.prompt ?? ''; + document.getElementById('modalAutoClearEnabled').checked = session.autoClear?.enabled ?? false; + document.getElementById('modalAutoClearThreshold').value = session.autoClear?.threshold ?? 140000; + // Populate Ralph Wiggum form with current session values const ralphState = this.ralphStates.get(sessionId); this.populateRalphForm({ @@ -1621,6 +1631,80 @@ class ClaudemanApp { document.getElementById('sessionOptionsModal').classList.add('active'); } + async autoSaveAutoCompact() { + if (!this.editingSessionId) return; + try { + await fetch(`/api/sessions/${this.editingSessionId}/auto-compact`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: document.getElementById('modalAutoCompactEnabled').checked, + threshold: parseInt(document.getElementById('modalAutoCompactThreshold').value) || 110000, + prompt: document.getElementById('modalAutoCompactPrompt').value.trim() || undefined + }) + }); + } catch { /* silent */ } + } + + async autoSaveAutoClear() { + if (!this.editingSessionId) return; + try { + await fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + enabled: document.getElementById('modalAutoClearEnabled').checked, + threshold: parseInt(document.getElementById('modalAutoClearThreshold').value) || 140000 + }) + }); + } catch { /* silent */ } + } + + async autoSaveRespawnConfig() { + if (!this.editingSessionId) return; + const config = { + updatePrompt: document.getElementById('modalRespawnPrompt').value, + sendClear: document.getElementById('modalRespawnSendClear').checked, + sendInit: document.getElementById('modalRespawnSendInit').checked, + kickstartPrompt: document.getElementById('modalRespawnKickstart').value.trim() || undefined, + }; + try { + await fetch(`/api/sessions/${this.editingSessionId}/respawn/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(config) + }); + } catch { + // Silent save - don't interrupt user + } + } + + async loadSavedRespawnConfig(sessionId) { + try { + const res = await fetch(`/api/sessions/${sessionId}/respawn/config`); + const data = await res.json(); + if (data.success && data.config) { + const c = data.config; + document.getElementById('modalRespawnPrompt').value = c.updatePrompt || 'update all the docs and CLAUDE.md'; + document.getElementById('modalRespawnSendClear').checked = c.sendClear ?? true; + document.getElementById('modalRespawnSendInit').checked = c.sendInit ?? true; + document.getElementById('modalRespawnKickstart').value = c.kickstartPrompt || ''; + // Restore duration if set + if (c.durationMinutes) { + const presetBtn = document.querySelector(`.duration-preset-btn[data-minutes="${c.durationMinutes}"]`); + if (presetBtn) { + this.selectDurationPreset(String(c.durationMinutes)); + } else { + this.selectDurationPreset('custom'); + document.getElementById('modalRespawnDuration').value = c.durationMinutes; + } + } + } + } catch { + // Ignore - use defaults + } + } + // Handle duration preset selection selectDurationPreset(value) { // Remove active from all buttons diff --git a/src/web/public/index.html b/src/web/public/index.html index ffadf90c..200150a5 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -310,18 +310,18 @@
- +
@@ -329,7 +329,7 @@
- + Sent only when /init completes but Claude stays idle
@@ -337,15 +337,15 @@
- + tokens
- + Summarizes context while preserving key information
@@ -353,11 +353,11 @@
- + tokens
diff --git a/src/web/server.ts b/src/web/server.ts index f4db6ef6..795228db 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -581,6 +581,24 @@ export class WebServer extends EventEmitter { }; }); + // Get respawn config (from running controller or pre-saved) + this.app.get('/api/sessions/:id/respawn/config', async (req) => { + const { id } = req.params as { id: string }; + const controller = this.respawnControllers.get(id); + + if (controller) { + return { success: true, config: controller.getConfig(), active: true }; + } + + // Return pre-saved config from screens.json + const preConfig = this.screenManager.getScreen(id)?.respawnConfig; + if (preConfig) { + return { success: true, config: preConfig, active: false }; + } + + return { success: true, config: null, active: false }; + }); + // Start respawn controller for a session this.app.post('/api/sessions/:id/respawn/start', async (req) => { const { id } = req.params as { id: string }; @@ -594,7 +612,10 @@ export class WebServer extends EventEmitter { // Create or get existing controller let controller = this.respawnControllers.get(id); if (!controller) { - controller = new RespawnController(session, body); + // Merge request body with pre-saved config from screens.json + const preConfig = this.screenManager.getScreen(id)?.respawnConfig; + const config = body || preConfig ? { ...preConfig, ...body } : undefined; + controller = new RespawnController(session, config); this.respawnControllers.set(id, controller); this.setupRespawnListeners(id, controller); } else if (body) { @@ -637,28 +658,44 @@ export class WebServer extends EventEmitter { return { success: true }; }); - // Update respawn configuration + // Update respawn configuration (works with or without running controller) this.app.put('/api/sessions/:id/respawn/config', async (req) => { const { id } = req.params as { id: string }; const config = req.body as Partial; + const session = this.sessions.get(id); + + if (!session) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); + } + const controller = this.respawnControllers.get(id); - if (!controller) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Respawn controller not found'); - } - - controller.updateConfig(config); - - // Persist updated config - this.saveRespawnConfig(id, controller.getConfig()); - const session = this.sessions.get(id); - if (session) { + if (controller) { + // Update running controller + controller.updateConfig(config); + this.saveRespawnConfig(id, controller.getConfig()); this.persistSessionState(session); + this.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() }); + return { success: true, config: controller.getConfig() }; } - this.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() }); - - return { success: true, config: controller.getConfig() }; + // No controller running - save as pre-config for when respawn starts + const existing = this.screenManager.getScreen(id); + const currentConfig = existing?.respawnConfig; + const merged: PersistedRespawnConfig = { + enabled: config.enabled ?? currentConfig?.enabled ?? false, + idleTimeoutMs: config.idleTimeoutMs ?? currentConfig?.idleTimeoutMs ?? 10000, + updatePrompt: config.updatePrompt ?? currentConfig?.updatePrompt ?? 'update all the docs and CLAUDE.md', + interStepDelayMs: config.interStepDelayMs ?? currentConfig?.interStepDelayMs ?? 1000, + sendClear: config.sendClear ?? currentConfig?.sendClear ?? true, + sendInit: config.sendInit ?? currentConfig?.sendInit ?? true, + kickstartPrompt: config.kickstartPrompt ?? currentConfig?.kickstartPrompt, + durationMinutes: currentConfig?.durationMinutes, + }; + this.screenManager.updateRespawnConfig(id, merged); + this.persistSessionState(session); + this.broadcast('respawn:configUpdated', { sessionId: id, config: merged }); + return { success: true, config: merged }; }); // Start interactive session WITH respawn enabled @@ -733,8 +770,10 @@ export class WebServer extends EventEmitter { existingController.stop(); } - // Create and start new respawn controller - const controller = new RespawnController(session, body?.config); + // Create and start new respawn controller (merge with pre-saved config) + const preConfig = this.screenManager.getScreen(id)?.respawnConfig; + const config = body?.config || preConfig ? { ...preConfig, ...body?.config } : undefined; + const controller = new RespawnController(session, config); this.respawnControllers.set(id, controller); this.setupRespawnListeners(id, controller); controller.start();