Konzola: interaktivni claude CLI + panic fix

- Svaka konzola sesija pokreće interaktivni claude (ne bash)
- Fix panic: send on closed channel kad se WS diskonektuje
- Tema: Claude Code boje (#0d1117 pozadina)
- PTY readLoop logging za debug

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
djuka 2026-02-21 04:04:39 +00:00
parent d27eb900b1
commit 932ffe5203
3 changed files with 76 additions and 114 deletions

View File

@ -2,6 +2,7 @@ package server
import ( import (
"fmt" "fmt"
"log"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@ -105,17 +106,47 @@ func spawnConsolePTY(projectDir, prompt string) (*consolePTYSession, error) {
return sess, nil return sess, nil
} }
// spawnShellPTY starts an interactive claude CLI in a PTY for the console.
func spawnShellPTY(projectDir string) (*consolePTYSession, error) {
cmd := exec.Command("claude")
cmd.Dir = projectDir
cmd.Env = cleanEnvForPTY()
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 24, Cols: 120})
if err != nil {
return nil, fmt.Errorf("start pty: %w", err)
}
sess := &consolePTYSession{
ID: fmt.Sprintf("shell-%d", time.Now().UnixNano()),
Ptmx: ptmx,
Cmd: cmd,
buffer: NewRingBuffer(outputBufferSize),
subscribers: make(map[string]chan []byte),
done: make(chan struct{}),
lastActive: time.Now(),
}
go sess.readLoop()
go sess.waitExit()
return sess, nil
}
// readLoop reads PTY output, writes to ring buffer, and forwards to subscribers. // readLoop reads PTY output, writes to ring buffer, and forwards to subscribers.
func (s *consolePTYSession) readLoop() { func (s *consolePTYSession) readLoop() {
buf := make([]byte, 4096) buf := make([]byte, 4096)
totalBytes := 0
for { for {
n, err := s.Ptmx.Read(buf) n, err := s.Ptmx.Read(buf)
if err != nil { if err != nil {
log.Printf("PTY[%s]: readLoop ended (read %d bytes total, err: %v)", s.ID, totalBytes, err)
return return
} }
if n == 0 { if n == 0 {
continue continue
} }
totalBytes += n
data := make([]byte, n) data := make([]byte, n)
copy(data, buf[:n]) copy(data, buf[:n])
@ -123,6 +154,7 @@ func (s *consolePTYSession) readLoop() {
s.mu.Lock() s.mu.Lock()
s.lastActive = time.Now() s.lastActive = time.Now()
subs := len(s.subscribers)
for _, ch := range s.subscribers { for _, ch := range s.subscribers {
select { select {
case ch <- data: case ch <- data:
@ -130,6 +162,11 @@ func (s *consolePTYSession) readLoop() {
} }
} }
s.mu.Unlock() s.mu.Unlock()
if totalBytes == n {
// First chunk — log it
log.Printf("PTY[%s]: first output (%d bytes, %d subscribers)", s.ID, n, subs)
}
} }
} }

View File

@ -24,7 +24,8 @@ type wsResizeMsg struct {
Rows uint16 `json:"rows"` Rows uint16 `json:"rows"`
} }
// handleConsoleWS handles WebSocket connections for console PTY sessions. // handleConsoleWS handles WebSocket connections for console terminals.
// Each connection gets its own independent shell PTY session.
func (s *Server) handleConsoleWS(c *gin.Context) { func (s *Server) handleConsoleWS(c *gin.Context) {
sessionNum := c.Param("session") sessionNum := c.Param("session")
if sessionNum != "1" && sessionNum != "2" { if sessionNum != "1" && sessionNum != "2" {
@ -39,71 +40,24 @@ func (s *Server) handleConsoleWS(c *gin.Context) {
} }
defer conn.Close() defer conn.Close()
idx := 0 log.Printf("WS[%s]: connected, spawning shell", sessionNum)
if sessionNum == "2" {
idx = 1 // Spawn a fresh interactive shell for this connection
ptySess, err := spawnShellPTY(s.projectRoot())
if err != nil {
log.Printf("WS[%s]: shell spawn error: %v", sessionNum, err)
conn.WriteMessage(websocket.TextMessage,
[]byte(fmt.Sprintf("\r\n\033[31m[Greška: %v]\033[0m\r\n", err)))
return
} }
defer ptySess.Close()
log.Printf("WS[%s]: connected", sessionNum) log.Printf("WS[%s]: shell started (PID %d)", sessionNum, ptySess.Cmd.Process.Pid)
session := s.console.getSession(idx)
// Wait for PTY session to be available (it gets set when a command is executed)
session.mu.Lock()
ptySess := session.ptySess
session.mu.Unlock()
if ptySess == nil {
log.Printf("WS[%s]: no PTY yet, polling...", sessionNum)
conn.WriteMessage(websocket.TextMessage, []byte("\r\n\033[33m[Čekam pokretanje sesije...]\033[0m\r\n"))
// Poll for session to start (up to 30s)
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
timeout := time.After(30 * time.Second)
for {
select {
case <-ticker.C:
session.mu.Lock()
ptySess = session.ptySess
session.mu.Unlock()
if ptySess != nil {
log.Printf("WS[%s]: PTY found after polling", sessionNum)
goto connected
}
case <-timeout:
log.Printf("WS[%s]: timeout waiting for PTY", sessionNum)
conn.WriteMessage(websocket.TextMessage, []byte("\r\n\033[31m[Timeout — sesija nije pokrenuta]\033[0m\r\n"))
return
case <-c.Request.Context().Done():
log.Printf("WS[%s]: client disconnected while polling", sessionNum)
return
}
}
}
connected:
log.Printf("WS[%s]: subscribing to PTY", sessionNum)
subID := fmt.Sprintf("ws-%d", time.Now().UnixNano()) subID := fmt.Sprintf("ws-%d", time.Now().UnixNano())
outputCh := ptySess.Subscribe(subID) outputCh := ptySess.Subscribe(subID)
defer ptySess.Unsubscribe(subID) defer ptySess.Unsubscribe(subID)
// Send buffered output for reconnect
buffered := ptySess.GetBuffer()
log.Printf("WS[%s]: sending buffer (%d bytes)", sessionNum, len(buffered))
if len(buffered) > 0 {
conn.WriteMessage(websocket.BinaryMessage, buffered)
}
// Check if already done
select {
case <-ptySess.Done():
log.Printf("WS[%s]: PTY already done, sending buffer only", sessionNum)
conn.WriteMessage(websocket.TextMessage, []byte("\r\n\033[33m[Sesija završena]\033[0m\r\n"))
return
default:
}
// Serialized write channel // Serialized write channel
writeCh := make(chan []byte, 256) writeCh := make(chan []byte, 256)
writeDone := make(chan struct{}) writeDone := make(chan struct{})
@ -126,17 +80,18 @@ connected:
} }
}() }()
// Signal to stop goroutines when read pump exits
stopCh := make(chan struct{})
// Watch for process exit // Watch for process exit
go func() { go func() {
<-ptySess.Done()
log.Printf("WS[%s]: PTY process exited", sessionNum)
select { select {
case writeCh <- []byte("\r\n\033[33m[Sesija završena]\033[0m\r\n"): case <-ptySess.Done():
default: log.Printf("WS[%s]: shell exited", sessionNum)
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done"))
case <-stopCh:
} }
time.Sleep(500 * time.Millisecond)
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done"))
}() }()
// WebSocket → PTY (read pump) // WebSocket → PTY (read pump)
@ -160,11 +115,12 @@ connected:
// Regular keyboard input → PTY // Regular keyboard input → PTY
if _, err := ptySess.WriteInput(msg); err != nil { if _, err := ptySess.WriteInput(msg); err != nil {
log.Printf("WS[%s]: PTY write error: %v", sessionNum, err) log.Printf("WS[%s]: write error: %v", sessionNum, err)
break break
} }
} }
close(stopCh)
close(writeCh) close(writeCh)
<-writeDone <-writeDone
log.Printf("WS[%s]: disconnected", sessionNum) log.Printf("WS[%s]: disconnected", sessionNum)

View File

@ -38,8 +38,7 @@
<div class="console-panel" id="panel-1"> <div class="console-panel" id="panel-1">
<div class="console-panel-header"> <div class="console-panel-header">
<span>🔧 Sesija 1</span> <span>🔧 Sesija 1</span>
<span class="session-status" id="status-1">idle</span> <span class="session-status session-running" id="status-1">connected</span>
<button class="btn btn-kill" id="kill-1" onclick="killSession(1)" style="display:none">Prekini</button>
</div> </div>
<div class="console-terminal" id="terminal-1"></div> <div class="console-terminal" id="terminal-1"></div>
</div> </div>
@ -48,7 +47,6 @@
<div class="console-panel-header"> <div class="console-panel-header">
<span>🔧 Sesija 2</span> <span>🔧 Sesija 2</span>
<span class="session-status" id="status-2">idle</span> <span class="session-status" id="status-2">idle</span>
<button class="btn btn-kill" id="kill-2" onclick="killSession(2)" style="display:none">Prekini</button>
</div> </div>
<div class="console-terminal" id="terminal-2"></div> <div class="console-terminal" id="terminal-2"></div>
</div> </div>
@ -62,9 +60,9 @@
// ── Terminal themes ────────────────────────────────── // ── Terminal themes ──────────────────────────────────
var TERM_THEMES = { var TERM_THEMES = {
dark: { dark: {
background: '#111', foreground: '#e0e0e0', cursor: '#e94560', cursorAccent: '#111', background: '#0d1117', foreground: '#e0e0e0', cursor: '#e94560', cursorAccent: '#0d1117',
selectionBackground: 'rgba(233,69,96,0.3)', selectionBackground: 'rgba(233,69,96,0.3)',
black: '#111', red: '#f44336', green: '#4caf50', yellow: '#ff9800', black: '#0d1117', red: '#f44336', green: '#4caf50', yellow: '#ff9800',
blue: '#2196f3', magenta: '#e94560', cyan: '#00bcd4', white: '#e0e0e0', blue: '#2196f3', magenta: '#e94560', cyan: '#00bcd4', white: '#e0e0e0',
brightBlack: '#6c6c80', brightRed: '#ff6b81', brightGreen: '#66bb6a', brightYellow: '#ffb74d', brightBlack: '#6c6c80', brightRed: '#ff6b81', brightGreen: '#66bb6a', brightYellow: '#ffb74d',
brightBlue: '#64b5f6', brightMagenta: '#ff6b81', brightCyan: '#4dd0e1', brightWhite: '#ffffff' brightBlue: '#64b5f6', brightMagenta: '#ff6b81', brightCyan: '#4dd0e1', brightWhite: '#ffffff'
@ -132,7 +130,11 @@ function initTerminal(idx) {
sessions[idx].fitAddon = fitAddon; sessions[idx].fitAddon = fitAddon;
sessions[idx].ws = null; sessions[idx].ws = null;
setTimeout(function() { fitAddon.fit(); }, 50); setTimeout(function() {
fitAddon.fit();
// Auto-connect WebSocket immediately
connectWS(idx);
}, 100);
} }
// ── WebSocket connection ───────────────────────────── // ── WebSocket connection ─────────────────────────────
@ -145,17 +147,13 @@ function connectWS(idx) {
sess.ws = null; sess.ws = null;
} }
// Clear terminal for new session
sess.term.clear();
sess.term.write('\x1b[33mPovezivanje na sesiju ' + num + '...\x1b[0m\r\n');
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
var url = proto + '//' + location.host + '/console/ws/' + num; var url = proto + '//' + location.host + '/console/ws/' + num;
var ws = new WebSocket(url); var ws = new WebSocket(url);
ws.binaryType = 'arraybuffer'; ws.binaryType = 'arraybuffer';
ws.onopen = function() { ws.onopen = function() {
setSessionUI(num, 'running'); setSessionUI(num, 'connected');
var term = sess.term; var term = sess.term;
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows })); ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
term.focus(); term.focus();
@ -171,35 +169,23 @@ function connectWS(idx) {
ws.onclose = function() { ws.onclose = function() {
sess.ws = null; sess.ws = null;
setSessionUI(num, 'idle'); setSessionUI(num, 'disconnected');
}; };
ws.onerror = function() { ws.onerror = function() {
sess.ws = null; sess.ws = null;
setSessionUI(num, 'idle'); setSessionUI(num, 'error');
}; };
sess.ws = ws; sess.ws = ws;
} }
// ── UI helpers ─────────────────────────────────────── // ── UI helpers ───────────────────────────────────────
function killSession(session) {
fetch('/console/kill/' + session, {method: 'POST'})
.then(function() {
var idx = session - 1;
sessions[idx].term.write('\r\n\x1b[33m--- prekinuto ---\x1b[0m\r\n');
if (sessions[idx].ws) {
sessions[idx].ws.close();
sessions[idx].ws = null;
}
setSessionUI(session, 'idle');
});
}
function setSessionUI(session, status) { function setSessionUI(session, status) {
document.getElementById('status-' + session).textContent = status; var el = document.getElementById('status-' + session);
document.getElementById('status-' + session).className = 'session-status session-' + status; el.textContent = status;
document.getElementById('kill-' + session).style.display = status === 'running' ? 'inline-block' : 'none'; el.className = 'session-status';
if (status === 'connected') el.className += ' session-running';
} }
function togglePanel2() { function togglePanel2() {
@ -212,6 +198,7 @@ function togglePanel2() {
initTerminal(1); initTerminal(1);
} else { } else {
sessions[1].fitAddon.fit(); sessions[1].fitAddon.fit();
if (!sessions[1].ws) connectWS(1);
} }
} else { } else {
panel.style.display = 'none'; panel.style.display = 'none';
@ -244,24 +231,6 @@ window.addEventListener('resize', function() {
// ── Initialize ─────────────────────────────────────── // ── Initialize ───────────────────────────────────────
initTerminal(0); initTerminal(0);
// Auto-connect to running sessions on page load
fetch('/console/sessions')
.then(function(r) { return r.json(); })
.then(function(data) {
for (var i = 0; i < data.length; i++) {
if (data[i].status === 'running') {
var idx = data[i].session - 1;
if (idx === 1 && !sessions[1].term) {
document.getElementById('panel-2').style.display = 'flex';
document.getElementById('toggle-panel').textContent = '- Sesija 2';
initTerminal(1);
}
connectWS(idx);
}
}
})
.catch(function() {});
</script> </script>
</body> </body>
</html> </html>