feat: remove npm warns from terminal + fix terminal render on refresh (#104)
This commit is contained in:
@@ -92,6 +92,9 @@ type interactiveProcess struct {
|
||||
directOutput DirectOutputWriter
|
||||
directOutputMu sync.RWMutex
|
||||
|
||||
// WebSocket tracking - tracks whether a WebSocket is actively connected
|
||||
hasActiveWebSocket bool
|
||||
|
||||
// Lifecycle
|
||||
stopOnce sync.Once
|
||||
stopSignal chan struct{}
|
||||
@@ -112,6 +115,13 @@ type StatusCallback func(status *types.ProcessStatusUpdate)
|
||||
// AgentStateCallback is called when agent TUI state changes (working, waiting, etc.).
|
||||
type AgentStateCallback func(sessionID string, state AgentState)
|
||||
|
||||
// sessionWebSocket tracks a WebSocket connection at the session level.
|
||||
// This allows the WebSocket to survive process restarts.
|
||||
type sessionWebSocket struct {
|
||||
writer DirectOutputWriter
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// InteractiveRunner manages interactive PTY-based processes with stdin support.
|
||||
type InteractiveRunner struct {
|
||||
logger *logger.Logger
|
||||
@@ -124,6 +134,10 @@ type InteractiveRunner struct {
|
||||
|
||||
mu sync.RWMutex
|
||||
processes map[string]*interactiveProcess
|
||||
|
||||
// Session-level WebSocket tracking - survives process restarts
|
||||
sessionWsMu sync.RWMutex
|
||||
sessionWs map[string]*sessionWebSocket
|
||||
}
|
||||
|
||||
// NewInteractiveRunner creates a new interactive process runner.
|
||||
@@ -133,6 +147,7 @@ func NewInteractiveRunner(workspaceTracker *WorkspaceTracker, log *logger.Logger
|
||||
workspaceTracker: workspaceTracker,
|
||||
bufferMaxBytes: bufferMaxBytes,
|
||||
processes: make(map[string]*interactiveProcess),
|
||||
sessionWs: make(map[string]*sessionWebSocket),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,13 +677,21 @@ func (r *InteractiveRunner) readOutput(proc *interactiveProcess) {
|
||||
}
|
||||
}
|
||||
|
||||
// Always buffer output for scrollback restoration on reconnect
|
||||
chunk := ProcessOutputChunk{
|
||||
Stream: "stdout",
|
||||
Data: dataStr,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
proc.buffer.append(chunk)
|
||||
|
||||
// Check if we have a direct output writer (binary WebSocket mode)
|
||||
proc.directOutputMu.RLock()
|
||||
directWriter := proc.directOutput
|
||||
proc.directOutputMu.RUnlock()
|
||||
|
||||
if directWriter != nil {
|
||||
// Direct mode: write raw bytes to the WebSocket, skip event bus
|
||||
// Direct mode: write raw bytes to the WebSocket
|
||||
if _, writeErr := directWriter.Write(data); writeErr != nil {
|
||||
r.logger.Debug("direct output write error",
|
||||
zap.String("process_id", proc.info.ID),
|
||||
@@ -676,13 +699,7 @@ func (r *InteractiveRunner) readOutput(proc *interactiveProcess) {
|
||||
// Don't return - the writer might have closed but process continues
|
||||
}
|
||||
} else {
|
||||
// Normal mode: buffer and stream output via event bus
|
||||
chunk := ProcessOutputChunk{
|
||||
Stream: "stdout",
|
||||
Data: dataStr,
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
proc.buffer.append(chunk)
|
||||
// No WebSocket connected: also publish via event bus
|
||||
r.publishOutput(proc, chunk)
|
||||
}
|
||||
|
||||
@@ -922,6 +939,7 @@ func (p *interactiveProcess) snapshot(includeOutput bool) InteractiveProcessInfo
|
||||
// SetDirectOutput sets a direct output writer for a process.
|
||||
// When set, PTY output bypasses the event bus and goes directly to this writer.
|
||||
// This is used for the dedicated binary WebSocket in passthrough mode.
|
||||
// Also tracks the WebSocket at the session level to survive process restarts.
|
||||
// Returns error if process not found.
|
||||
func (r *InteractiveRunner) SetDirectOutput(processID string, writer DirectOutputWriter) error {
|
||||
proc, ok := r.get(processID)
|
||||
@@ -929,36 +947,119 @@ func (r *InteractiveRunner) SetDirectOutput(processID string, writer DirectOutpu
|
||||
return fmt.Errorf("process not found: %s", processID)
|
||||
}
|
||||
|
||||
sessionID := proc.info.SessionID
|
||||
|
||||
proc.directOutputMu.Lock()
|
||||
proc.directOutput = writer
|
||||
proc.hasActiveWebSocket = true
|
||||
proc.directOutputMu.Unlock()
|
||||
|
||||
// Also track at session level for restart support
|
||||
r.sessionWsMu.Lock()
|
||||
r.sessionWs[sessionID] = &sessionWebSocket{writer: writer}
|
||||
r.sessionWsMu.Unlock()
|
||||
|
||||
r.logger.Info("direct output set for process",
|
||||
zap.String("process_id", processID),
|
||||
zap.String("session_id", proc.info.SessionID))
|
||||
zap.String("session_id", sessionID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearDirectOutput clears the direct output writer for a process.
|
||||
// Output will return to the normal event bus path.
|
||||
// Also clears the session-level WebSocket tracking.
|
||||
func (r *InteractiveRunner) ClearDirectOutput(processID string) error {
|
||||
proc, ok := r.get(processID)
|
||||
if !ok {
|
||||
return fmt.Errorf("process not found: %s", processID)
|
||||
// Process may have been deleted - still try to clear session tracking
|
||||
r.logger.Debug("process not found during ClearDirectOutput, trying to clear by session",
|
||||
zap.String("process_id", processID))
|
||||
return nil
|
||||
}
|
||||
|
||||
sessionID := proc.info.SessionID
|
||||
|
||||
proc.directOutputMu.Lock()
|
||||
proc.directOutput = nil
|
||||
proc.hasActiveWebSocket = false
|
||||
proc.directOutputMu.Unlock()
|
||||
|
||||
// Also clear session-level tracking
|
||||
r.sessionWsMu.Lock()
|
||||
delete(r.sessionWs, sessionID)
|
||||
r.sessionWsMu.Unlock()
|
||||
|
||||
r.logger.Info("direct output cleared for process",
|
||||
zap.String("process_id", processID),
|
||||
zap.String("session_id", proc.info.SessionID))
|
||||
zap.String("session_id", sessionID))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearDirectOutputBySession clears the direct output for a session.
|
||||
// This is used when the terminal WebSocket disconnects.
|
||||
func (r *InteractiveRunner) ClearDirectOutputBySession(sessionID string) {
|
||||
// Clear session-level tracking
|
||||
r.sessionWsMu.Lock()
|
||||
delete(r.sessionWs, sessionID)
|
||||
r.sessionWsMu.Unlock()
|
||||
|
||||
// Also clear from any process with this session
|
||||
r.mu.RLock()
|
||||
for _, proc := range r.processes {
|
||||
if proc.info.SessionID == sessionID {
|
||||
proc.directOutputMu.Lock()
|
||||
proc.directOutput = nil
|
||||
proc.hasActiveWebSocket = false
|
||||
proc.directOutputMu.Unlock()
|
||||
}
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
r.logger.Info("direct output cleared for session",
|
||||
zap.String("session_id", sessionID))
|
||||
}
|
||||
|
||||
// ConnectSessionWebSocket connects an existing session WebSocket to a process.
|
||||
// This is called when a new process starts for a session that already has an active WebSocket.
|
||||
// Returns true if a WebSocket was connected.
|
||||
func (r *InteractiveRunner) ConnectSessionWebSocket(processID string) bool {
|
||||
proc, ok := r.get(processID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
sessionID := proc.info.SessionID
|
||||
|
||||
r.sessionWsMu.RLock()
|
||||
sessWs, exists := r.sessionWs[sessionID]
|
||||
r.sessionWsMu.RUnlock()
|
||||
|
||||
if !exists || sessWs == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
sessWs.mu.RLock()
|
||||
writer := sessWs.writer
|
||||
sessWs.mu.RUnlock()
|
||||
|
||||
if writer == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
proc.directOutputMu.Lock()
|
||||
proc.directOutput = writer
|
||||
proc.hasActiveWebSocket = true
|
||||
proc.directOutputMu.Unlock()
|
||||
|
||||
r.logger.Info("connected existing session WebSocket to new process",
|
||||
zap.String("process_id", processID),
|
||||
zap.String("session_id", sessionID))
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// GetPtyWriter returns a writer for sending input to the PTY.
|
||||
// This is used for the dedicated binary WebSocket in passthrough mode.
|
||||
func (r *InteractiveRunner) GetPtyWriter(processID string) (io.Writer, error) {
|
||||
@@ -981,6 +1082,113 @@ func (r *InteractiveRunner) GetPtyWriter(processID string) (io.Writer, error) {
|
||||
return proc.ptmx, nil
|
||||
}
|
||||
|
||||
// GetPtyWriterBySession returns a writer for sending input to the PTY for a session.
|
||||
// This is used to reconnect after a process restart.
|
||||
func (r *InteractiveRunner) GetPtyWriterBySession(sessionID string) (io.Writer, string, error) {
|
||||
r.mu.RLock()
|
||||
var proc *interactiveProcess
|
||||
for _, p := range r.processes {
|
||||
if p.info.SessionID == sessionID {
|
||||
proc = p
|
||||
break
|
||||
}
|
||||
}
|
||||
r.mu.RUnlock()
|
||||
|
||||
if proc == nil {
|
||||
return nil, "", fmt.Errorf("no process found for session: %s", sessionID)
|
||||
}
|
||||
|
||||
proc.mu.Lock()
|
||||
defer proc.mu.Unlock()
|
||||
|
||||
if !proc.started {
|
||||
return nil, proc.info.ID, fmt.Errorf("process not started yet - waiting for terminal dimensions")
|
||||
}
|
||||
|
||||
if proc.ptmx == nil {
|
||||
return nil, proc.info.ID, fmt.Errorf("PTY not available")
|
||||
}
|
||||
|
||||
return proc.ptmx, proc.info.ID, nil
|
||||
}
|
||||
|
||||
// HasActiveWebSocket checks if a process has an active WebSocket connection.
|
||||
// This is used to determine if auto-restart should be attempted on process exit.
|
||||
func (r *InteractiveRunner) HasActiveWebSocket(processID string) bool {
|
||||
proc, ok := r.get(processID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
proc.directOutputMu.RLock()
|
||||
defer proc.directOutputMu.RUnlock()
|
||||
return proc.hasActiveWebSocket
|
||||
}
|
||||
|
||||
// HasActiveWebSocketBySession checks if a session has an active WebSocket connection.
|
||||
// Uses session-level tracking which survives process restarts.
|
||||
func (r *InteractiveRunner) HasActiveWebSocketBySession(sessionID string) bool {
|
||||
r.sessionWsMu.RLock()
|
||||
sessWs, exists := r.sessionWs[sessionID]
|
||||
r.sessionWsMu.RUnlock()
|
||||
|
||||
if !exists || sessWs == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
sessWs.mu.RLock()
|
||||
hasWriter := sessWs.writer != nil
|
||||
sessWs.mu.RUnlock()
|
||||
|
||||
return hasWriter
|
||||
}
|
||||
|
||||
// WriteToDirectOutput writes data directly to the WebSocket output for a process.
|
||||
// This is used to send messages like restart notifications to the terminal.
|
||||
// Returns error if process not found or no direct output is set.
|
||||
func (r *InteractiveRunner) WriteToDirectOutput(processID string, data []byte) error {
|
||||
proc, ok := r.get(processID)
|
||||
if !ok {
|
||||
return fmt.Errorf("process not found: %s", processID)
|
||||
}
|
||||
|
||||
proc.directOutputMu.RLock()
|
||||
directWriter := proc.directOutput
|
||||
proc.directOutputMu.RUnlock()
|
||||
|
||||
if directWriter == nil {
|
||||
return fmt.Errorf("no direct output writer set for process: %s", processID)
|
||||
}
|
||||
|
||||
_, err := directWriter.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// WriteToDirectOutputBySession writes data directly to the WebSocket output for a session.
|
||||
// This is used to send messages like restart notifications to the terminal.
|
||||
// Uses session-level tracking which survives process restarts.
|
||||
func (r *InteractiveRunner) WriteToDirectOutputBySession(sessionID string, data []byte) error {
|
||||
r.sessionWsMu.RLock()
|
||||
sessWs, exists := r.sessionWs[sessionID]
|
||||
r.sessionWsMu.RUnlock()
|
||||
|
||||
if !exists || sessWs == nil {
|
||||
return fmt.Errorf("no WebSocket found for session: %s", sessionID)
|
||||
}
|
||||
|
||||
sessWs.mu.RLock()
|
||||
writer := sessWs.writer
|
||||
sessWs.mu.RUnlock()
|
||||
|
||||
if writer == nil {
|
||||
return fmt.Errorf("no direct output writer set for session: %s", sessionID)
|
||||
}
|
||||
|
||||
_, err := writer.Write(data)
|
||||
return err
|
||||
}
|
||||
|
||||
// containsDSRQuery checks if data contains a Device Status Report (cursor position) query.
|
||||
// DSR query is: ESC [ 6 n or ESC [ ? 6 n
|
||||
func containsDSRQuery(data []byte) bool {
|
||||
|
||||
Reference in New Issue
Block a user