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 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-22 20:49:09 +01:00
co-authored by Claude Opus 4.5
parent 43eaafe084
commit e5aff372e8
4 changed files with 185 additions and 33 deletions
+36 -7
View File
@@ -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/<name>.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.
+84
View File
@@ -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
+9 -9
View File
@@ -310,18 +310,18 @@
<div class="form-row">
<label>Update Prompt</label>
<input type="text" id="modalRespawnPrompt" value="update all the docs and CLAUDE.md" placeholder="Prompt to send when idle">
<input type="text" id="modalRespawnPrompt" value="update all the docs and CLAUDE.md" placeholder="Prompt to send when idle" onchange="app.autoSaveRespawnConfig()">
</div>
<div class="form-row">
<label>After Update Prompt</label>
<div class="checkbox-group-inline">
<label class="checkbox-inline">
<input type="checkbox" id="modalRespawnSendClear" checked>
<input type="checkbox" id="modalRespawnSendClear" checked onchange="app.autoSaveRespawnConfig()">
<span>Send /clear</span>
</label>
<label class="checkbox-inline">
<input type="checkbox" id="modalRespawnSendInit" checked>
<input type="checkbox" id="modalRespawnSendInit" checked onchange="app.autoSaveRespawnConfig()">
<span>Send /init</span>
</label>
</div>
@@ -329,7 +329,7 @@
<div class="form-row">
<label>Kickstart Prompt</label>
<input type="text" id="modalRespawnKickstart" value="" placeholder="Optional: prompt if /init doesn't trigger work">
<input type="text" id="modalRespawnKickstart" value="" placeholder="Optional: prompt if /init doesn't trigger work" onchange="app.autoSaveRespawnConfig()">
<span class="form-hint">Sent only when /init completes but Claude stays idle</span>
</div>
@@ -337,15 +337,15 @@
<label>Auto-Compact Context</label>
<div class="auto-clear-row">
<label class="checkbox-inline">
<input type="checkbox" id="modalAutoCompactEnabled">
<input type="checkbox" id="modalAutoCompactEnabled" onchange="app.autoSaveAutoCompact()">
<span>Enable at</span>
</label>
<div class="input-suffix input-suffix-sm">
<input type="number" id="modalAutoCompactThreshold" value="110000" min="10000" max="500000" step="10000">
<input type="number" id="modalAutoCompactThreshold" value="110000" min="10000" max="500000" step="10000" onchange="app.autoSaveAutoCompact()">
<span>tokens</span>
</div>
</div>
<input type="text" id="modalAutoCompactPrompt" value="" placeholder="Optional: focus prompt for compact" class="mt-sm">
<input type="text" id="modalAutoCompactPrompt" value="" placeholder="Optional: focus prompt for compact" class="mt-sm" onchange="app.autoSaveAutoCompact()">
<span class="form-hint">Summarizes context while preserving key information</span>
</div>
@@ -353,11 +353,11 @@
<label>Auto-Clear Context</label>
<div class="auto-clear-row">
<label class="checkbox-inline">
<input type="checkbox" id="modalAutoClearEnabled">
<input type="checkbox" id="modalAutoClearEnabled" onchange="app.autoSaveAutoClear()">
<span>Enable at</span>
</label>
<div class="input-suffix input-suffix-sm">
<input type="number" id="modalAutoClearThreshold" value="140000" min="10000" max="500000" step="10000">
<input type="number" id="modalAutoClearThreshold" value="140000" min="10000" max="500000" step="10000" onchange="app.autoSaveAutoClear()">
<span>tokens</span>
</div>
</div>
+56 -17
View File
@@ -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<RespawnConfig>;
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();