diff --git a/CLAUDE.md b/CLAUDE.md index ed3992d8..537645fe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,10 +22,10 @@ npx tsx src/index.ts web -p 8080 # Dev mode with custom port node dist/index.js web # After npm run build claudeman web # After npm link -# NOTE: `npm run dev` runs the CLI (shows help), NOT the web server -# You must specify the `web` subcommand to start the server +# IMPORTANT: `npm run dev` runs the CLI help, NOT the web server +# Always use `npx tsx src/index.ts web` for development -# Testing +# Testing (vitest) npm run test # Run all tests once npm run test:watch # Watch mode npm run test:coverage # With coverage report @@ -33,6 +33,20 @@ npx vitest run test/session.test.ts # Single file npx vitest run -t "should create session" # By pattern ``` +### Test Files + +| File | Coverage | +|------|----------| +| `session.test.ts` | Session creation, PTY modes, token tracking | +| `respawn-controller.test.ts` | State machine transitions, config updates | +| `scheduled-runs.test.ts` | Timed runs, iteration cleanup | +| `quick-start.test.ts` | Case creation + session startup | +| `sse-events.test.ts` | Event broadcasting, client reconnection | +| `integration-flows.test.ts` | Multi-step workflows | +| `edge-cases.test.ts` | Error handling, boundary conditions | +| `session-cleanup.test.ts` | Process termination, buffer management | +| `pty-interactive.test.ts` | Terminal resize, input handling | + ## Architecture ``` @@ -64,7 +78,7 @@ src/ ### Key Components -- **Session** (`src/session.ts`): Wraps Claude CLI as PTY subprocess. Two modes: `runPrompt(prompt)` for one-shot execution, `startInteractive()` for persistent terminal. Emits `output`, `terminal`, `message`, `completion`, `exit`, `idle`, `working`, `autoClear` events. Maintains terminal buffer for reconnections. Includes buffer management for long-running sessions (12-24+ hours) with automatic trimming. Tracks input/output tokens and supports auto-clear at configurable threshold. +- **Session** (`src/session.ts`): Wraps Claude CLI as PTY subprocess. Two modes: `runPrompt(prompt)` for one-shot execution, `startInteractive()` for persistent terminal. Emits `output`, `terminal`, `message`, `completion`, `exit`, `idle`, `working`, `autoClear`, `clearTerminal` events. Maintains terminal buffer for reconnections. Includes buffer management for long-running sessions (12-24+ hours) with automatic trimming. Tracks input/output tokens and supports auto-clear at configurable threshold. - **TaskTracker** (`src/task-tracker.ts`): Detects Claude's background Task tool usage from JSON output. Builds a tree of parent-child task relationships. Emits `taskCreated`, `taskUpdated`, `taskCompleted`, `taskFailed` events. Used by Session to track background work. @@ -208,11 +222,18 @@ Session tracks input/output tokens differently depending on mode: When enabled, auto-clear waits for idle state, sends `/clear`, and resets token counts. +### Screen Session Initialization + +GNU screen creates blank space at the top when initializing a session. This is handled after attaching to the screen: + +- **Claude sessions**: After 100ms, clears terminal buffer and emits `clearTerminal` event. Client receives `session:clearTerminal` via SSE and clears/resets its xterm. +- **Shell sessions**: After 100ms, clears terminal buffer and sends `clear\n` command to the shell. + ### SSE Events All events broadcast to `/api/events` with format: `{ type: string, sessionId?: string, data: any }`. -Event categories (prefixes): `session:`, `task:`, `respawn:`, `scheduled:`, `case:`, `init`. Key events include `session:idle`, `session:working`, `session:terminal`, `session:completion`, `respawn:stateChanged`. See `src/web/server.ts` for the full event catalog. +Event categories (prefixes): `session:`, `task:`, `respawn:`, `scheduled:`, `case:`, `init`. Key events include `session:idle`, `session:working`, `session:terminal`, `session:clearTerminal`, `session:completion`, `respawn:stateChanged`. See `src/web/server.ts` for the full event catalog. ## API Endpoints @@ -303,8 +324,30 @@ Key files: - `styles.css` - All CSS styles - Libraries loaded from CDN (xterm.js, addons) +## Adding New Features + +### New API Endpoint + +1. Add types to `src/types.ts` (request/response interfaces) +2. Add route in `src/web/server.ts` within the `buildServer()` function +3. Follow existing patterns: use `createErrorResponse()` for errors + +### New SSE Event + +1. Add event type constant in `src/web/server.ts` (see `broadcast()` calls) +2. Emit from appropriate component (Session, RespawnController, etc.) +3. Handle in `src/web/public/app.js` `handleSSEEvent()` switch + +### New Session Event + +1. Add to `SessionEvents` interface in `src/session.ts` +2. Emit in `src/session.ts` via `this.emit()` +3. Subscribe in `src/web/server.ts` when wiring session to SSE +4. Handle in `src/web/public/app.js` SSE event listener + ## Notes - State persists to `~/.claudeman/state.json` and `~/.claudeman/screens.json` - Cases are created in `~/claudeman-cases/` by default - Sessions are wrapped in GNU screen for persistence across server restarts +- Tests use vitest with mocking via `vi.mock()` - no real Claude CLI spawned diff --git a/src/screen-manager.ts b/src/screen-manager.ts index c413a133..80ad371c 100644 --- a/src/screen-manager.ts +++ b/src/screen-manager.ts @@ -52,7 +52,9 @@ export class ScreenManager extends EventEmitter { const screenName = `claudeman-${sessionId.slice(0, 8)}`; // Create screen in detached mode with the appropriate command - const cmd = mode === 'claude' ? 'claude --dangerously-skip-permissions' : '$SHELL'; + const cmd = mode === 'claude' + ? 'claude --dangerously-skip-permissions' + : '$SHELL'; try { // Start screen in detached mode diff --git a/src/session.ts b/src/session.ts index 58902030..6cbc9358 100644 --- a/src/session.ts +++ b/src/session.ts @@ -50,6 +50,7 @@ export interface SessionEvents { exit: (code: number | null) => void; completion: (result: string, cost: number) => void; terminal: (data: string) => void; // Raw terminal data + clearTerminal: () => void; // Signal client to clear terminal (after screen attach) // Background task events taskCreated: (task: BackgroundTask) => void; taskUpdated: (task: BackgroundTask) => void; @@ -327,6 +328,13 @@ export class Session extends EventEmitter { cwd: this.workingDir, env: { ...process.env, TERM: 'xterm-256color' }, }); + + // Screen creates blank space when initializing. After attaching, wait for + // the initial burst then clear the buffer and tell clients to clear their terminal. + setTimeout(() => { + this._terminalBuffer = ''; + this.emit('clearTerminal'); + }, 100); } catch (err) { console.error('[Session] Failed to create screen session, falling back to direct PTY:', err); this._useScreen = false; @@ -448,6 +456,15 @@ export class Session extends EventEmitter { cwd: this.workingDir, env: { ...process.env, TERM: 'xterm-256color' }, }); + + // Screen creates blank space when initializing. After attaching, wait for + // the initial burst then clear by sending 'clear' command to the shell. + setTimeout(() => { + if (this.ptyProcess) { + this._terminalBuffer = ''; + this.ptyProcess.write('clear\n'); + } + }, 100); } catch (err) { console.error('[Session] Failed to create screen session, falling back to direct PTY:', err); this._useScreen = false; diff --git a/src/web/public/app.js b/src/web/public/app.js index 33ef9ec9..9c9e107e 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -254,6 +254,15 @@ class ClaudemanApp { } }); + this.eventSource.addEventListener('session:clearTerminal', (e) => { + const data = JSON.parse(e.data); + if (data.id === this.activeSessionId) { + // Clear terminal after screen attach to remove initialization blank space + this.terminal.clear(); + this.terminal.reset(); + } + }); + this.eventSource.addEventListener('session:completion', (e) => { const data = JSON.parse(e.data); this.totalCost += data.cost || 0; @@ -552,7 +561,12 @@ class ClaudemanApp { try { const res = await fetch(`/api/sessions/${sessionId}/terminal`); const data = await res.json(); + + // Aggressively clear the terminal before switching sessions + // This ensures no residual content from previous display + this.terminal.clear(); this.terminal.reset(); + if (data.terminalBuffer) { // Strip leading ANSI escape sequences and whitespace to prevent gaps // This handles: @@ -560,8 +574,16 @@ class ClaudemanApp { // - OSC sequences: ESC ] ... BEL or ESC \ // - Simple sequences: ESC followed by single char // - Whitespace, CR, LF + // - Screen initialization sequences let cleanBuffer = data.terminalBuffer; - cleanBuffer = cleanBuffer.replace(/^(\x1b\[[0-9;?]*[A-Za-z@`]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b[DEMNOP78>=c]|\s|\r|\n)*/g, ''); + + // First, strip any leading screen/terminal initialization artifacts + // Including cursor positioning that could leave blank space + cleanBuffer = cleanBuffer.replace(/^(\x1b\[[0-9;?]*[A-Za-z@`]|\x1b\][^\x07]*\x07|\x1b[()][AB012]|\x1b[=>DEMNOP78c]|\x1b[^\x1b]|\s|\r|\n)*/g, ''); + + // Also strip any DCS sequences (screen might send these) + cleanBuffer = cleanBuffer.replace(/^\x1bP[^\x1b]*\x1b\\/g, ''); + this.terminal.write(cleanBuffer); } @@ -797,7 +819,28 @@ class ClaudemanApp { // Auto-switch to the new session if (firstSessionId) { - await this.selectSession(firstSessionId); + // IMPORTANT: Clear terminal BEFORE setting activeSessionId + // This prevents SSE events from writing during the transition + this.terminal.clear(); + this.terminal.reset(); + + // Small delay to ensure terminal is fully reset before SSE events can write + await new Promise(resolve => setTimeout(resolve, 50)); + + // NOW set the active session so SSE events start populating + this.activeSessionId = firstSessionId; + this.renderSessionTabs(); + + // Send resize to the new session + const dims = this.fitAddon.proposeDimensions(); + if (dims) { + fetch(`/api/sessions/${firstSessionId}/resize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cols: dims.cols, rows: dims.rows }) + }); + } + this.loadQuickStartCases(); } diff --git a/src/web/server.ts b/src/web/server.ts index d752d258..a5a05b95 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -813,6 +813,11 @@ export class WebServer extends EventEmitter { this.batchTerminalData(session.id, data); }); + session.on('clearTerminal', () => { + // Tell clients to clear their terminal (after screen attach) + this.broadcast('session:clearTerminal', { id: session.id }); + }); + session.on('message', (msg: ClaudeMessage) => { this.broadcast('session:message', { id: session.id, message: msg }); });