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 (
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user