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:
parent
d27eb900b1
commit
932ffe5203
@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
@ -105,17 +106,47 @@ func spawnConsolePTY(projectDir, prompt string) (*consolePTYSession, error) {
|
||||
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.
|
||||
func (s *consolePTYSession) readLoop() {
|
||||
buf := make([]byte, 4096)
|
||||
totalBytes := 0
|
||||
for {
|
||||
n, err := s.Ptmx.Read(buf)
|
||||
if err != nil {
|
||||
log.Printf("PTY[%s]: readLoop ended (read %d bytes total, err: %v)", s.ID, totalBytes, err)
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
totalBytes += n
|
||||
data := make([]byte, n)
|
||||
copy(data, buf[:n])
|
||||
|
||||
@ -123,6 +154,7 @@ func (s *consolePTYSession) readLoop() {
|
||||
|
||||
s.mu.Lock()
|
||||
s.lastActive = time.Now()
|
||||
subs := len(s.subscribers)
|
||||
for _, ch := range s.subscribers {
|
||||
select {
|
||||
case ch <- data:
|
||||
@ -130,6 +162,11 @@ func (s *consolePTYSession) readLoop() {
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if totalBytes == n {
|
||||
// First chunk — log it
|
||||
log.Printf("PTY[%s]: first output (%d bytes, %d subscribers)", s.ID, n, subs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -24,7 +24,8 @@ type wsResizeMsg struct {
|
||||
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) {
|
||||
sessionNum := c.Param("session")
|
||||
if sessionNum != "1" && sessionNum != "2" {
|
||||
@ -39,71 +40,24 @@ func (s *Server) handleConsoleWS(c *gin.Context) {
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
idx := 0
|
||||
if sessionNum == "2" {
|
||||
idx = 1
|
||||
}
|
||||
log.Printf("WS[%s]: connected, spawning shell", sessionNum)
|
||||
|
||||
log.Printf("WS[%s]: connected", sessionNum)
|
||||
|
||||
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)
|
||||
// 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]: shell started (PID %d)", sessionNum, ptySess.Cmd.Process.Pid)
|
||||
|
||||
connected:
|
||||
log.Printf("WS[%s]: subscribing to PTY", sessionNum)
|
||||
subID := fmt.Sprintf("ws-%d", time.Now().UnixNano())
|
||||
outputCh := ptySess.Subscribe(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
|
||||
writeCh := make(chan []byte, 256)
|
||||
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
|
||||
go func() {
|
||||
<-ptySess.Done()
|
||||
log.Printf("WS[%s]: PTY process exited", sessionNum)
|
||||
select {
|
||||
case writeCh <- []byte("\r\n\033[33m[Sesija završena]\033[0m\r\n"):
|
||||
default:
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
case <-ptySess.Done():
|
||||
log.Printf("WS[%s]: shell exited", sessionNum)
|
||||
conn.WriteMessage(websocket.CloseMessage,
|
||||
websocket.FormatCloseMessage(websocket.CloseNormalClosure, "done"))
|
||||
case <-stopCh:
|
||||
}
|
||||
}()
|
||||
|
||||
// WebSocket → PTY (read pump)
|
||||
@ -160,11 +115,12 @@ connected:
|
||||
|
||||
// Regular keyboard input → PTY
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
close(stopCh)
|
||||
close(writeCh)
|
||||
<-writeDone
|
||||
log.Printf("WS[%s]: disconnected", sessionNum)
|
||||
|
||||
@ -38,8 +38,7 @@
|
||||
<div class="console-panel" id="panel-1">
|
||||
<div class="console-panel-header">
|
||||
<span>🔧 Sesija 1</span>
|
||||
<span class="session-status" id="status-1">idle</span>
|
||||
<button class="btn btn-kill" id="kill-1" onclick="killSession(1)" style="display:none">Prekini</button>
|
||||
<span class="session-status session-running" id="status-1">connected</span>
|
||||
</div>
|
||||
<div class="console-terminal" id="terminal-1"></div>
|
||||
</div>
|
||||
@ -48,7 +47,6 @@
|
||||
<div class="console-panel-header">
|
||||
<span>🔧 Sesija 2</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 class="console-terminal" id="terminal-2"></div>
|
||||
</div>
|
||||
@ -62,9 +60,9 @@
|
||||
// ── Terminal themes ──────────────────────────────────
|
||||
var TERM_THEMES = {
|
||||
dark: {
|
||||
background: '#111', foreground: '#e0e0e0', cursor: '#e94560', cursorAccent: '#111',
|
||||
background: '#0d1117', foreground: '#e0e0e0', cursor: '#e94560', cursorAccent: '#0d1117',
|
||||
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',
|
||||
brightBlack: '#6c6c80', brightRed: '#ff6b81', brightGreen: '#66bb6a', brightYellow: '#ffb74d',
|
||||
brightBlue: '#64b5f6', brightMagenta: '#ff6b81', brightCyan: '#4dd0e1', brightWhite: '#ffffff'
|
||||
@ -132,7 +130,11 @@ function initTerminal(idx) {
|
||||
sessions[idx].fitAddon = fitAddon;
|
||||
sessions[idx].ws = null;
|
||||
|
||||
setTimeout(function() { fitAddon.fit(); }, 50);
|
||||
setTimeout(function() {
|
||||
fitAddon.fit();
|
||||
// Auto-connect WebSocket immediately
|
||||
connectWS(idx);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// ── WebSocket connection ─────────────────────────────
|
||||
@ -145,17 +147,13 @@ function connectWS(idx) {
|
||||
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 url = proto + '//' + location.host + '/console/ws/' + num;
|
||||
var ws = new WebSocket(url);
|
||||
ws.binaryType = 'arraybuffer';
|
||||
|
||||
ws.onopen = function() {
|
||||
setSessionUI(num, 'running');
|
||||
setSessionUI(num, 'connected');
|
||||
var term = sess.term;
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }));
|
||||
term.focus();
|
||||
@ -171,35 +169,23 @@ function connectWS(idx) {
|
||||
|
||||
ws.onclose = function() {
|
||||
sess.ws = null;
|
||||
setSessionUI(num, 'idle');
|
||||
setSessionUI(num, 'disconnected');
|
||||
};
|
||||
|
||||
ws.onerror = function() {
|
||||
sess.ws = null;
|
||||
setSessionUI(num, 'idle');
|
||||
setSessionUI(num, 'error');
|
||||
};
|
||||
|
||||
sess.ws = ws;
|
||||
}
|
||||
|
||||
// ── 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) {
|
||||
document.getElementById('status-' + session).textContent = status;
|
||||
document.getElementById('status-' + session).className = 'session-status session-' + status;
|
||||
document.getElementById('kill-' + session).style.display = status === 'running' ? 'inline-block' : 'none';
|
||||
var el = document.getElementById('status-' + session);
|
||||
el.textContent = status;
|
||||
el.className = 'session-status';
|
||||
if (status === 'connected') el.className += ' session-running';
|
||||
}
|
||||
|
||||
function togglePanel2() {
|
||||
@ -212,6 +198,7 @@ function togglePanel2() {
|
||||
initTerminal(1);
|
||||
} else {
|
||||
sessions[1].fitAddon.fit();
|
||||
if (!sessions[1].ws) connectWS(1);
|
||||
}
|
||||
} else {
|
||||
panel.style.display = 'none';
|
||||
@ -244,24 +231,6 @@ window.addEventListener('resize', function() {
|
||||
|
||||
// ── Initialize ───────────────────────────────────────
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user