diff --git a/linux/Makefile b/linux/Makefile index f0fac6eb..6e68213c 100644 --- a/linux/Makefile +++ b/linux/Makefile @@ -5,7 +5,7 @@ # Variables APP_NAME := vibetunnel -VERSION := 1.0.0 +VERSION := 1.0.3 BUILD_DIR := build WEB_DIR := ../web DIST_DIR := $(WEB_DIR)/dist @@ -56,12 +56,18 @@ dev: build ## Build and run in development mode install: build ## Install to /usr/local/bin @echo "Installing $(APP_NAME) to /usr/local/bin..." sudo cp $(BUILD_DIR)/$(APP_NAME) /usr/local/bin/ + @echo "Installing vt command..." + sudo cp cmd/vt/vt /usr/local/bin/ + sudo chmod +x /usr/local/bin/vt @echo "Installation complete. Run 'vibetunnel --help' to get started." install-user: build ## Install to ~/bin @echo "Installing $(APP_NAME) to ~/bin..." @mkdir -p ~/bin cp $(BUILD_DIR)/$(APP_NAME) ~/bin/ + @echo "Installing vt command..." + cp cmd/vt/vt ~/bin/ + chmod +x ~/bin/vt @echo "Installation complete. Make sure ~/bin is in your PATH." @echo "Run 'vibetunnel --help' to get started." diff --git a/linux/build-vt-universal.sh b/linux/build-vt-universal.sh index d16e4080..1452ac7f 100755 --- a/linux/build-vt-universal.sh +++ b/linux/build-vt-universal.sh @@ -1,33 +1,28 @@ #!/bin/bash set -e -# Build universal vt binary for macOS +# Copy vt bash script for macOS app bundle -echo "Building vt universal binary..." +echo "Preparing vt bash script..." # Get the directory where this script is located SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" cd "$SCRIPT_DIR" -# Build for x86_64 -echo "Building vt for x86_64..." -GOOS=darwin GOARCH=amd64 go build -o vt-x86_64 ./cmd/vt - -# Build for arm64 -echo "Building vt for arm64..." -GOOS=darwin GOARCH=arm64 go build -o vt-arm64 ./cmd/vt - -# Create universal binary -echo "Creating universal binary..." -lipo -create -output vt vt-x86_64 vt-arm64 - -# Clean up architecture-specific binaries -rm vt-x86_64 vt-arm64 +# Copy the bash script +echo "Copying vt bash script..." +cp cmd/vt/vt vt # Make it executable chmod +x vt -echo "vt universal binary built successfully at: $SCRIPT_DIR/vt" +# Sign it for macOS if codesign is available +if command -v codesign >/dev/null 2>&1; then + echo "Signing vt script..." + codesign --force --sign - vt +fi + +echo "vt script prepared successfully at: $SCRIPT_DIR/vt" # Copy to target location if provided if [ -n "$1" ]; then diff --git a/linux/cmd/vibetunnel/main.go b/linux/cmd/vibetunnel/main.go index af2dffa2..f3a6d515 100644 --- a/linux/cmd/vibetunnel/main.go +++ b/linux/cmd/vibetunnel/main.go @@ -16,6 +16,9 @@ import ( ) var ( + // Version injected at build time + version = "dev" + // Session management flags controlPath string sessionName string @@ -71,10 +74,8 @@ var rootCmd = &cobra.Command{ Long: `VibeTunnel allows you to access your Linux terminal from any web browser. This is the Linux implementation compatible with the macOS VibeTunnel app.`, RunE: run, - // Allow passing through unknown flags to the command being executed - FParseErrWhitelist: cobra.FParseErrWhitelist{ - UnknownFlags: true, - }, + // Allow positional arguments after flags (for command execution) + Args: cobra.ArbitraryArgs, } func init() { @@ -135,7 +136,7 @@ func init() { Use: "version", Short: "Show version information", Run: func(cmd *cobra.Command, args []string) { - fmt.Println("VibeTunnel Linux v1.0.2") + fmt.Printf("VibeTunnel Linux v%s\n", version) fmt.Println("Compatible with VibeTunnel macOS app") }, }) @@ -461,63 +462,103 @@ func main() { if len(args) > 0 && (args[0] == "version" || args[0] == "config") { // This is a subcommand, let Cobra handle it normally } else { - // Check if any args look like VibeTunnel flags - hasVibeTunnelFlags := false - for _, arg := range args { - if strings.HasPrefix(arg, "-") { - // Check if this is one of our known flags - flag := strings.TrimLeft(arg, "-") - flag = strings.Split(flag, "=")[0] // Handle --flag=value format - - knownFlags := []string{ - "serve", "port", "p", "bind", "localhost", "network", - "password", "password-enabled", "tls", "tls-port", "tls-domain", - "tls-self-signed", "tls-cert", "tls-key", "tls-redirect", - "ngrok", "ngrok-token", "debug", "cleanup-startup", - "server-mode", "update-channel", "config", "c", - "control-path", "session-name", "list-sessions", - "send-key", "send-text", "signal", "stop", "kill", - "cleanup-exited", "detached-session", "static-path", "help", "h", - } - - for _, known := range knownFlags { - if flag == known { - hasVibeTunnelFlags = true - break - } - } - if hasVibeTunnelFlags { - break - } + // Check if we have a -- separator (everything after it is the command) + dashDashIndex := -1 + for i, arg := range args { + if arg == "--" { + dashDashIndex = i + break } } - // If no VibeTunnel flags found, treat everything as a command - if !hasVibeTunnelFlags && len(args) > 0 { - homeDir, _ := os.UserHomeDir() - defaultControlPath := filepath.Join(homeDir, ".vibetunnel", "control") - cfg := config.LoadConfig(filepath.Join(homeDir, ".vibetunnel", "config.yaml")) - if cfg.ControlPath != "" { - defaultControlPath = cfg.ControlPath + if dashDashIndex >= 0 { + // We have a -- separator, everything after it is the command to execute + cmdArgs := args[dashDashIndex+1:] + if len(cmdArgs) > 0 { + homeDir, _ := os.UserHomeDir() + defaultControlPath := filepath.Join(homeDir, ".vibetunnel", "control") + cfg := config.LoadConfig(filepath.Join(homeDir, ".vibetunnel", "config.yaml")) + if cfg.ControlPath != "" { + defaultControlPath = cfg.ControlPath + } + + manager := session.NewManager(defaultControlPath) + sess, err := manager.CreateSession(session.Config{ + Name: "", + Cmdline: cmdArgs, + Cwd: ".", + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Attach to the session + if err := sess.Attach(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return + } + } else { + // No -- separator, check if any args look like VibeTunnel flags + hasVibeTunnelFlags := false + for _, arg := range args { + if strings.HasPrefix(arg, "-") { + // Check if this is one of our known flags + flag := strings.TrimLeft(arg, "-") + flag = strings.Split(flag, "=")[0] // Handle --flag=value format + + knownFlags := []string{ + "serve", "port", "p", "bind", "localhost", "network", + "password", "password-enabled", "tls", "tls-port", "tls-domain", + "tls-self-signed", "tls-cert", "tls-key", "tls-redirect", + "ngrok", "ngrok-token", "debug", "cleanup-startup", + "server-mode", "update-channel", "config", "c", + "control-path", "session-name", "list-sessions", + "send-key", "send-text", "signal", "stop", "kill", + "cleanup-exited", "detached-session", "static-path", "help", "h", + } + + for _, known := range knownFlags { + if flag == known { + hasVibeTunnelFlags = true + break + } + } + if hasVibeTunnelFlags { + break + } + } } - manager := session.NewManager(defaultControlPath) - sess, err := manager.CreateSession(session.Config{ - Name: "", - Cmdline: args, - Cwd: ".", - }) - if err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) + // If no VibeTunnel flags found, treat everything as a command + if !hasVibeTunnelFlags && len(args) > 0 { + homeDir, _ := os.UserHomeDir() + defaultControlPath := filepath.Join(homeDir, ".vibetunnel", "control") + cfg := config.LoadConfig(filepath.Join(homeDir, ".vibetunnel", "config.yaml")) + if cfg.ControlPath != "" { + defaultControlPath = cfg.ControlPath + } + + manager := session.NewManager(defaultControlPath) + sess, err := manager.CreateSession(session.Config{ + Name: "", + Cmdline: args, + Cwd: ".", + }) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + // Attach to the session + if err := sess.Attach(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + return } - - // Attach to the session - if err := sess.Attach(); err != nil { - fmt.Fprintf(os.Stderr, "Error: %v\n", err) - os.Exit(1) - } - return } } } diff --git a/linux/cmd/vt/main.go b/linux/cmd/vt/main.go deleted file mode 100644 index 753c90df..00000000 --- a/linux/cmd/vt/main.go +++ /dev/null @@ -1,277 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "syscall" -) - -const Version = "1.0.2" - -type Config struct { - Server string `json:"server,omitempty"` -} - -func main() { - // Debug incoming args - if os.Getenv("VT_DEBUG") != "" { - fmt.Fprintf(os.Stderr, "VT Debug: args = %v\n", os.Args) - } - - // Handle version flag only if it's the only argument - if len(os.Args) == 2 && (os.Args[1] == "--version" || os.Args[1] == "-v") { - fmt.Printf("vt version %s\n", Version) - os.Exit(0) - } - - // Get preferred server - server := getPreferredServer() - - // Forward to appropriate server - var err error - switch server { - case "rust": - err = forwardToRustServer(os.Args[1:]) - case "go": - err = forwardToGoServer(os.Args[1:]) - default: - err = forwardToGoServer(os.Args[1:]) - } - - if err != nil { - // If the command exited with a specific code, preserve it - if exitErr, ok := err.(*exec.ExitError); ok { - if status, ok := exitErr.Sys().(syscall.WaitStatus); ok { - os.Exit(status.ExitStatus()) - } - os.Exit(1) - } - fmt.Fprintf(os.Stderr, "vt: %v\n", err) - os.Exit(1) - } -} - -func getPreferredServer() string { - // Check environment variable first - if server := os.Getenv("VT_SERVER"); server != "" { - if server == "rust" || server == "go" { - return server - } - } - - // Read from config file - configPath := filepath.Join(os.Getenv("HOME"), ".vibetunnel", "config.json") - data, err := os.ReadFile(configPath) - if err != nil { - return "go" // default - } - - var config Config - if err := json.Unmarshal(data, &config); err != nil { - return "go" // default on parse error - } - - if config.Server == "rust" || config.Server == "go" { - return config.Server - } - return "go" // default for invalid values -} - -func forwardToGoServer(args []string) error { - // Find vibetunnel binary - vibetunnelPath := findVibetunnelBinary() - if vibetunnelPath == "" { - return fmt.Errorf("vibetunnel binary not found") - } - - // Check if this is a special VT command - var finalArgs []string - if len(args) > 0 && isVTSpecialCommand(args[0]) { - // Translate special VT commands - finalArgs = translateVTToGoArgs(args) - } else { - // For regular commands, just prepend -- to tell vibetunnel to stop parsing - finalArgs = append([]string{"--"}, args...) - } - - // Debug: print what we're executing - if os.Getenv("VT_DEBUG") != "" { - fmt.Fprintf(os.Stderr, "VT Debug: executing %s with args: %v\n", vibetunnelPath, finalArgs) - } - - // Create command - cmd := exec.Command(vibetunnelPath, finalArgs...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - // Run and return - return cmd.Run() -} - -func isVTSpecialCommand(arg string) bool { - switch arg { - case "--claude", "--claude-yolo", "--shell", "-i", - "--no-shell-wrap", "-S", "--show-session-info", "--show-session-id": - return true - } - return false -} - -func forwardToRustServer(args []string) error { - // Find tty-fwd binary - ttyFwdPath := findTtyFwdBinary() - if ttyFwdPath == "" { - return fmt.Errorf("tty-fwd binary not found") - } - - // Create command with original args (tty-fwd already understands vt args) - cmd := exec.Command(ttyFwdPath, args...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - - // Run and return - return cmd.Run() -} - -func translateVTToGoArgs(args []string) []string { - if len(args) == 0 { - return args - } - - // Check for special vt-only flags - switch args[0] { - case "--claude": - // Find Claude and run it with any additional arguments - claudePath := findClaude() - if claudePath != "" { - // Pass all remaining args to claude - result := []string{"--", claudePath} - if len(args) > 1 { - result = append(result, args[1:]...) - } - return result - } - // Fallback - result := []string{"--", "claude"} - if len(args) > 1 { - result = append(result, args[1:]...) - } - return result - - case "--claude-yolo": - // Find Claude and run with permissions skip - claudePath := findClaude() - if claudePath != "" { - return []string{"--", claudePath, "--dangerously-skip-permissions"} - } - return []string{"--", "claude", "--dangerously-skip-permissions"} - - case "--shell", "-i": - // Launch interactive shell - shell := os.Getenv("SHELL") - if shell == "" { - shell = "/bin/bash" - } - return []string{"--", shell, "-i"} - - case "--no-shell-wrap", "-S": - // Direct execution without shell - skip the flag and pass rest - if len(args) > 1 { - return append([]string{"--"}, args[1:]...) - } - return []string{} - - case "--show-session-info": - return []string{"--list-sessions"} - - case "--show-session-id": - // This needs special handling - just pass through for now - return args - - default: - // This shouldn't happen since we check isVTSpecialCommand first - return args - } -} - -func findVibetunnelBinary() string { - // Check common locations - paths := []string{ - // App bundle location - "/Applications/VibeTunnel.app/Contents/Resources/vibetunnel", - // Development locations - "./vibetunnel", - "../vibetunnel", - "../../linux/vibetunnel", - // Installed location - "/usr/local/bin/vibetunnel", - } - - for _, path := range paths { - if _, err := os.Stat(path); err == nil { - return path - } - } - - // Try to find in PATH - if path, err := exec.LookPath("vibetunnel"); err == nil { - return path - } - - return "" -} - -func findTtyFwdBinary() string { - // Check common locations - paths := []string{ - // App bundle location - "/Applications/VibeTunnel.app/Contents/Resources/tty-fwd", - // Development locations - "./tty-fwd", - "../tty-fwd", - "../../tty-fwd/target/release/tty-fwd", - // Installed location - "/usr/local/bin/tty-fwd", - } - - for _, path := range paths { - if _, err := os.Stat(path); err == nil { - return path - } - } - - // Try to find in PATH - if path, err := exec.LookPath("tty-fwd"); err == nil { - return path - } - - return "" -} - -func findClaude() string { - // Check common Claude installation paths - claudePaths := []string{ - filepath.Join(os.Getenv("HOME"), ".claude", "local", "claude"), - "/opt/homebrew/bin/claude", - "/usr/local/bin/claude", - "/usr/bin/claude", - } - - for _, path := range claudePaths { - if _, err := os.Stat(path); err == nil { - return path - } - } - - // Try PATH - if path, err := exec.LookPath("claude"); err == nil { - return path - } - - return "" -} diff --git a/linux/go.mod b/linux/go.mod index bfbd8a1c..bf6be564 100644 --- a/linux/go.mod +++ b/linux/go.mod @@ -10,6 +10,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/google/uuid v1.6.0 github.com/gorilla/mux v1.8.1 + github.com/gorilla/websocket v1.5.3 github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 golang.ngrok.com/ngrok v1.13.0 diff --git a/linux/go.sum b/linux/go.sum index c6fcafb2..b0b306c8 100644 --- a/linux/go.sum +++ b/linux/go.sum @@ -18,6 +18,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= github.com/hashicorp/yamux v0.1.1/go.mod h1:CtWFDAQgb7dxtzFs4tWbplKIe2jSi3+5vKbgIO0SLnQ= github.com/inconshreveable/log15 v3.0.0-testing.5+incompatible h1:VryeOTiaZfAzwx8xBcID1KlJCeoWSIpsNbSk+/D2LNk= diff --git a/linux/pkg/api/multistream.go b/linux/pkg/api/multistream.go index 8c39c6b3..6e7c929c 100644 --- a/linux/pkg/api/multistream.go +++ b/linux/pkg/api/multistream.go @@ -101,18 +101,39 @@ func (m *MultiSSEStreamer) streamSession(sessionID string) { } func (m *MultiSSEStreamer) sendEvent(sessionID string, event *protocol.StreamEvent) error { - data := map[string]interface{}{ - "session_id": sessionID, - "event": event, - } - - jsonData, err := json.Marshal(data) - if err != nil { - return err - } - - if _, err := fmt.Fprintf(m.w, "data: %s\n\n", jsonData); err != nil { - return err // Client disconnected + // Match Rust format: send raw arrays for terminal events + if event.Type == "event" && event.Event != nil { + // For terminal events, send as raw array + data := []interface{}{ + event.Event.Time, + string(event.Event.Type), + event.Event.Data, + } + + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + // Match Rust multistream format: sessionID:event_json + prefixedEvent := fmt.Sprintf("%s:%s", sessionID, jsonData) + + if _, err := fmt.Fprintf(m.w, "data: %s\n\n", prefixedEvent); err != nil { + return err // Client disconnected + } + } else { + // For other event types, serialize the event + jsonData, err := json.Marshal(event) + if err != nil { + return err + } + + // Match Rust multistream format: sessionID:event_json + prefixedEvent := fmt.Sprintf("%s:%s", sessionID, jsonData) + + if _, err := fmt.Fprintf(m.w, "data: %s\n\n", prefixedEvent); err != nil { + return err // Client disconnected + } } if m.flusher != nil { diff --git a/linux/pkg/api/server.go b/linux/pkg/api/server.go index e05c5066..186da045 100644 --- a/linux/pkg/api/server.go +++ b/linux/pkg/api/server.go @@ -75,8 +75,6 @@ func (s *Server) Start(addr string) error { } } - // Stop the manager's background tasks - s.manager.Stop() // Shutdown HTTP server ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) @@ -116,6 +114,15 @@ func (s *Server) createHandler() http.Handler { api.HandleFunc("/ngrok/stop", s.handleNgrokStop).Methods("POST") api.HandleFunc("/ngrok/status", s.handleNgrokStatus).Methods("GET") + // WebSocket endpoint for binary terminal streaming + bufferHandler := NewBufferWebSocketHandler(s.manager) + // Apply authentication middleware if password is set + if s.password != "" { + r.Handle("/buffers", s.basicAuthMiddleware(bufferHandler)) + } else { + r.Handle("/buffers", bufferHandler) + } + if s.staticPath != "" { // Serve static files with index.html fallback for directories r.PathPrefix("/").HandlerFunc(s.serveStaticWithIndex) @@ -224,24 +231,24 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { return } - // Convert to Rust-compatible format - type RustSessionInfo struct { + // Convert to API response format + type APISessionInfo struct { ID string `json:"id"` Name string `json:"name"` - Command string `json:"command"` // Changed from cmdline array to command string - WorkingDir string `json:"workingDir"` // Changed from cwd to workingDir + Command string `json:"command"` + WorkingDir string `json:"workingDir"` Pid *int `json:"pid,omitempty"` Status string `json:"status"` - ExitCode *int `json:"exitCode,omitempty"` // Changed from exit_code to exitCode - StartedAt time.Time `json:"startedAt"` // Changed from started_at to startedAt + ExitCode *int `json:"exitCode,omitempty"` + StartedAt time.Time `json:"startedAt"` Term string `json:"term"` Width int `json:"width"` Height int `json:"height"` Env map[string]string `json:"env,omitempty"` - LastModified time.Time `json:"lastModified"` // Added to match Rust + LastModified time.Time `json:"lastModified"` } - rustSessions := make([]RustSessionInfo, len(sessions)) + apiSessions := make([]APISessionInfo, len(sessions)) for i, s := range sessions { // Convert PID to pointer for omitempty behavior var pid *int @@ -249,7 +256,7 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { pid = &s.Pid } - rustSessions[i] = RustSessionInfo{ + apiSessions[i] = APISessionInfo{ ID: s.ID, Name: s.Name, Command: s.Cmdline, // Already a string @@ -267,7 +274,7 @@ func (s *Server) handleListSessions(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(rustSessions) + json.NewEncoder(w).Encode(apiSessions) } func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { @@ -493,10 +500,62 @@ func (s *Server) handleGetSession(w http.ResponseWriter, r *http.Request) { return } - // Return current session info without blocking on status update - // Status will be eventually consistent through background updates + // Get session info and convert to Rust-compatible format + info := sess.GetInfo() + if info == nil { + http.Error(w, "Session info not available", http.StatusInternalServerError) + return + } + + // Update status on-demand + sess.UpdateStatus() + + // Convert to Rust-compatible format like in handleListSessions + rustInfo := session.RustSessionInfo{ + ID: info.ID, + Name: info.Name, + Cmdline: info.Args, + Cwd: info.Cwd, + Status: info.Status, + ExitCode: info.ExitCode, + Term: info.Term, + SpawnType: "pty", + Cols: &info.Width, + Rows: &info.Height, + Env: info.Env, + } + + if info.Pid > 0 { + rustInfo.Pid = &info.Pid + } + + if !info.StartedAt.IsZero() { + rustInfo.StartedAt = &info.StartedAt + } + + // Convert to API response format with camelCase like Rust + response := map[string]interface{}{ + "id": rustInfo.ID, + "name": rustInfo.Name, + "command": strings.Join(rustInfo.Cmdline, " "), + "workingDir": rustInfo.Cwd, + "pid": rustInfo.Pid, + "status": rustInfo.Status, + "exitCode": rustInfo.ExitCode, + "startedAt": rustInfo.StartedAt, + "term": rustInfo.Term, + "width": rustInfo.Cols, + "height": rustInfo.Rows, + "env": rustInfo.Env, + } + + // Add lastModified like Rust does + if stat, err := os.Stat(sess.Path()); err == nil { + response["lastModified"] = stat.ModTime() + } + w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(sess) + json.NewEncoder(w).Encode(response) } func (s *Server) handleStreamSession(w http.ResponseWriter, r *http.Request) { @@ -635,7 +694,7 @@ func (s *Server) handleCleanupSession(w http.ResponseWriter, r *http.Request) { } func (s *Server) handleCleanupExited(w http.ResponseWriter, r *http.Request) { - if err := s.manager.CleanupExitedSessions(); err != nil { + if err := s.manager.RemoveExitedSessions(); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } diff --git a/linux/pkg/api/sse.go b/linux/pkg/api/sse.go index b9273102..25d6caf4 100644 --- a/linux/pkg/api/sse.go +++ b/linux/pkg/api/sse.go @@ -227,25 +227,37 @@ func (s *SSEStreamer) sendEvent(event *protocol.StreamEvent) error { } func (s *SSEStreamer) sendRawEvent(event *protocol.StreamEvent) error { - var data interface{} - + // Match Rust behavior exactly - send raw arrays for terminal events if event.Type == "header" { - // For header events, we can skip them since the frontend might not expect them - // Or send them in a compatible format if needed + // Skip headers like Rust does return nil } else if event.Type == "event" && event.Event != nil { - // Convert to asciinema format: [timestamp, type, data] - data = []interface{}{ + // Send raw array directly like Rust: [timestamp, type, data] + data := []interface{}{ event.Event.Time, string(event.Event.Type), event.Event.Data, } - } else { - // For other event types, use the original format - data = event + + jsonData, err := json.Marshal(data) + if err != nil { + return err + } + + // Send as SSE data + if _, err := fmt.Fprintf(s.w, "data: %s\n\n", jsonData); err != nil { + return err // Client disconnected + } + + if s.flusher != nil { + s.flusher.Flush() + } + + return nil } - - jsonData, err := json.Marshal(data) + + // For other event types (error, end), send without wrapping + jsonData, err := json.Marshal(event) if err != nil { return err } diff --git a/linux/pkg/api/websocket.go b/linux/pkg/api/websocket.go new file mode 100644 index 00000000..431dd6fc --- /dev/null +++ b/linux/pkg/api/websocket.go @@ -0,0 +1,389 @@ +package api + +import ( + "encoding/binary" + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/gorilla/websocket" + "github.com/vibetunnel/linux/pkg/protocol" + "github.com/vibetunnel/linux/pkg/session" +) + +const ( + // Magic byte for binary messages + BufferMagicByte = 0xbf + + // WebSocket timeouts + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 512 * 1024 // 512KB +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + // Allow all origins for now + return true + }, + ReadBufferSize: 1024, + WriteBufferSize: 1024, +} + +type BufferWebSocketHandler struct { + manager *session.Manager + clients sync.Map // sessionID -> *websocket.Conn +} + +func NewBufferWebSocketHandler(manager *session.Manager) *BufferWebSocketHandler { + return &BufferWebSocketHandler{ + manager: manager, + } +} + +func (h *BufferWebSocketHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("[WebSocket] Failed to upgrade connection: %v", err) + return + } + defer conn.Close() + + // Set up connection parameters + conn.SetReadLimit(maxMessageSize) + conn.SetReadDeadline(time.Now().Add(pongWait)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + // Start ping ticker + ticker := time.NewTicker(pingPeriod) + defer ticker.Stop() + + // Channel for writing messages + send := make(chan []byte, 256) + done := make(chan struct{}) + + // Start writer goroutine + go h.writer(conn, send, ticker, done) + + // Handle incoming messages + for { + select { + case <-done: + return + default: + messageType, message, err := conn.ReadMessage() + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("[WebSocket] Error: %v", err) + } + close(done) + return + } + + if messageType == websocket.TextMessage { + h.handleTextMessage(conn, message, send, done) + } + } + } +} + +func (h *BufferWebSocketHandler) handleTextMessage(conn *websocket.Conn, message []byte, send chan []byte, done chan struct{}) { + var msg map[string]interface{} + if err := json.Unmarshal(message, &msg); err != nil { + log.Printf("[WebSocket] Failed to parse message: %v", err) + return + } + + msgType, ok := msg["type"].(string) + if !ok { + return + } + + switch msgType { + case "ping": + // Send pong response + pong, _ := json.Marshal(map[string]string{"type": "pong"}) + select { + case send <- pong: + case <-done: + return + } + + case "subscribe": + sessionID, ok := msg["sessionId"].(string) + if !ok { + return + } + + // Start streaming session data + go h.streamSession(sessionID, send, done) + + case "unsubscribe": + // Currently we just close the connection when unsubscribing + close(done) + } +} + +func (h *BufferWebSocketHandler) streamSession(sessionID string, send chan []byte, done chan struct{}) { + sess, err := h.manager.GetSession(sessionID) + if err != nil { + log.Printf("[WebSocket] Session not found: %v", err) + errorMsg, _ := json.Marshal(map[string]string{ + "type": "error", + "message": fmt.Sprintf("Session not found: %v", err), + }) + select { + case send <- errorMsg: + case <-done: + } + return + } + + streamPath := sess.StreamOutPath() + + // Create file watcher + watcher, err := fsnotify.NewWatcher() + if err != nil { + log.Printf("[WebSocket] Failed to create watcher: %v", err) + return + } + defer watcher.Close() + + // Add the stream file to the watcher + err = watcher.Add(streamPath) + if err != nil { + log.Printf("[WebSocket] Failed to watch file: %v", err) + return + } + + headerSent := false + seenBytes := int64(0) + + // Send initial content + h.processAndSendContent(sessionID, streamPath, &headerSent, &seenBytes, send, done) + + // Watch for changes + for { + select { + case <-done: + return + + case event, ok := <-watcher.Events: + if !ok { + return + } + + if event.Op&fsnotify.Write == fsnotify.Write { + h.processAndSendContent(sessionID, streamPath, &headerSent, &seenBytes, send, done) + } + + case err, ok := <-watcher.Errors: + if !ok { + return + } + log.Printf("[WebSocket] Watcher error: %v", err) + + case <-time.After(1 * time.Second): + // Check if session is still alive + if !sess.IsAlive() { + // Send exit event + exitMsg := h.createBinaryMessage(sessionID, []byte(`{"type":"exit","code":0}`)) + select { + case send <- exitMsg: + case <-done: + } + return + } + } + } +} + +func (h *BufferWebSocketHandler) processAndSendContent(sessionID, streamPath string, headerSent *bool, seenBytes *int64, send chan []byte, done chan struct{}) { + file, err := os.Open(streamPath) + if err != nil { + log.Printf("[WebSocket] Failed to open stream file: %v", err) + return + } + defer file.Close() + + // Get current file size + fileInfo, err := file.Stat() + if err != nil { + return + } + + currentSize := fileInfo.Size() + if currentSize <= *seenBytes { + return + } + + // Seek to last position + if _, err := file.Seek(*seenBytes, 0); err != nil { + return + } + + // Read new content + newContentSize := currentSize - *seenBytes + newContent := make([]byte, newContentSize) + + bytesRead, err := file.Read(newContent) + if err != nil { + return + } + + *seenBytes = currentSize + + // Process content line by line + content := string(newContent[:bytesRead]) + lines := strings.Split(content, "\n") + + // Handle incomplete last line + endIndex := len(lines) + if !strings.HasSuffix(content, "\n") && len(lines) > 0 { + incompleteLineBytes := int64(len(lines[len(lines)-1])) + *seenBytes -= incompleteLineBytes + endIndex = len(lines) - 1 + } + + // Process complete lines + for i := 0; i < endIndex; i++ { + line := lines[i] + if line == "" { + continue + } + + // Try to parse as header first + if !*headerSent { + var header protocol.AsciinemaHeader + if err := json.Unmarshal([]byte(line), &header); err == nil && header.Version > 0 { + *headerSent = true + // Send header as binary message + headerData, _ := json.Marshal(map[string]interface{}{ + "type": "header", + "width": header.Width, + "height": header.Height, + }) + msg := h.createBinaryMessage(sessionID, headerData) + select { + case send <- msg: + case <-done: + return + } + continue + } + } + + // Try to parse as event array [timestamp, type, data] + var eventArray []interface{} + if err := json.Unmarshal([]byte(line), &eventArray); err == nil && len(eventArray) == 3 { + timestamp, ok1 := eventArray[0].(float64) + eventType, ok2 := eventArray[1].(string) + data, ok3 := eventArray[2].(string) + + if ok1 && ok2 && ok3 && eventType == "o" { + // Create terminal output message + outputData, _ := json.Marshal(map[string]interface{}{ + "type": "output", + "timestamp": timestamp, + "data": data, + }) + + msg := h.createBinaryMessage(sessionID, outputData) + select { + case send <- msg: + case <-done: + return + } + } else if ok1 && ok2 && ok3 && eventType == "r" { + // Create resize message + resizeData, _ := json.Marshal(map[string]interface{}{ + "type": "resize", + "timestamp": timestamp, + "dimensions": data, + }) + + msg := h.createBinaryMessage(sessionID, resizeData) + select { + case send <- msg: + case <-done: + return + } + } + } + } +} + +func (h *BufferWebSocketHandler) createBinaryMessage(sessionID string, data []byte) []byte { + // Binary message format: + // [magic byte (1)] [session ID length (4, little endian)] [session ID] [data] + + sessionIDBytes := []byte(sessionID) + totalLen := 1 + 4 + len(sessionIDBytes) + len(data) + + msg := make([]byte, totalLen) + offset := 0 + + // Magic byte + msg[offset] = BufferMagicByte + offset++ + + // Session ID length (little endian) + binary.LittleEndian.PutUint32(msg[offset:], uint32(len(sessionIDBytes))) + offset += 4 + + // Session ID + copy(msg[offset:], sessionIDBytes) + offset += len(sessionIDBytes) + + // Data + copy(msg[offset:], data) + + return msg +} + +func (h *BufferWebSocketHandler) writer(conn *websocket.Conn, send chan []byte, ticker *time.Ticker, done chan struct{}) { + defer close(send) + + for { + select { + case message, ok := <-send: + conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + // Check if it's a text message (JSON) or binary + if len(message) > 0 && message[0] == '{' { + // Text message + if err := conn.WriteMessage(websocket.TextMessage, message); err != nil { + return + } + } else { + // Binary message + if err := conn.WriteMessage(websocket.BinaryMessage, message); err != nil { + return + } + } + + case <-ticker.C: + conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + + case <-done: + return + } + } +} \ No newline at end of file diff --git a/linux/pkg/session/manager.go b/linux/pkg/session/manager.go index a0b094d4..7c86fc05 100644 --- a/linux/pkg/session/manager.go +++ b/linux/pkg/session/manager.go @@ -4,35 +4,26 @@ import ( "fmt" "log" "os" + "os/exec" "path/filepath" "sort" + "strconv" "strings" "sync" "syscall" - "time" ) type Manager struct { controlPath string runningSessions map[string]*Session mutex sync.RWMutex - stopChan chan struct{} - cleanupInterval time.Duration } func NewManager(controlPath string) *Manager { - m := &Manager{ + return &Manager{ controlPath: controlPath, runningSessions: make(map[string]*Session), - stopChan: make(chan struct{}), - cleanupInterval: 30 * time.Second, // Clean up every 30 seconds } - - // Start background cleanup goroutine - // Disabled automatic cleanup to match Rust behavior - // go m.backgroundCleanup() - - return m } func (m *Manager) CreateSession(config Config) (*Session, error) { @@ -169,38 +160,23 @@ func (m *Manager) RemoveExitedSessions() error { // No PID recorded, consider it exited shouldRemove = true } else { - // First check if it's a zombie process - statPath := fmt.Sprintf("/proc/%d/stat", info.Pid) - if data, err := os.ReadFile(statPath); err == nil { - statStr := string(data) - if lastParen := strings.LastIndex(statStr, ")"); lastParen != -1 { - fields := strings.Fields(statStr[lastParen+1:]) - if len(fields) > 0 && fields[0] == "Z" { - // It's a zombie, should remove - shouldRemove = true - - // Try to reap the zombie - var status syscall.WaitStatus - syscall.Wait4(info.Pid, &status, syscall.WNOHANG, nil) - } - } - } else { - // Can't read stat, process doesn't exist - shouldRemove = true - } + // Use ps command to check process status (portable across Unix systems) + cmd := exec.Command("ps", "-p", strconv.Itoa(info.Pid), "-o", "stat=") + output, err := cmd.Output() - // If not already marked for removal, check if process is alive - if !shouldRemove { - proc, err := os.FindProcess(info.Pid) - if err != nil { + if err != nil { + // Process doesn't exist + shouldRemove = true + } else { + // Check if it's a zombie process (status starts with 'Z') + stat := strings.TrimSpace(string(output)) + if strings.HasPrefix(stat, "Z") { + // It's a zombie, should remove shouldRemove = true - } else { - // Signal 0 just checks if process exists without actually sending a signal - err = proc.Signal(syscall.Signal(0)) - if err != nil { - // Process doesn't exist - shouldRemove = true - } + + // Try to reap the zombie + var status syscall.WaitStatus + syscall.Wait4(info.Pid, &status, syscall.WNOHANG, nil) } } } @@ -222,26 +198,6 @@ func (m *Manager) RemoveExitedSessions() error { return nil } -// backgroundCleanup runs periodic cleanup of dead sessions -func (m *Manager) backgroundCleanup() { - ticker := time.NewTicker(m.cleanupInterval) - defer ticker.Stop() - - for { - select { - case <-ticker.C: - // Update session statuses and clean up dead ones - if err := m.UpdateAllSessionStatuses(); err != nil { - fmt.Printf("Background cleanup error: %v\n", err) - } - if err := m.CleanupExitedSessions(); err != nil { - fmt.Printf("Background cleanup error: %v\n", err) - } - case <-m.stopChan: - return - } - } -} // UpdateAllSessionStatuses updates the status of all sessions func (m *Manager) UpdateAllSessionStatuses() error { @@ -259,10 +215,6 @@ func (m *Manager) UpdateAllSessionStatuses() error { return nil } -// Stop stops the background cleanup goroutine -func (m *Manager) Stop() { - close(m.stopChan) -} func (m *Manager) RemoveSession(id string) error { // Remove from running sessions registry diff --git a/linux/pkg/session/pty.go b/linux/pkg/session/pty.go index fe56d93b..8a30049f 100644 --- a/linux/pkg/session/pty.go +++ b/linux/pkg/session/pty.go @@ -46,15 +46,6 @@ func NewPTY(session *Session) (*PTY, error) { debugLog("[DEBUG] NewPTY: Initial cmdline: %v", cmdline) - // For shells, force interactive mode to prevent immediate exit - if len(cmdline) == 1 && (strings.HasSuffix(cmdline[0], "bash") || strings.HasSuffix(cmdline[0], "zsh") || strings.HasSuffix(cmdline[0], "sh")) { - cmdline = append(cmdline, "-i") - // Update session info to reflect the actual command being run - session.info.Args = cmdline - session.info.Cmdline = strings.Join(cmdline, " ") - debugLog("[DEBUG] NewPTY: Added -i flag, cmdline now: %v", cmdline) - } - cmd := exec.Command(cmdline[0], cmdline[1:]...) // Set working directory, ensuring it's valid @@ -68,10 +59,43 @@ func NewPTY(session *Session) (*PTY, error) { debugLog("[DEBUG] NewPTY: Set working directory to: %s", session.info.Cwd) } - // Set up environment with proper terminal settings - env := os.Environ() - env = append(env, "TERM="+session.info.Term) - env = append(env, "SHELL="+cmdline[0]) + // Set up environment with filtered variables like Rust implementation + // Only pass safe environment variables + safeEnvVars := []string{"TERM", "SHELL", "LANG", "LC_ALL", "PATH", "USER", "HOME"} + env := make([]string, 0) + + // Copy only safe environment variables from parent + for _, v := range os.Environ() { + parts := strings.SplitN(v, "=", 2) + if len(parts) == 2 { + for _, safe := range safeEnvVars { + if parts[0] == safe { + env = append(env, v) + break + } + } + } + } + + // Ensure TERM and SHELL are set + hasTermVar := false + hasShellVar := false + for _, v := range env { + if strings.HasPrefix(v, "TERM=") { + hasTermVar = true + } + if strings.HasPrefix(v, "SHELL=") { + hasShellVar = true + } + } + + if !hasTermVar { + env = append(env, "TERM="+session.info.Term) + } + if !hasShellVar { + env = append(env, "SHELL="+cmdline[0]) + } + cmd.Env = env ptmx, err := pty.Start(cmd) @@ -81,6 +105,10 @@ func NewPTY(session *Session) (*PTY, error) { } debugLog("[DEBUG] NewPTY: PTY started successfully, PID: %d", cmd.Process.Pid) + + // Log the actual command being executed + debugLog("[DEBUG] NewPTY: Executing command: %v in directory: %s", cmdline, cmd.Dir) + debugLog("[DEBUG] NewPTY: Environment has %d variables", len(cmd.Env)) if err := pty.Setsize(ptmx, &pty.Winsize{ Rows: uint16(session.info.Height), @@ -92,6 +120,22 @@ func NewPTY(session *Session) (*PTY, error) { return nil, fmt.Errorf("failed to set PTY size: %w", err) } + // Set terminal flags to match Rust implementation + // Get the current terminal attributes + oldState, err := term.MakeRaw(int(ptmx.Fd())) + if err != nil { + debugLog("[DEBUG] NewPTY: Failed to get terminal attributes: %v", err) + } else { + // Restore the terminal but with specific flags enabled + // We don't want raw mode, we want interactive mode with ISIG, ICANON, and ECHO + term.Restore(int(ptmx.Fd()), oldState) + + // The creack/pty library should have already set up the terminal properly + // for interactive use. The key is that the PTY slave (not master) needs + // these settings, and they're typically inherited from the process. + debugLog("[DEBUG] NewPTY: Terminal configured for interactive mode") + } + streamOut, err := os.Create(session.StreamOutPath()) if err != nil { log.Printf("[ERROR] NewPTY: Failed to create stream-out: %v", err) diff --git a/linux/pkg/session/session.go b/linux/pkg/session/session.go index 1a7214d2..062dd5b4 100644 --- a/linux/pkg/session/session.go +++ b/linux/pkg/session/session.go @@ -1,14 +1,18 @@ package session import ( + "bytes" "encoding/json" "fmt" + "io" "log" + "net/http" "os" + "os/exec" "path/filepath" + "strconv" "strings" "sync" - "syscall" "time" "github.com/google/uuid" @@ -249,7 +253,11 @@ func (s *Session) sendInput(data []byte) error { stdinPath := s.StdinPath() pipe, err := os.OpenFile(stdinPath, os.O_WRONLY, 0) if err != nil { - return fmt.Errorf("failed to open stdin pipe: %w", err) + // If pipe fails, try Node.js proxy fallback like Rust + if os.Getenv("VIBETUNNEL_DEBUG") != "" { + log.Printf("[DEBUG] Failed to open stdin pipe, trying Node.js proxy fallback: %v", err) + } + return s.proxyInputToNodeJS(data) } s.stdinPipe = pipe } @@ -259,11 +267,58 @@ func (s *Session) sendInput(data []byte) error { // If write fails, close and reset the pipe for next attempt s.stdinPipe.Close() s.stdinPipe = nil - return fmt.Errorf("failed to write to stdin pipe: %w", err) + + // Try Node.js proxy fallback like Rust + if os.Getenv("VIBETUNNEL_DEBUG") != "" { + log.Printf("[DEBUG] Failed to write to stdin pipe, trying Node.js proxy fallback: %v", err) + } + return s.proxyInputToNodeJS(data) } return nil } +// proxyInputToNodeJS sends input via Node.js server fallback (like Rust implementation) +func (s *Session) proxyInputToNodeJS(data []byte) error { + client := &http.Client{ + Timeout: 5 * time.Second, + } + + url := fmt.Sprintf("http://localhost:3000/api/sessions/%s/input", s.ID) + + payload := map[string]interface{}{ + "data": string(data), + } + + jsonData, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal input data: %w", err) + } + + req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData)) + if err != nil { + return fmt.Errorf("failed to create proxy request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("Node.js proxy fallback failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return fmt.Errorf("Node.js proxy returned status %d: %s", resp.StatusCode, string(body)) + } + + if os.Getenv("VIBETUNNEL_DEBUG") != "" { + log.Printf("[DEBUG] Successfully sent input via Node.js proxy for session %s", s.ID[:8]) + } + + return nil +} + func (s *Session) Signal(sig string) error { if s.info.Pid == 0 { return fmt.Errorf("no process to signal") @@ -364,59 +419,53 @@ func (s *Session) Resize(width, height int) error { func (s *Session) IsAlive() bool { if s.info.Pid == 0 { + if os.Getenv("VIBETUNNEL_DEBUG") != "" { + log.Printf("[DEBUG] IsAlive: PID is 0 for session %s", s.ID[:8]) + } return false } - // Check if process exists and is not a zombie - if isZombie(s.info.Pid) { - return false - } - - proc, err := os.FindProcess(s.info.Pid) + // Use ps command to check process status (portable across Unix systems) + // This matches the Rust implementation + cmd := exec.Command("ps", "-p", strconv.Itoa(s.info.Pid), "-o", "stat=") + output, err := cmd.Output() + if err != nil { + // Process doesn't exist + if os.Getenv("VIBETUNNEL_DEBUG") != "" { + log.Printf("[DEBUG] IsAlive: ps command failed for PID %d: %v", s.info.Pid, err) + } return false } - - err = proc.Signal(syscall.Signal(0)) - return err == nil + + // Check if it's a zombie process (status starts with 'Z') + stat := strings.TrimSpace(string(output)) + if strings.HasPrefix(stat, "Z") { + if os.Getenv("VIBETUNNEL_DEBUG") != "" { + log.Printf("[DEBUG] IsAlive: Process %d is zombie (stat=%s) for session %s", s.info.Pid, stat, s.ID[:8]) + } + return false + } + + if os.Getenv("VIBETUNNEL_DEBUG") != "" { + log.Printf("[DEBUG] IsAlive: Process %d is alive (stat=%s) for session %s", s.info.Pid, stat, s.ID[:8]) + } + + return true } -// isZombie checks if a process is in zombie state -func isZombie(pid int) bool { - // Read process status from /proc/[pid]/stat - statPath := fmt.Sprintf("/proc/%d/stat", pid) - data, err := os.ReadFile(statPath) - if err != nil { - // If we can't read the stat file, process doesn't exist - return true - } - - // The process state is the third field after the command name in parentheses - // Find the last ')' to handle processes with ')' in their names - statStr := string(data) - lastParen := strings.LastIndex(statStr, ")") - if lastParen == -1 { - return true - } - - // Parse fields after the command name - fields := strings.Fields(statStr[lastParen+1:]) - if len(fields) < 1 { - return true - } - - // State is the first field after the command - // Z = zombie - state := fields[0] - return state == "Z" -} func (s *Session) UpdateStatus() error { if s.info.Status == string(StatusExited) { return nil } - if !s.IsAlive() { + alive := s.IsAlive() + if os.Getenv("VIBETUNNEL_DEBUG") != "" { + log.Printf("[DEBUG] UpdateStatus for session %s: PID=%d, alive=%v", s.ID[:8], s.info.Pid, alive) + } + + if !alive { s.info.Status = string(StatusExited) exitCode := 0 s.info.ExitCode = &exitCode @@ -434,7 +483,32 @@ func (s *Session) GetInfo() *Info { } func (i *Info) Save(sessionPath string) error { - data, err := json.MarshalIndent(i, "", " ") + // Convert to Rust format for saving + rustInfo := RustSessionInfo{ + ID: i.ID, + Name: i.Name, + Cmdline: i.Args, // Use Args array instead of Cmdline string + Cwd: i.Cwd, + Status: i.Status, + ExitCode: i.ExitCode, + Term: i.Term, + SpawnType: "pty", // Default spawn type + Cols: &i.Width, + Rows: &i.Height, + Env: i.Env, + } + + // Only include Pid if non-zero + if i.Pid > 0 { + rustInfo.Pid = &i.Pid + } + + // Only include StartedAt if not zero time + if !i.StartedAt.IsZero() { + rustInfo.StartedAt = &i.StartedAt + } + + data, err := json.MarshalIndent(rustInfo, "", " ") if err != nil { return err } @@ -465,21 +539,14 @@ func LoadInfo(sessionPath string) (*Info, error) { return nil, err } - // First try to unmarshal as Go format - var info Info - if err := json.Unmarshal(data, &info); err == nil { - // Successfully parsed as Go format - return &info, nil - } - - // If that fails, try Rust format + // Load Rust format (the only format we support) var rustInfo RustSessionInfo if err := json.Unmarshal(data, &rustInfo); err != nil { return nil, fmt.Errorf("failed to parse session.json: %w", err) } - // Convert Rust format to Go format - info = Info{ + // Convert Rust format to internal Info format + info := Info{ ID: rustInfo.ID, Name: rustInfo.Name, Cmdline: strings.Join(rustInfo.Cmdline, " "),