feat: add asciinema stream pruning for clear sequences (#155)

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Mario Zechner 2025-07-01 06:01:59 +02:00 committed by GitHub
parent ba8d7be280
commit 40d2cd1998
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 6704 additions and 122 deletions

View file

@ -6,12 +6,13 @@ A comprehensive navigation guide for the VibeTunnel web terminal system.
VibeTunnel is a web-based terminal multiplexer with distributed architecture support. It provides:
- PTY-based terminal sessions via node-pty
- Real-time terminal streaming via SSE (asciinema cast files)
- Real-time terminal streaming via SSE (asciinema cast v2 format)
- Binary-optimized buffer synchronization (current viewport via WebSocket)
- Distributed HQ/remote server architecture
- Web UI with full terminal emulation
- Push notifications for terminal bell events
- Multi-method authentication (SSH keys, JWT, PAM)
- Advanced terminal title management
## Directory Structure
@ -23,9 +24,10 @@ web/
│ │ ├── pty/ # PTY management
│ │ ├── routes/ # API endpoints
│ │ ├── services/ # Core services
│ │ ├── utils/ # Server utilities
│ │ ├── server.ts # Main server implementation
│ │ └── fwd.ts # CLI forwarding tool
│ ├── client/ # Lit-based web UI
│ ├── client/ # Lit-based web UI
│ │ ├── assets/ # Static files (fonts, icons, html)
│ │ ├── components/ # UI components
│ │ ├── services/ # Client services
@ -41,104 +43,133 @@ web/
### Core Components
#### Entry Points
- `cli.ts` (1-62): Main entry point, routes to server or forward mode
- `server/server.ts` (1-953): Core server implementation with Express app factory
- CLI parsing (132-236): Extensive configuration options
- Server modes (432-444): Normal, HQ, Remote initialization
- WebSocket upgrade (564-677): Authentication and buffer streaming
- Graceful shutdown (888-944): Cleanup intervals and service termination
- `cli.ts:1-62`: Main entry point, routes to server or forward mode
- Version display, debug initialization, mode routing
- `server.ts:1-1017`: Core server implementation with Express app factory
- CLI parsing: `142-246`
- Server modes: `432-455` (Normal, HQ, Remote initialization)
- WebSocket upgrade: `585-707` (Authentication and buffer streaming)
- Graceful shutdown: `893-1017` (Cleanup intervals and service termination)
### Authentication (`middleware/auth.ts`)
- Multi-method authentication (1-159)
- Multi-method authentication: `1-159`
- SSH key authentication with Ed25519 support
- Basic auth (username/password)
- Bearer token for HQ↔Remote communication
- JWT tokens for session persistence
- Local bypass for localhost connections (24-48, 68-87)
- Local bypass for localhost connections: `24-48`, `68-87`
- Query parameter token support for EventSource
### Session Management
#### PTY Manager (`pty/pty-manager.ts`)
- Session creation (78-163): Spawns PTY processes with node-pty
- **Automatic alias resolution** (191-204): Uses `ProcessUtils.resolveCommand()`
- Terminal resize handling (63-157): Dimension synchronization
- Control pipe support using file watching on all platforms
- Session creation: `173-400` (Spawns PTY processes with node-pty)
- **Automatic alias resolution**: `204-217` (Uses `ProcessUtils.resolveCommand()`)
- Terminal resize handling: `115-168` (Native terminal dimension sync)
- Control pipe support using file watching
- Bell event emission for push notifications
- Clean termination with SIGTERM→SIGKILL escalation
#### Process Utils (`pty/process-utils.ts`)
- `resolveCommand()` (242-378): Detects if command exists in PATH
- `resolveCommand()`: `241-388` (Detects if command exists in PATH)
- Uses `which` (Unix) or `where` (Windows) to check existence
- Returns appropriate shell command for aliases/builtins
- Sources shell config files for proper alias support
- `getUserShell()` (384-484): Determines user's preferred shell
- `getUserShell()`: `394-484` (Determines user's preferred shell)
- Checks `$SHELL` environment variable first
- Platform-specific fallbacks (pwsh/cmd on Windows, zsh/bash on Unix)
- Interactive shell detection (220-235): Auto-adds `-i -l` flags
- Interactive shell detection: `220-235` (Auto-adds `-i -l` flags)
#### Session Manager (`pty/session-manager.ts`)
- Session persistence in `~/.vibetunnel/control/`
- Filesystem-based session discovery
- Zombie session cleanup
- Zombie session cleanup: `337-366`
- Atomic writes for session metadata: `99-115`
#### Terminal Manager (`services/terminal-manager.ts`)
- Headless xterm.js for server-side state
- Binary buffer snapshot generation
- Headless xterm.js for server-side state: `47-76`
- Binary buffer snapshot generation: `261-292`
- Watches asciinema cast files and applies to terminal
- Debounced buffer change notifications
- Debounced buffer change notifications (100ms)
### API Routes (`routes/`)
#### Sessions (`sessions.ts`)
- `GET /api/sessions` (51-124): List all sessions
- `GET /api/sessions`: `51-124` - List all sessions
- Returns array with `source: 'local' | 'remote'`
- HQ mode: Aggregates from all remote servers
- `POST /api/sessions` (126-265): Create session
- Body: `{ command, workingDir?, name?, remoteId?, spawn_terminal? }`
- `POST /api/sessions`: `126-265` - Create session
- Body: `{ command, workingDir?, name?, remoteId?, spawn_terminal?, cols?, rows?, titleMode? }`
- Returns: `{ sessionId: string, message?: string }`
- `GET /api/sessions/:id` (369-410): Get session info
- `DELETE /api/sessions/:id` (413-467): Kill session
- `DELETE /api/sessions/:id/cleanup` (470-518): Clean session files
- `POST /api/cleanup-exited` (521-598): Clean all exited sessions
- `GET /api/sessions/:id`: `369-410` - Get session info
- `DELETE /api/sessions/:id`: `413-467` - Kill session
- `DELETE /api/sessions/:id/cleanup`: `470-518` - Clean session files
- `POST /api/cleanup-exited`: `521-598` - Clean all exited sessions
#### Session I/O
- `POST /api/sessions/:id/input` (874-950): Send keyboard input
- `POST /api/sessions/:id/input`: `874-950` - Send keyboard input
- Body: `{ text: string }` OR `{ key: SpecialKey }`
- `POST /api/sessions/:id/resize` (953-1025): Resize terminal
- `POST /api/sessions/:id/resize`: `953-1025` - Resize terminal
- Body: `{ cols: number, rows: number }`
- `POST /api/sessions/:id/reset-size` (1028-1083): Reset to native size
- `POST /api/sessions/:id/reset-size`: `1028-1083` - Reset to native size
#### Session Output
- `GET /api/sessions/:id/stream` (723-871): SSE streaming
- `GET /api/sessions/:id/stream`: `723-871` - SSE streaming
- Streams asciinema v2 format with custom exit event
- Replays existing content, then real-time streaming
- `GET /api/sessions/:id/buffer` (662-721): Binary buffer snapshot
- `GET /api/sessions/:id/text` (601-659): Plain text output
- `GET /api/sessions/:id/buffer`: `662-721` - Binary buffer snapshot
- `GET /api/sessions/:id/text`: `601-659` - Plain text output
- Optional `?styles` for markup: `[style fg="15" bold]text[/style]`
#### Activity Monitoring
- `GET /api/sessions/activity` (268-324): All sessions activity
- `GET /api/sessions/:id/activity` (327-366): Single session activity
- `GET /api/sessions/activity`: `268-324` - All sessions activity
- `GET /api/sessions/:id/activity`: `327-366` - Single session activity
- Returns: `{ isActive: boolean, timestamp: string, session: SessionInfo }`
#### WebSocket Input (`routes/websocket-input.ts`)
- `GET /ws/input?sessionId=<id>&token=<token>`: Low-latency input
- Fire-and-forget protocol for keyboard/mouse input
- Direct PTY forwarding for performance
#### Remotes (`remotes.ts`) - HQ Mode Only
- `GET /api/remotes` (19-33): List registered servers
- `POST /api/remotes/register` (36-64): Register remote
- `DELETE /api/remotes/:id` (67-84): Unregister remote
- `POST /api/remotes/:id/refresh-sessions` (87-152): Refresh session list
- `GET /api/remotes`: `19-33` - List registered servers
- `POST /api/remotes/register`: `36-64` - Register remote
- `DELETE /api/remotes/:id`: `67-84` - Unregister remote
- `POST /api/remotes/:id/refresh-sessions`: `87-152` - Refresh session list
#### Filesystem (`filesystem.ts`)
- `GET /api/fs/browse`: Browse directory with Git status
- `GET /api/fs/preview`: File preview with Monaco support
- `GET /api/fs/content`: Text file content
- `GET /api/fs/diff`: Git diff for files
- `POST /api/fs/mkdir`: Create directory
#### Logs (`logs.ts`)
- `POST /api/logs/client` (21-53): Client log submission
- `GET /api/logs/raw` (56-74): Stream raw log file
- `GET /api/logs/info` (77-102): Log file metadata
- `DELETE /api/logs/clear` (105-119): Clear log file
- `POST /api/logs/client`: `21-53` - Client log submission
- `GET /api/logs/raw`: `56-74` - Stream raw log file
- `GET /api/logs/info`: `77-102` - Log file metadata
- `DELETE /api/logs/clear`: `105-119` - Clear log file
#### Push Notifications (`push.ts`)
- `GET /api/push/vapid-public-key`: Get VAPID public key
- `POST /api/push/subscribe`: Subscribe to notifications
- `POST /api/push/unsubscribe`: Unsubscribe
- `POST /api/push/test`: Send test notification
- `GET /api/push/status`: Get service status
#### Authentication (`auth.ts`)
- `POST /api/auth/challenge`: Create SSH key auth challenge
- `POST /api/auth/ssh-key`: Authenticate with SSH key
- `POST /api/auth/password`: Authenticate with password
- `GET /api/auth/verify`: Verify authentication status
- `GET /api/auth/config`: Get auth configuration
- `GET /api/auth/avatar/:userId`: Get user avatar (macOS)
### Binary Buffer Protocol
**Note**: "Buffer" refers to the current terminal viewport without scrollback - used for terminal previews.
#### Format (`terminal-manager.ts:361-555`)
#### Format (`terminal-manager.ts:361-579`)
```
Header (32 bytes):
- Magic: 0x5654 "VT" (2 bytes)
@ -169,86 +200,96 @@ Cells: Variable-length with type byte
- Heartbeat every 30 seconds
### WebSocket (`services/buffer-aggregator.ts`)
- Client connections (30-87): Authentication and subscription
- Message handling (88-127): Subscribe/unsubscribe/ping
- Binary protocol (156-209): `[0xBF][ID Length][Session ID][Buffer Data]`
- Client connections: `35-71` (Authentication and subscription)
- Message handling: `76-145` (Subscribe/unsubscribe/ping)
- Binary protocol: `156-185` - `[0xBF][ID Length][Session ID][Buffer Data]`
- Local and remote session proxy support
### Activity Monitoring (`services/activity-monitor.ts`)
- Monitors `stream-out` file size changes (143-146)
- 100ms check interval (41-44)
- 500ms inactivity timeout (209-212)
- Persists to `activity.json` per session (220-245)
- Monitors `stdout` file size changes: `143-147`
- 100ms check interval: `41-44`
- 500ms inactivity timeout: `209-212`
- Persists to `activity.json` per session: `220-245`
- Works for all sessions regardless of creation method
### HQ Mode Components
#### Remote Registry (`services/remote-registry.ts`)
- Health checks every 15s (150-187)
- Session ownership tracking (91-148)
- Health checks every 15s: `150-187`
- Session ownership tracking: `91-148`
- Bearer token authentication
- Automatic unhealthy remote removal
#### HQ Client (`services/hq-client.ts`)
- Registration with HQ (40-90)
- Registration with HQ: `40-90`
- Unique ID generation with UUID v4
- Graceful unregistration on shutdown (92-113)
- Graceful unregistration on shutdown: `92-113`
### Additional Services
#### Push Notifications (`services/push-notification-service.ts`)
- Web Push API integration (64-363)
- Web Push API integration: `64-363`
- Subscription management in `~/.vibetunnel/notifications/`
- Bell event notifications (231-247)
- Bell event notifications: `231-247`
- Automatic expired subscription cleanup
#### Bell Event Handler (`services/bell-event-handler.ts`)
- Processes terminal bell events (59-182)
- Processes terminal bell events: `59-182`
- Integrates with push notifications
- Includes process context in notifications
#### Authentication Service (`services/auth-service.ts`)
- SSH key authentication (144-159, 201-271)
- SSH key authentication: `144-159`, `201-271`
- Ed25519 signature verification
- Challenge-response system
- Checks `~/.ssh/authorized_keys`
- Password authentication (105-120)
- PAM authentication fallback (184-196)
- JWT token management (176-180)
- Password authentication: `105-120`
- PAM authentication fallback: `184-196`
- JWT token management: `176-180`
#### Control Directory Watcher (`services/control-dir-watcher.ts`)
- Monitors external session changes (20-175)
- HQ mode integration (116-163)
- Monitors external session changes: `20-175`
- HQ mode integration: `116-163`
- Detects new/removed sessions
#### Shutdown State (`services/shutdown-state.ts`)
- Global shutdown state tracking
- Allows components to check shutdown status
### Utilities
#### Logger (`utils/logger.ts`)
- Structured logging with file and console output (1-186)
- Structured logging with file and console output: `1-186`
- Color-coded console with chalk
- Log levels: log, warn, error, debug
- File output to `~/.vibetunnel/log.txt`
- Debug mode via `VIBETUNNEL_DEBUG`
#### VAPID Manager (`utils/vapid-manager.ts`)
- Auto-generates VAPID keys for push notifications (20-331)
- Auto-generates VAPID keys for push notifications: `20-331`
- Stores in `~/.vibetunnel/vapid/keys.json`
- Key rotation support
#### Version (`version.ts`)
- Version info with build date and git commit
- Runtime version banner display
- Development mode detection
## Client Architecture (`src/client/`)
### Core Components
#### Entry Points
- `app-entry.ts` (1-28): Main entry, initializes Monaco and push notifications
- `app-entry.ts:1-28`: Main entry, initializes Monaco and push notifications
- `test-entry.ts`: Test terminals entry
- `styles.css`: Global Tailwind styles
#### Main Application (`app.ts`)
- Lit-based SPA (15-1200+): `<vibetunnel-app>`
- Lit-based SPA: `44-1401` - `<vibetunnel-app>`
- URL-based routing with `?session=<id>`
- Global keyboard handlers (Cmd+O, Escape)
- View management: auth/list/session
- Split-screen support for session list and detail
- **Events fired**:
- `toggle-nav`, `navigate-to-list`, `error`, `success`, `navigate`
@ -275,20 +316,21 @@ vibetunnel-app
### Terminal Components
#### Terminal (`terminal.ts`)
- Full xterm.js implementation (1-1000+)
- Virtual scrolling (537-555)
- Full xterm.js implementation: `23-150+`
- Virtual scrolling: `537-555`
- Touch/momentum support
- URL highlighting integration
- Custom width selection
- **Events**: `terminal-ready`, `terminal-input`, `terminal-resize`, `url-clicked`
#### VibeTunnelBuffer (`vibe-terminal-buffer.ts`)
- Read-only terminal preview (25-268)
- Read-only terminal preview: `26-268`
- WebSocket buffer subscription
- Auto-resizing
- **Events**: `content-changed`
#### SessionView (`session-view.ts`)
- Full-screen terminal view (29-1331)
- Full-screen terminal view: `52-200+`
- Manager architecture:
- ConnectionManager: SSE streaming
- InputManager: Keyboard/mouse
@ -300,19 +342,19 @@ vibetunnel-app
### Session Management Components
#### SessionList (`session-list.ts`)
- Grid/list layout (61-700+)
- Grid/list layout: `34-150+`
- Hide/show exited sessions
- Search and filtering
- **Events**: `navigate-to-session`, `refresh`, `error`, `success`
#### SessionCard (`session-card.ts`)
- Individual session display (31-420+)
- Individual session display: `25-150+`
- Live terminal preview
- Activity detection
- **Events**: `session-select`, `session-killed`, `session-kill-error`
#### SessionCreateForm (`session-create-form.ts`)
- Modal dialog (27-381)
- Modal dialog: `27-381`
- Command input with working directory
- Native terminal spawn option
- **Events**: `session-created`, `cancel`, `error`
@ -320,57 +362,71 @@ vibetunnel-app
### UI Components
#### AppHeader (`app-header.ts`)
- Main navigation (15-280+)
- Main navigation: `15-280+`
- Session status display
- Theme toggle
- **Events**: `toggle-nav`, `navigate-to-list`, `toggle-create-form`
- **Events**: `create-session`, `hide-exited-change`, `kill-all-sessions`, `logout`
#### FileBrowser (`file-browser.ts`)
- Filesystem navigation (48-665)
- Git status display
- Filesystem navigation: `48-665`
- Browse and select modes
- Monaco editor preview
- **Events**: `insert-path`, `open-in-editor`, `directory-selected`
- **Events**: `file-selected`, `browser-cancel`
#### UnifiedSettings (`unified-settings.ts`)
- Settings modal with multiple tabs
- Terminal preferences, notifications, appearance
- **Events**: `close`, `notifications-enabled`, `success`, `error`
#### LogViewer (`log-viewer.ts`)
- Real-time log display (1-432)
- Real-time log display: `1-432`
- SSE-style polling
- Level filtering
- Search functionality
- Level filtering and search
### Services
#### BufferSubscriptionService (`buffer-subscription-service.ts`)
- WebSocket client (30-87)
- Binary protocol decoder (163-208)
- WebSocket client: `77-164`
- Binary protocol decoder: `234-259`
- Auto-reconnection with backoff
- Per-session subscriptions
#### WebSocketInputClient (`websocket-input-client.ts`)
- Alternative input method via WebSocket
- Low-latency keyboard/mouse input
- Fire-and-forget protocol
#### PushNotificationService (`push-notification-service.ts`)
- Service worker registration
- Push subscription management
- Notification action handling
#### AuthClient (`auth-client.ts`)
- Token management
- Authentication state
- Token management: `27-100+`
- SSH key and password authentication
- API header generation
- SSH agent integration
### Utils
#### CastConverter (`cast-converter.ts`)
- Asciinema v2 parser (31-82)
- SSE stream handler (294-427)
- Custom exit event support
#### Terminal Utils
- `terminal-renderer.ts:276-418`: Binary buffer decoder and HTML generation
- `terminal-utils.ts:14-44`: Terminal resize helpers
- `terminal-preferences.ts`: Width preferences management
- `xterm-colors.ts`: Color palette definitions
- `url-highlighter.ts`: URL detection and click handling
#### TerminalRenderer (`terminal-renderer.ts`)
- Binary buffer decoder (279-424)
- HTML generation from buffer
- Style mapping
#### UI Utils
- `responsive-utils.ts`: Media query observer
- `title-updater.ts`: Dynamic document title
- `keyboard-shortcut-highlighter.ts`: Shortcut formatting
- `offline-notification-manager.ts`: Offline detection
#### URLHighlighter (`url-highlighter.ts`)
- Multi-line URL detection
- Protocol validation
- Click event handling
#### General Utils
- `cast-converter.ts:31-82`: Asciinema v2 parser
- `logger.ts`: Namespaced console logging
- `path-utils.ts`: Path formatting and clipboard
- `constants.ts`: UI timing, breakpoints, z-indexes
## Forward Tool (`src/server/fwd.ts`)
@ -379,38 +435,44 @@ CLI tool for spawning PTY sessions using VibeTunnel infrastructure.
### Usage
```bash
npx tsx src/fwd.ts [--session-id <id>] <command> [args...]
pnpm exec tsx src/fwd.ts [--session-id <id>] [--title-mode <mode>] <command> [args...]
# Examples
npx tsx src/fwd.ts claude --resume
npx tsx src/fwd.ts --session-id abc123 bash -l
pnpm exec tsx src/fwd.ts claude --resume
pnpm exec tsx src/fwd.ts --session-id abc123 --title-mode dynamic bash -l
```
### Key Features
- Interactive terminal forwarding (43-195)
- Interactive terminal forwarding: `58-289`
- Automatic shell alias support via ProcessUtils
- Session ID pre-generation support
- Terminal title management modes
- Graceful cleanup on exit
- Colorful output with chalk
### Title Modes
- `none`: No title management (default)
- `filter`: Block all title changes
- `static`: Show working directory and command
- `dynamic`: Show directory, command, and activity
### Integration Points
- Uses central PTY Manager (78-82)
- Control pipe handling delegated to PTY Manager
- Terminal resize synchronization (148-163)
- Raw mode for proper input capture (166-172)
- Uses central PTY Manager: `147-150`
- Terminal resize synchronization: `257-269`
- Raw mode for proper input capture: `276-277`
- Auto-detects Claude commands for dynamic titles: `135-143`
## Build System
### Main Build (`scripts/build.js`)
- Asset copying (7-121)
- Asset copying: `7-121`
- CSS compilation with Tailwind
- Client bundling with esbuild
- Server TypeScript compilation
- Native executable creation
### Native Binary (`scripts/build-native.js`)
- Node.js SEA integration (1-537)
- node-pty patching for compatibility (82-218)
- Node.js SEA integration: `1-537`
- node-pty patching for compatibility: `82-218`
- Outputs:
- `native/vibetunnel`: Main executable
- `native/pty.node`: Terminal emulation
@ -424,6 +486,7 @@ npx tsx src/fwd.ts --session-id abc123 bash -l
- `src/server/server.ts`: Server implementation
- `src/server/middleware/auth.ts`: Authentication
- `src/server/routes/sessions.ts`: Session API
- `src/server/routes/websocket-input.ts`: WebSocket input
- `src/server/pty/pty-manager.ts`: PTY management
- `src/server/services/terminal-manager.ts`: Terminal state
- `src/server/services/activity-monitor.ts`: Activity tracking
@ -435,6 +498,7 @@ npx tsx src/fwd.ts --session-id abc123 bash -l
- `src/client/components/terminal.ts`: Terminal renderer
- `src/client/components/session-view.ts`: Session viewer
- `src/client/services/buffer-subscription-service.ts`: WebSocket
- `src/client/services/websocket-input-client.ts`: Low-latency input
- `src/client/utils/cast-converter.ts`: Asciinema parser
### Configuration
@ -446,6 +510,7 @@ npx tsx src/fwd.ts --session-id abc123 bash -l
- REST API: Session CRUD, terminal I/O
- SSE: Real-time asciinema streaming
- WebSocket: Binary buffer updates
- WebSocket Input: Low-latency keyboard/mouse
- Control pipes: External session control
### Session Data Storage
@ -454,19 +519,20 @@ Each session has a directory in `~/.vibetunnel/control/[sessionId]/`:
- `stream-out`: Asciinema cast file
- `stdin`: Input pipe
- `control`: Control pipe
- `stdout`: Raw output file
- `activity.json`: Activity status
## Development Notes
### Recent Improvements
- Push notification support for terminal bells
- Multi-method authentication (SSH keys, JWT, PAM)
- Unified logging infrastructure with style guide
- Activity monitoring for all sessions
- Control directory watcher for external sessions
- Improved TypeScript types (no "as any")
- Colorful CLI output with chalk
- Auto-generation of security keys (VAPID)
- WebSocket input endpoint for lower latency
- Advanced terminal title management system
- Unified shutdown state management
- Atomic file writes for session data
- Split-screen support in web UI
- Enhanced activity monitoring with 100ms precision
- Improved command resolution for aliases
- Better error messages for spawn failures
### Testing
- Unit tests: `pnpm test`

View file

@ -1,6 +1,7 @@
import chalk from 'chalk';
import type { Response } from 'express';
import * as fs from 'fs';
import type { AsciinemaHeader } from '../pty/types.js';
import { createLogger } from '../utils/logger.js';
const logger = createLogger('stream-watcher');
@ -10,6 +11,34 @@ interface StreamClient {
startTime: number;
}
// Type for asciinema event array format
type AsciinemaOutputEvent = [number, 'o', string];
type AsciinemaInputEvent = [number, 'i', string];
type AsciinemaResizeEvent = [number, 'r', string];
type AsciinemaExitEvent = ['exit', number, string];
type AsciinemaEvent =
| AsciinemaOutputEvent
| AsciinemaInputEvent
| AsciinemaResizeEvent
| AsciinemaExitEvent;
// Type guard functions
function isOutputEvent(event: AsciinemaEvent): event is AsciinemaOutputEvent {
return (
Array.isArray(event) && event.length === 3 && event[1] === 'o' && typeof event[0] === 'number'
);
}
function isResizeEvent(event: AsciinemaEvent): event is AsciinemaResizeEvent {
return (
Array.isArray(event) && event.length === 3 && event[1] === 'r' && typeof event[0] === 'number'
);
}
function isExitEvent(event: AsciinemaEvent): event is AsciinemaExitEvent {
return Array.isArray(event) && event[0] === 'exit';
}
interface WatcherInfo {
clients: Set<StreamClient>;
watcher?: fs.FSWatcher;
@ -122,6 +151,149 @@ export class StreamWatcher {
* Send existing content to a client
*/
private sendExistingContent(streamPath: string, client: StreamClient): void {
try {
// First pass: analyze the stream to find the last clear and track resize events
const analysisStream = fs.createReadStream(streamPath, { encoding: 'utf8' });
let lineBuffer = '';
const events: AsciinemaEvent[] = [];
let lastClearIndex = -1;
let lastResizeBeforeClear: AsciinemaResizeEvent | null = null;
let currentResize: AsciinemaResizeEvent | null = null;
let header: AsciinemaHeader | null = null;
analysisStream.on('data', (chunk: string | Buffer) => {
lineBuffer += chunk.toString();
const lines = lineBuffer.split('\n');
lineBuffer = lines.pop() || ''; // Keep incomplete line for next chunk
for (const line of lines) {
if (line.trim()) {
try {
const parsed = JSON.parse(line);
if (parsed.version && parsed.width && parsed.height) {
header = parsed;
} else if (Array.isArray(parsed)) {
// Check if it's an exit event first
if (parsed[0] === 'exit') {
events.push(parsed as AsciinemaExitEvent);
} else if (parsed.length >= 3 && typeof parsed[0] === 'number') {
const event = parsed as AsciinemaEvent;
// Track resize events
if (isResizeEvent(event)) {
currentResize = event;
}
// Check for clear sequence in output events
if (isOutputEvent(event) && event[2].includes('\x1b[3J')) {
lastClearIndex = events.length;
lastResizeBeforeClear = currentResize;
logger.debug(
`found clear sequence at event index ${lastClearIndex}, current resize: ${currentResize ? currentResize[2] : 'none'}`
);
}
events.push(event);
}
}
} catch (e) {
logger.debug(`skipping invalid JSON line during analysis: ${e}`);
}
}
}
});
analysisStream.on('end', () => {
// Process any remaining line in analysis
if (lineBuffer.trim()) {
try {
const parsed = JSON.parse(lineBuffer);
if (Array.isArray(parsed)) {
if (parsed[0] === 'exit') {
events.push(parsed as AsciinemaExitEvent);
} else if (parsed.length >= 3 && typeof parsed[0] === 'number') {
const event = parsed as AsciinemaEvent;
if (isResizeEvent(event)) {
currentResize = event;
}
if (isOutputEvent(event) && event[2].includes('\x1b[3J')) {
lastClearIndex = events.length;
lastResizeBeforeClear = currentResize;
logger.debug(
`found clear sequence at event index ${lastClearIndex} (last event)`
);
}
events.push(event);
}
}
} catch (e) {
logger.debug(`skipping invalid JSON in line buffer during analysis: ${e}`);
}
}
// Now replay the stream with pruning
let startIndex = 0;
if (lastClearIndex >= 0) {
// Start from after the last clear
startIndex = lastClearIndex + 1;
logger.log(
chalk.green(`pruning stream: skipping ${lastClearIndex + 1} events before last clear`)
);
}
// Send header first - update dimensions if we have a resize
if (header) {
const headerToSend = { ...header };
if (lastClearIndex >= 0 && lastResizeBeforeClear) {
// Update header with last known dimensions before clear
const dimensions = lastResizeBeforeClear[2].split('x');
headerToSend.width = Number.parseInt(dimensions[0], 10);
headerToSend.height = Number.parseInt(dimensions[1], 10);
}
client.response.write(`data: ${JSON.stringify(headerToSend)}\n\n`);
}
// Send remaining events
let exitEventFound = false;
for (let i = startIndex; i < events.length; i++) {
const event = events[i];
if (isExitEvent(event)) {
exitEventFound = true;
client.response.write(`data: ${JSON.stringify(event)}\n\n`);
} else if (isOutputEvent(event) || isResizeEvent(event)) {
// Set timestamp to 0 for existing content
const instantEvent: AsciinemaEvent = [0, event[1], event[2]];
client.response.write(`data: ${JSON.stringify(instantEvent)}\n\n`);
}
}
// If exit event found, close connection
if (exitEventFound) {
logger.log(
chalk.yellow(
`session ${client.response.locals?.sessionId || 'unknown'} already ended, closing stream`
)
);
client.response.end();
}
});
analysisStream.on('error', (error) => {
logger.error('failed to analyze stream for pruning:', error);
// Fall back to original implementation without pruning
this.sendExistingContentWithoutPruning(streamPath, client);
});
} catch (error) {
logger.error('failed to create read stream:', error);
}
}
/**
* Original implementation without pruning (fallback)
*/
private sendExistingContentWithoutPruning(streamPath: string, client: StreamClient): void {
try {
const stream = fs.createReadStream(streamPath, { encoding: 'utf8' });
let exitEventFound = false;

View file

@ -81,7 +81,7 @@ export class PromptDetector {
// Check cache first
if (PromptDetector.onlyPromptCache.has(trimmed)) {
return PromptDetector.onlyPromptCache.get(trimmed)!;
return PromptDetector.onlyPromptCache.get(trimmed) ?? false;
}
const result = PROMPT_ONLY_REGEX.test(trimmed);
@ -103,7 +103,7 @@ export class PromptDetector {
// Check cache first
if (PromptDetector.endPromptCache.has(cacheKey)) {
return PromptDetector.endPromptCache.get(cacheKey)!;
return PromptDetector.endPromptCache.get(cacheKey) ?? false;
}
// Strip ANSI codes for more reliable detection

File diff suppressed because it is too large Load diff

View file

@ -157,3 +157,131 @@ export const mockUser = {
username: 'testuser',
token: mockAuthToken,
};
// Asciinema fixtures for stream pruning tests
export const mockAsciinemaWithClears = {
header: {
version: 2,
width: 120,
height: 40,
timestamp: 1704103200,
},
events: [
// Initial content
[0, 'o', 'Line 1: Initial content that will be cleared\\r\\n'],
[0.1, 'o', 'Line 2: More content\\r\\n'],
[0.2, 'o', 'Line 3: Even more content\\r\\n'],
[0.3, 'r', '80x24'], // Resize
[0.4, 'o', 'Line 4: Content after resize\\r\\n'],
[0.5, 'o', 'Line 5: More content to clear\\r\\n'],
// First clear
[0.6, 'o', '\u001b[3J'], // Clear scrollback
[0.7, 'o', '\u001b[H\u001b[2J'], // Home + clear screen
[0.8, 'o', 'Line 6: Content after first clear\\r\\n'],
[0.9, 'o', 'Line 7: More content\\r\\n'],
[1.0, 'r', '100x30'], // Another resize
[1.1, 'o', 'Line 8: Content with new dimensions\\r\\n'],
// Second clear (this should be the last one)
[1.2, 'o', '\u001b[2J\u001b[3J\u001b[H'], // Clear in one command
[1.3, 'o', 'Line 9: Final content that should remain\\r\\n'],
[1.4, 'o', 'Line 10: This should be visible\\r\\n'],
[1.5, 'o', 'Line 11: Last line\\r\\n'],
['exit', 0, 'test-session'],
],
};
export const mockAsciinemaWithClearMidLine = {
header: {
version: 2,
width: 80,
height: 24,
timestamp: 1704103200,
},
events: [
[0, 'o', 'Before clear'],
[0.1, 'o', 'This has a clear \u001b[3J in the middle'],
[0.2, 'o', 'After clear\\r\\n'],
['exit', 0, 'test-session'],
],
};
export const mockAsciinemaNoClears = {
header: {
version: 2,
width: 80,
height: 24,
timestamp: 1704103200,
},
events: [
[0, 'o', 'Line 1: No clears in this stream\\r\\n'],
[0.1, 'o', 'Line 2: Just regular content\\r\\n'],
[0.2, 'o', 'Line 3: Should replay everything\\r\\n'],
['exit', 0, 'test-session'],
],
};
// Real-world asciinema data from Claude session with multiple clears
export const mockRealWorldAsciinema = {
header: {
version: 2,
width: 80,
height: 24,
timestamp: 1751323457,
command: 'claude',
title: 'Claude session',
},
events: [
// Initial content before first clear
[1189.0, 'o', 'Some previous Claude output that will be cleared\\r\\n'],
[1189.1, 'o', 'More content before clear...\\r\\n'],
[1189.2, 'r', '179x60'], // Terminal resize
[1189.455, 'o', '\u001b[2J\u001b[3J\u001b[H'], // First clear
[1189.5, 'o', 'Content after first clear\\r\\n'],
[1190.0, 'o', 'More content...\\r\\n'],
[1190.325, 'o', '\u001b[2J\u001b[3J\u001b[H'], // Second clear
[1190.4, 'o', 'Content after second clear\\r\\n'],
// ... more content ...
[1361.0, 'o', 'Final Claude output before last clear\\r\\n'],
[1361.137, 'o', '\u001b[2J\u001b[3J\u001b[H'], // Third clear
[1361.2, 'o', 'Some content after third clear\\r\\n'],
[1362.0, 'r', '180x61'], // Another resize
[1362.264, 'o', '\u001b[2J\u001b[3J\u001b[H'], // Fourth and final clear
// Content after the last clear - this should be preserved
[
1362.29,
'o',
'\u001b[2K\u001b[1A\u001b[2K\u001b[1A\u001b[2K\u001b[1A\u001b[2K\u001b[G\u001b[38;2;215;119;87m╭───────────────────────────────────────────────────╮\u001b[39m\\r\\n',
],
[
1362.29,
'o',
'\u001b[38;2;215;119;87m│\u001b[39m \u001b[38;2;215;119;87m✻\u001b[39m Welcome to \u001b[1mClaude Code\u001b[22m! \u001b[38;2;215;119;87m│\u001b[39m\\r\\n',
],
[
1362.29,
'o',
'\u001b[38;2;215;119;87m│\u001b[39m \u001b[38;2;215;119;87m│\u001b[39m\\r\\n',
],
[
1362.29,
'o',
'\u001b[38;2;215;119;87m│\u001b[39m \u001b[3m\u001b[38;2;153;153;153m/help for help, /status for your current setup\u001b[39m\u001b[23m \u001b[38;2;215;119;87m│\u001b[39m\\r\\n',
],
[
1362.29,
'o',
'\u001b[38;2;215;119;87m│\u001b[39m \u001b[38;2;215;119;87m│\u001b[39m\\r\\n',
],
[
1362.29,
'o',
'\u001b[38;2;215;119;87m│\u001b[39m \u001b[38;2;153;153;153mcwd: /Users/badlogic/workspaces/vibetunnel/web\u001b[39m \u001b[38;2;215;119;87m│\u001b[39m\\r\\n',
],
[
1362.29,
'o',
'\u001b[38;2;215;119;87m╰───────────────────────────────────────────────────╯\u001b[39m\\r\\n\\r\\n\\r\\n ',
],
['exit', 0, 'claude-session'],
],
};

View file

@ -0,0 +1,204 @@
import type { Response } from 'express';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { AsciinemaHeader } from '../../server/pty/types.js';
import { StreamWatcher } from '../../server/services/stream-watcher.js';
import {
mockAsciinemaNoClears,
mockAsciinemaWithClearMidLine,
mockAsciinemaWithClears,
} from '../fixtures/test-data.js';
// Type for asciinema events used in tests
type TestAsciinemaEvent = [number | 'exit', string | number, string?];
describe('StreamWatcher - Asciinema Stream Pruning', () => {
let streamWatcher: StreamWatcher;
let tempDir: string;
let mockResponse: Partial<Response>;
let writtenData: string[] = [];
beforeEach(() => {
// Create temp directory for test files
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stream-pruning-test-'));
// Mock response object
writtenData = [];
mockResponse = {
write: vi.fn((data: string) => {
writtenData.push(data);
return true;
}),
end: vi.fn(),
locals: {},
};
streamWatcher = new StreamWatcher();
});
afterEach(() => {
// Clean up temp directory
fs.rmSync(tempDir, { recursive: true, force: true });
});
// Helper to create test asciinema file
function createTestFile(
filename: string,
header: AsciinemaHeader,
events: TestAsciinemaEvent[]
): string {
const filepath = path.join(tempDir, filename);
const lines = [JSON.stringify(header), ...events.map((event) => JSON.stringify(event))];
fs.writeFileSync(filepath, lines.join('\n'));
return filepath;
}
// Helper to parse SSE data
function parseSSEData(data: string[]): Array<AsciinemaHeader | TestAsciinemaEvent> {
return data
.filter((line) => line.startsWith('data: '))
.map((line) => {
const jsonStr = line.substring(6).trim();
try {
return JSON.parse(jsonStr);
} catch {
return null;
}
})
.filter(Boolean);
}
it('should prune content before the last clear sequence', async () => {
const filepath = createTestFile(
'with-clears.cast',
mockAsciinemaWithClears.header as AsciinemaHeader,
mockAsciinemaWithClears.events as TestAsciinemaEvent[]
);
// Use reflection to call private method
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
sendExistingContent(filepath, { response: mockResponse, startTime: Date.now() / 1000 });
// Wait for async operations to complete
await new Promise((resolve) => setTimeout(resolve, 200));
const events = parseSSEData(writtenData);
// Should have header + final content only
expect(events.length).toBeGreaterThan(0);
// First event should be header with updated dimensions
const header = events[0] as AsciinemaHeader;
expect(header.version).toBe(2);
expect(header.width).toBe(100); // From last resize before clear
expect(header.height).toBe(30);
// Should only have content after the last clear
const outputEvents = events.filter((e) => Array.isArray(e) && e[1] === 'o');
expect(outputEvents.length).toBe(3); // Lines 9, 10, 11
expect(outputEvents[0][2]).toContain('Line 9: Final content');
expect(outputEvents[1][2]).toContain('Line 10: This should be visible');
expect(outputEvents[2][2]).toContain('Line 11: Last line');
// Should have exit event
const exitEvent = events.find((e) => Array.isArray(e) && e[0] === 'exit');
expect(exitEvent).toBeDefined();
});
it('should handle clear sequence in middle of line', async () => {
const filepath = createTestFile(
'clear-mid-line.cast',
mockAsciinemaWithClearMidLine.header as AsciinemaHeader,
mockAsciinemaWithClearMidLine.events as TestAsciinemaEvent[]
);
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
sendExistingContent(filepath, { response: mockResponse, startTime: Date.now() / 1000 });
await new Promise((resolve) => setTimeout(resolve, 200));
const events = parseSSEData(writtenData);
// Should only have content after the clear
const outputEvents = events.filter((e) => Array.isArray(e) && e[1] === 'o');
expect(outputEvents.length).toBe(1); // Only "After clear"
expect(outputEvents[0][2]).toContain('After clear');
});
it('should not prune streams without clear sequences', async () => {
const filepath = createTestFile(
'no-clears.cast',
mockAsciinemaNoClears.header as AsciinemaHeader,
mockAsciinemaNoClears.events as TestAsciinemaEvent[]
);
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
sendExistingContent(filepath, { response: mockResponse, startTime: Date.now() / 1000 });
await new Promise((resolve) => setTimeout(resolve, 200));
const events = parseSSEData(writtenData);
// Should have all events
const outputEvents = events.filter((e) => Array.isArray(e) && e[1] === 'o');
expect(outputEvents.length).toBe(3); // All 3 lines
expect(outputEvents[0][2]).toContain('Line 1: No clears');
expect(outputEvents[1][2]).toContain('Line 2: Just regular');
expect(outputEvents[2][2]).toContain('Line 3: Should replay');
});
it('should fall back to non-pruning on read errors', async () => {
const nonExistentPath = path.join(tempDir, 'does-not-exist.cast');
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
sendExistingContent(nonExistentPath, { response: mockResponse, startTime: Date.now() / 1000 });
await new Promise((resolve) => setTimeout(resolve, 200));
// Should have called the fallback method (no data written due to missing file)
expect(writtenData.length).toBe(0);
});
it('should handle real-world Claude session with multiple clears', async () => {
// Use the actual real-world cast file
const filepath = path.join(__dirname, '../fixtures/asciinema/real-world-claude-session.cast');
// biome-ignore lint/suspicious/noExplicitAny: accessing private method for testing
const sendExistingContent = (streamWatcher as any).sendExistingContent.bind(streamWatcher);
sendExistingContent(filepath, { response: mockResponse, startTime: Date.now() / 1000 });
await new Promise((resolve) => setTimeout(resolve, 200));
const events = parseSSEData(writtenData);
// Should have pruned everything before the last clear
expect(events.length).toBeGreaterThan(0);
// First event should be header
const header = events[0] as AsciinemaHeader;
expect(header.version).toBe(2);
// Check that we're getting content after the last clear
const outputEvents = events.filter((e) => Array.isArray(e) && e[1] === 'o');
expect(outputEvents.length).toBeGreaterThan(0);
// The real file has 4 clear sequences, we should only see content after the last one
// Check that we have the welcome banner (appears after the last clear)
const welcomeContent = outputEvents.map((e) => e[2]).join('');
// Strip ANSI escape sequences for easier testing
// biome-ignore lint/suspicious/noControlCharactersInRegex: ANSI escape sequences are necessary for terminal output
const cleanContent = welcomeContent.replace(/\u001b\[[^m]*m/g, '');
expect(cleanContent).toContain('Welcome to Claude Code');
expect(cleanContent).toContain('/help for help');
// We should NOT see content from before the clears
expect(cleanContent).not.toContain('Some previous Claude output');
expect(cleanContent).not.toContain('cd workspaces'); // This was at the beginning
});
});