diff --git a/daemon/remote/cmd/programad-remote/main.go b/daemon/remote/cmd/programad-remote/main.go index 6bc4d7dac8b..182464f5078 100644 --- a/daemon/remote/cmd/programad-remote/main.go +++ b/daemon/remote/cmd/programad-remote/main.go @@ -3,7 +3,6 @@ package main import ( "bufio" "bytes" - "encoding/base64" "encoding/json" "errors" "flag" @@ -13,8 +12,6 @@ import ( "net" "os" "path/filepath" - "sort" - "strconv" "strings" "sync" "time" @@ -82,6 +79,10 @@ type sessionState struct { const maxRPCFrameBytes = 4 * 1024 * 1024 +// The RPC method handlers implementing this dispatch table live in +// main_proxy.go (proxy.* — raw TCP stream tunneling) and +// main_sessions.go (session.* — terminal attachment/resize bookkeeping). + func main() { if shouldRunCLIForInvocation(os.Args[0], os.Args[1:]) { os.Exit(runCLI(os.Args[1:])) @@ -360,621 +361,9 @@ func (s *rpcServer) handleRequest(req rpcRequest) rpcResponse { } } -func (s *rpcServer) handleProxyOpen(req rpcRequest) rpcResponse { - host, ok := getStringParam(req.Params, "host") - if !ok || host == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.open requires host", - }, - } - } - port, ok := getIntParam(req.Params, "port") - if !ok || port <= 0 || port > 65535 { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.open requires port in range 1-65535", - }, - } - } - - timeoutMs := 10000 - if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout && parsed >= 0 { - timeoutMs = parsed - } - - conn, err := net.DialTimeout( - "tcp", - net.JoinHostPort(host, strconv.Itoa(port)), - time.Duration(timeoutMs)*time.Millisecond, - ) - if err != nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "open_failed", - Message: err.Error(), - }, - } - } - setTCPNoDelay(conn) - - s.mu.Lock() - streamID := fmt.Sprintf("s-%d", s.nextStreamID) - s.nextStreamID++ - s.streams[streamID] = &streamState{conn: conn} - s.mu.Unlock() - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "stream_id": streamID, - }, - } -} - -func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse { - streamID, ok := getStringParam(req.Params, "stream_id") - if !ok || streamID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.close requires stream_id", - }, - } - } - - s.mu.Lock() - state, exists := s.streams[streamID] - if exists { - delete(s.streams, streamID) - } - s.mu.Unlock() - - if !exists { - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "closed": true, - }, - } - } - - _ = state.conn.Close() - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "closed": true, - }, - } -} - -func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { - streamID, ok := getStringParam(req.Params, "stream_id") - if !ok || streamID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.write requires stream_id", - }, - } - } - dataBase64, ok := getStringParam(req.Params, "data_base64") - if !ok { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.write requires data_base64", - }, - } - } - payload, err := base64.StdEncoding.DecodeString(dataBase64) - if err != nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "data_base64 must be valid base64", - }, - } - } - - state, found := s.getStream(streamID) - if !found { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "stream not found", - }, - } - } - conn := state.conn - - timeoutMs := 8000 - if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout { - timeoutMs = parsed - } - if timeoutMs > 0 { - if err := conn.SetWriteDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)); err != nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "stream_error", - Message: err.Error(), - }, - } - } - defer conn.SetWriteDeadline(time.Time{}) - } - - total := 0 - for total < len(payload) { - written, writeErr := conn.Write(payload[total:]) - if written == 0 && writeErr == nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "stream_error", - Message: "write made no progress", - }, - } - } - total += written - if writeErr != nil { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "stream_error", - Message: writeErr.Error(), - }, - } - } - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "written": total, - }, - } -} - -func (s *rpcServer) handleProxyStreamSubscribe(req rpcRequest) rpcResponse { - streamID, ok := getStringParam(req.Params, "stream_id") - if !ok || streamID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "proxy.stream.subscribe requires stream_id", - }, - } - } - - s.mu.Lock() - state, found := s.streams[streamID] - if !found { - s.mu.Unlock() - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "stream not found", - }, - } - } - alreadySubscribed := state.readerStarted - if !alreadySubscribed { - state.readerStarted = true - } - conn := state.conn - s.mu.Unlock() - - if !alreadySubscribed { - go s.streamPump(streamID, conn) - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "subscribed": true, - "already_subscribed": alreadySubscribed, - }, - } -} - -func (s *rpcServer) handleSessionOpen(req rpcRequest) rpcResponse { - sessionID, _ := getStringParam(req.Params, "session_id") - - s.mu.Lock() - defer s.mu.Unlock() - - if sessionID == "" { - sessionID = fmt.Sprintf("sess-%d", s.nextSessionID) - s.nextSessionID++ - } - - session, exists := s.sessions[sessionID] - if !exists { - session = &sessionState{ - attachments: map[string]sessionAttachment{}, - } - s.sessions[sessionID] = session - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func (s *rpcServer) handleSessionClose(req rpcRequest) rpcResponse { - sessionID, ok := getStringParam(req.Params, "session_id") - if !ok || sessionID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "session.close requires session_id", - }, - } - } - - s.mu.Lock() - _, exists := s.sessions[sessionID] - if exists { - delete(s.sessions, sessionID) - } - s.mu.Unlock() - - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: map[string]any{ - "session_id": sessionID, - "closed": true, - }, - } -} - -func (s *rpcServer) handleSessionAttach(req rpcRequest) rpcResponse { - sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.attach") - if badResp != nil { - return *badResp - } - - s.mu.Lock() - defer s.mu.Unlock() - - session, exists := s.sessions[sessionID] - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - - session.attachments[attachmentID] = sessionAttachment{ - Cols: cols, - Rows: rows, - UpdatedAt: time.Now().UTC(), - } - recomputeSessionSize(session) - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func (s *rpcServer) handleSessionResize(req rpcRequest) rpcResponse { - sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.resize") - if badResp != nil { - return *badResp - } - - s.mu.Lock() - defer s.mu.Unlock() - - session, exists := s.sessions[sessionID] - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - if _, exists := session.attachments[attachmentID]; !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "attachment not found", - }, - } - } - - session.attachments[attachmentID] = sessionAttachment{ - Cols: cols, - Rows: rows, - UpdatedAt: time.Now().UTC(), - } - recomputeSessionSize(session) - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func (s *rpcServer) handleSessionDetach(req rpcRequest) rpcResponse { - sessionID, ok := getStringParam(req.Params, "session_id") - if !ok || sessionID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "session.detach requires session_id", - }, - } - } - attachmentID, ok := getStringParam(req.Params, "attachment_id") - if !ok || attachmentID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "session.detach requires attachment_id", - }, - } - } - - s.mu.Lock() - defer s.mu.Unlock() - - session, exists := s.sessions[sessionID] - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - if _, exists := session.attachments[attachmentID]; !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "attachment not found", - }, - } - } - - delete(session.attachments, attachmentID) - recomputeSessionSize(session) - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func (s *rpcServer) handleSessionStatus(req rpcRequest) rpcResponse { - sessionID, ok := getStringParam(req.Params, "session_id") - if !ok || sessionID == "" { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: "session.status requires session_id", - }, - } - } - - s.mu.Lock() - defer s.mu.Unlock() - - session, exists := s.sessions[sessionID] - if !exists { - return rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "not_found", - Message: "session not found", - }, - } - } - - return rpcResponse{ - ID: req.ID, - OK: true, - Result: sessionSnapshot(sessionID, session), - } -} - -func parseSessionAttachmentParams(req rpcRequest, method string) (sessionID string, attachmentID string, cols int, rows int, badResp *rpcResponse) { - sessionID, ok := getStringParam(req.Params, "session_id") - if !ok || sessionID == "" { - resp := rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: method + " requires session_id", - }, - } - return "", "", 0, 0, &resp - } - attachmentID, ok = getStringParam(req.Params, "attachment_id") - if !ok || attachmentID == "" { - resp := rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: method + " requires attachment_id", - }, - } - return "", "", 0, 0, &resp - } - - cols, ok = getIntParam(req.Params, "cols") - if !ok || cols <= 0 { - resp := rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: method + " requires cols > 0", - }, - } - return "", "", 0, 0, &resp - } - rows, ok = getIntParam(req.Params, "rows") - if !ok || rows <= 0 { - resp := rpcResponse{ - ID: req.ID, - OK: false, - Error: &rpcError{ - Code: "invalid_params", - Message: method + " requires rows > 0", - }, - } - return "", "", 0, 0, &resp - } - - return sessionID, attachmentID, cols, rows, nil -} - -func recomputeSessionSize(session *sessionState) { - if len(session.attachments) == 0 { - session.effectiveCols = session.lastKnownCols - session.effectiveRows = session.lastKnownRows - return - } - - minCols := 0 - minRows := 0 - for _, attachment := range session.attachments { - if minCols == 0 || attachment.Cols < minCols { - minCols = attachment.Cols - } - if minRows == 0 || attachment.Rows < minRows { - minRows = attachment.Rows - } - } - - session.effectiveCols = minCols - session.effectiveRows = minRows - session.lastKnownCols = minCols - session.lastKnownRows = minRows -} - -func sessionSnapshot(sessionID string, session *sessionState) map[string]any { - attachmentIDs := make([]string, 0, len(session.attachments)) - for attachmentID := range session.attachments { - attachmentIDs = append(attachmentIDs, attachmentID) - } - sort.Strings(attachmentIDs) - - attachments := make([]map[string]any, 0, len(attachmentIDs)) - for _, attachmentID := range attachmentIDs { - attachment := session.attachments[attachmentID] - attachments = append(attachments, map[string]any{ - "attachment_id": attachmentID, - "cols": attachment.Cols, - "rows": attachment.Rows, - "updated_at": attachment.UpdatedAt.Format(time.RFC3339Nano), - }) - } - - return map[string]any{ - "session_id": sessionID, - "attachments": attachments, - "effective_cols": session.effectiveCols, - "effective_rows": session.effectiveRows, - "last_known_cols": session.lastKnownCols, - "last_known_rows": session.lastKnownRows, - } -} - -func (s *rpcServer) getStream(streamID string) (*streamState, bool) { - s.mu.Lock() - defer s.mu.Unlock() - state, ok := s.streams[streamID] - return state, ok -} - -func (s *rpcServer) dropStream(streamID string) { - s.mu.Lock() - state, ok := s.streams[streamID] - if ok { - delete(s.streams, streamID) - } - s.mu.Unlock() - if ok { - _ = state.conn.Close() - } -} - +// closeAll tears down every open proxy stream and clears session state. +// It touches both s.streams and s.sessions, so it lives here rather than +// in either handler file. func (s *rpcServer) closeAll() { s.mu.Lock() streams := make([]net.Conn, 0, len(s.streams)) @@ -991,62 +380,6 @@ func (s *rpcServer) closeAll() { } } -func (s *rpcServer) streamPump(streamID string, conn net.Conn) { - defer func() { - if recovered := recover(); recovered != nil { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.error", - StreamID: streamID, - Error: fmt.Sprintf("stream panic: %v", recovered), - }) - s.dropStream(streamID) - } - }() - - buffer := make([]byte, 32768) - for { - n, readErr := conn.Read(buffer) - data := append([]byte(nil), buffer[:max(0, n)]...) - if len(data) > 0 { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.data", - StreamID: streamID, - DataBase64: base64.StdEncoding.EncodeToString(data), - }) - } - - if readErr == nil { - if n == 0 { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.error", - StreamID: streamID, - Error: "read made no progress", - }) - s.dropStream(streamID) - return - } - continue - } - - if readErr == io.EOF { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.eof", - StreamID: streamID, - DataBase64: "", - }) - } else if !errors.Is(readErr, net.ErrClosed) { - _ = s.frameWriter.writeEvent(rpcEvent{ - Event: "proxy.stream.error", - StreamID: streamID, - Error: readErr.Error(), - }) - } - - s.dropStream(streamID) - return - } -} - func getStringParam(params map[string]any, key string) (string, bool) { if params == nil { return "", false diff --git a/daemon/remote/cmd/programad-remote/main_proxy.go b/daemon/remote/cmd/programad-remote/main_proxy.go new file mode 100644 index 00000000000..f9e72c831c4 --- /dev/null +++ b/daemon/remote/cmd/programad-remote/main_proxy.go @@ -0,0 +1,338 @@ +package main + +import ( + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "strconv" + "time" +) + +// --- proxy.* RPC handlers: raw TCP stream tunneling over the daemon's +// stdio RPC connection (proxy.open/close/write/stream.subscribe). --- + +func (s *rpcServer) handleProxyOpen(req rpcRequest) rpcResponse { + host, ok := getStringParam(req.Params, "host") + if !ok || host == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.open requires host", + }, + } + } + port, ok := getIntParam(req.Params, "port") + if !ok || port <= 0 || port > 65535 { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.open requires port in range 1-65535", + }, + } + } + + timeoutMs := 10000 + if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout && parsed >= 0 { + timeoutMs = parsed + } + + conn, err := net.DialTimeout( + "tcp", + net.JoinHostPort(host, strconv.Itoa(port)), + time.Duration(timeoutMs)*time.Millisecond, + ) + if err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "open_failed", + Message: err.Error(), + }, + } + } + setTCPNoDelay(conn) + + s.mu.Lock() + streamID := fmt.Sprintf("s-%d", s.nextStreamID) + s.nextStreamID++ + s.streams[streamID] = &streamState{conn: conn} + s.mu.Unlock() + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "stream_id": streamID, + }, + } +} + +func (s *rpcServer) handleProxyClose(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.close requires stream_id", + }, + } + } + + s.mu.Lock() + state, exists := s.streams[streamID] + if exists { + delete(s.streams, streamID) + } + s.mu.Unlock() + + if !exists { + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "closed": true, + }, + } + } + + _ = state.conn.Close() + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "closed": true, + }, + } +} + +func (s *rpcServer) handleProxyWrite(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.write requires stream_id", + }, + } + } + dataBase64, ok := getStringParam(req.Params, "data_base64") + if !ok { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.write requires data_base64", + }, + } + } + payload, err := base64.StdEncoding.DecodeString(dataBase64) + if err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "data_base64 must be valid base64", + }, + } + } + + state, found := s.getStream(streamID) + if !found { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "stream not found", + }, + } + } + conn := state.conn + + timeoutMs := 8000 + if parsed, hasTimeout := getIntParam(req.Params, "timeout_ms"); hasTimeout { + timeoutMs = parsed + } + if timeoutMs > 0 { + if err := conn.SetWriteDeadline(time.Now().Add(time.Duration(timeoutMs) * time.Millisecond)); err != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: err.Error(), + }, + } + } + defer conn.SetWriteDeadline(time.Time{}) + } + + total := 0 + for total < len(payload) { + written, writeErr := conn.Write(payload[total:]) + if written == 0 && writeErr == nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: "write made no progress", + }, + } + } + total += written + if writeErr != nil { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "stream_error", + Message: writeErr.Error(), + }, + } + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "written": total, + }, + } +} + +func (s *rpcServer) handleProxyStreamSubscribe(req rpcRequest) rpcResponse { + streamID, ok := getStringParam(req.Params, "stream_id") + if !ok || streamID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "proxy.stream.subscribe requires stream_id", + }, + } + } + + s.mu.Lock() + state, found := s.streams[streamID] + if !found { + s.mu.Unlock() + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "stream not found", + }, + } + } + alreadySubscribed := state.readerStarted + if !alreadySubscribed { + state.readerStarted = true + } + conn := state.conn + s.mu.Unlock() + + if !alreadySubscribed { + go s.streamPump(streamID, conn) + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "subscribed": true, + "already_subscribed": alreadySubscribed, + }, + } +} + +func (s *rpcServer) getStream(streamID string) (*streamState, bool) { + s.mu.Lock() + defer s.mu.Unlock() + state, ok := s.streams[streamID] + return state, ok +} + +func (s *rpcServer) dropStream(streamID string) { + s.mu.Lock() + state, ok := s.streams[streamID] + if ok { + delete(s.streams, streamID) + } + s.mu.Unlock() + if ok { + _ = state.conn.Close() + } +} + +func (s *rpcServer) streamPump(streamID string, conn net.Conn) { + defer func() { + if recovered := recover(); recovered != nil { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: fmt.Sprintf("stream panic: %v", recovered), + }) + s.dropStream(streamID) + } + }() + + buffer := make([]byte, 32768) + for { + n, readErr := conn.Read(buffer) + data := append([]byte(nil), buffer[:max(0, n)]...) + if len(data) > 0 { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.data", + StreamID: streamID, + DataBase64: base64.StdEncoding.EncodeToString(data), + }) + } + + if readErr == nil { + if n == 0 { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: "read made no progress", + }) + s.dropStream(streamID) + return + } + continue + } + + if readErr == io.EOF { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.eof", + StreamID: streamID, + DataBase64: "", + }) + } else if !errors.Is(readErr, net.ErrClosed) { + _ = s.frameWriter.writeEvent(rpcEvent{ + Event: "proxy.stream.error", + StreamID: streamID, + Error: readErr.Error(), + }) + } + + s.dropStream(streamID) + return + } +} diff --git a/daemon/remote/cmd/programad-remote/main_sessions.go b/daemon/remote/cmd/programad-remote/main_sessions.go new file mode 100644 index 00000000000..a3cadd65598 --- /dev/null +++ b/daemon/remote/cmd/programad-remote/main_sessions.go @@ -0,0 +1,357 @@ +package main + +import ( + "fmt" + "sort" + "time" +) + +// --- session.* RPC handlers: terminal session attachment/resize +// bookkeeping (session.open/close/attach/resize/detach/status). --- + +func (s *rpcServer) handleSessionOpen(req rpcRequest) rpcResponse { + sessionID, _ := getStringParam(req.Params, "session_id") + + s.mu.Lock() + defer s.mu.Unlock() + + if sessionID == "" { + sessionID = fmt.Sprintf("sess-%d", s.nextSessionID) + s.nextSessionID++ + } + + session, exists := s.sessions[sessionID] + if !exists { + session = &sessionState{ + attachments: map[string]sessionAttachment{}, + } + s.sessions[sessionID] = session + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionClose(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.close requires session_id", + }, + } + } + + s.mu.Lock() + _, exists := s.sessions[sessionID] + if exists { + delete(s.sessions, sessionID) + } + s.mu.Unlock() + + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: map[string]any{ + "session_id": sessionID, + "closed": true, + }, + } +} + +func (s *rpcServer) handleSessionAttach(req rpcRequest) rpcResponse { + sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.attach") + if badResp != nil { + return *badResp + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + session.attachments[attachmentID] = sessionAttachment{ + Cols: cols, + Rows: rows, + UpdatedAt: time.Now().UTC(), + } + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionResize(req rpcRequest) rpcResponse { + sessionID, attachmentID, cols, rows, badResp := parseSessionAttachmentParams(req, "session.resize") + if badResp != nil { + return *badResp + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + if _, exists := session.attachments[attachmentID]; !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "attachment not found", + }, + } + } + + session.attachments[attachmentID] = sessionAttachment{ + Cols: cols, + Rows: rows, + UpdatedAt: time.Now().UTC(), + } + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionDetach(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.detach requires session_id", + }, + } + } + attachmentID, ok := getStringParam(req.Params, "attachment_id") + if !ok || attachmentID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.detach requires attachment_id", + }, + } + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + if _, exists := session.attachments[attachmentID]; !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "attachment not found", + }, + } + } + + delete(session.attachments, attachmentID) + recomputeSessionSize(session) + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func (s *rpcServer) handleSessionStatus(req rpcRequest) rpcResponse { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: "session.status requires session_id", + }, + } + } + + s.mu.Lock() + defer s.mu.Unlock() + + session, exists := s.sessions[sessionID] + if !exists { + return rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "not_found", + Message: "session not found", + }, + } + } + + return rpcResponse{ + ID: req.ID, + OK: true, + Result: sessionSnapshot(sessionID, session), + } +} + +func parseSessionAttachmentParams(req rpcRequest, method string) (sessionID string, attachmentID string, cols int, rows int, badResp *rpcResponse) { + sessionID, ok := getStringParam(req.Params, "session_id") + if !ok || sessionID == "" { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires session_id", + }, + } + return "", "", 0, 0, &resp + } + attachmentID, ok = getStringParam(req.Params, "attachment_id") + if !ok || attachmentID == "" { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires attachment_id", + }, + } + return "", "", 0, 0, &resp + } + + cols, ok = getIntParam(req.Params, "cols") + if !ok || cols <= 0 { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires cols > 0", + }, + } + return "", "", 0, 0, &resp + } + rows, ok = getIntParam(req.Params, "rows") + if !ok || rows <= 0 { + resp := rpcResponse{ + ID: req.ID, + OK: false, + Error: &rpcError{ + Code: "invalid_params", + Message: method + " requires rows > 0", + }, + } + return "", "", 0, 0, &resp + } + + return sessionID, attachmentID, cols, rows, nil +} + +func recomputeSessionSize(session *sessionState) { + if len(session.attachments) == 0 { + session.effectiveCols = session.lastKnownCols + session.effectiveRows = session.lastKnownRows + return + } + + minCols := 0 + minRows := 0 + for _, attachment := range session.attachments { + if minCols == 0 || attachment.Cols < minCols { + minCols = attachment.Cols + } + if minRows == 0 || attachment.Rows < minRows { + minRows = attachment.Rows + } + } + + session.effectiveCols = minCols + session.effectiveRows = minRows + session.lastKnownCols = minCols + session.lastKnownRows = minRows +} + +func sessionSnapshot(sessionID string, session *sessionState) map[string]any { + attachmentIDs := make([]string, 0, len(session.attachments)) + for attachmentID := range session.attachments { + attachmentIDs = append(attachmentIDs, attachmentID) + } + sort.Strings(attachmentIDs) + + attachments := make([]map[string]any, 0, len(attachmentIDs)) + for _, attachmentID := range attachmentIDs { + attachment := session.attachments[attachmentID] + attachments = append(attachments, map[string]any{ + "attachment_id": attachmentID, + "cols": attachment.Cols, + "rows": attachment.Rows, + "updated_at": attachment.UpdatedAt.Format(time.RFC3339Nano), + }) + } + + return map[string]any{ + "session_id": sessionID, + "attachments": attachments, + "effective_cols": session.effectiveCols, + "effective_rows": session.effectiveRows, + "last_known_cols": session.lastKnownCols, + "last_known_rows": session.lastKnownRows, + } +} diff --git a/daemon/remote/cmd/programad-remote/tmux_args.go b/daemon/remote/cmd/programad-remote/tmux_args.go new file mode 100644 index 00000000000..c2185b532ec --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_args.go @@ -0,0 +1,122 @@ +package main + +import ( + "fmt" + "strings" +) + +// --- Tmux argument parsing --- + +type tmuxParsed struct { + flags map[string]bool // boolean flags like -d, -P + options map[string][]string // value flags like -t + positional []string +} + +func (p *tmuxParsed) hasFlag(f string) bool { + return p.flags[f] +} + +func (p *tmuxParsed) value(f string) string { + vals := p.options[f] + if len(vals) == 0 { + return "" + } + return vals[len(vals)-1] +} + +func splitTmuxCmd(args []string) (string, []string, error) { + globalValueFlags := map[string]bool{"-L": true, "-S": true, "-f": true} + globalBoolFlags := map[string]bool{"-V": true, "-v": true} + + i := 0 + for i < len(args) { + arg := args[i] + if !strings.HasPrefix(arg, "-") || arg == "-" { + return strings.ToLower(arg), args[i+1:], nil + } + if arg == "--" { + break + } + if globalBoolFlags[arg] { + return arg, nil, nil + } + if globalValueFlags[arg] { + // Skip the value + i++ + } + i++ + } + return "", nil, fmt.Errorf("tmux shim requires a command") +} + +func parseTmuxArgs(args []string, valueFlags, boolFlags []string) *tmuxParsed { + vSet := make(map[string]bool, len(valueFlags)) + for _, f := range valueFlags { + vSet[f] = true + } + bSet := make(map[string]bool, len(boolFlags)) + for _, f := range boolFlags { + bSet[f] = true + } + + p := &tmuxParsed{ + flags: make(map[string]bool), + options: make(map[string][]string), + } + pastTerminator := false + + for i := 0; i < len(args); i++ { + arg := args[i] + if pastTerminator { + p.positional = append(p.positional, arg) + continue + } + if arg == "--" { + pastTerminator = true + continue + } + if !strings.HasPrefix(arg, "-") || arg == "-" { + p.positional = append(p.positional, arg) + continue + } + if strings.HasPrefix(arg, "--") { + p.positional = append(p.positional, arg) + continue + } + + // Cluster parsing: -dPh etc. + cluster := []rune(arg[1:]) + cursor := 0 + recognized := false + for cursor < len(cluster) { + flag := "-" + string(cluster[cursor]) + if bSet[flag] { + p.flags[flag] = true + cursor++ + recognized = true + continue + } + if vSet[flag] { + remainder := string(cluster[cursor+1:]) + var value string + if remainder != "" { + value = remainder + } else if i+1 < len(args) { + i++ + value = args[i] + } + p.options[flag] = append(p.options[flag], value) + recognized = true + cursor = len(cluster) + continue + } + recognized = false + break + } + if !recognized { + p.positional = append(p.positional, arg) + } + } + return p +} diff --git a/daemon/remote/cmd/programad-remote/tmux_commands.go b/daemon/remote/cmd/programad-remote/tmux_commands.go new file mode 100644 index 00000000000..4a6d568dd93 --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_commands.go @@ -0,0 +1,659 @@ +package main + +import ( + "fmt" + "math" + "os" + "strings" + "time" +) + +// --- Command implementations --- + +// tmuxCreateWorkspace implements the body shared by `new-session` and +// `new-window`: create a workspace (optionally routed into the macOS window +// that owns targetWsId, mirroring tmux's "new window goes into the target's +// session"), rename it, and pipe in a shell command if one was given. +func tmuxCreateWorkspace(rc *rpcContext, p *tmuxParsed, title string, targetWsId string) (string, error) { + params := map[string]any{"focus": false} + if cwd := p.value("-c"); cwd != "" { + params["cwd"] = cwd + } + if targetWsId != "" { + // Route the new workspace into the same top-level window as the + // resolved target instead of always landing in the active window. + params["workspace_id"] = targetWsId + } + created, err := rc.call("workspace.create", params) + if err != nil { + return "", err + } + wsId, _ := created["workspace_id"].(string) + if wsId == "" { + return "", fmt.Errorf("workspace.create did not return workspace_id") + } + if strings.TrimSpace(title) != "" { + rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) + } + if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { + surfaceId, err := tmuxGetFirstSurface(rc, wsId) + if err == nil { + rc.call("surface.send_text", map[string]any{"workspace_id": wsId, "surface_id": surfaceId, "text": text}) + } + } + return wsId, nil +} + +// tmuxPrintWorkspaceRef prints the `-P`/`-F` formatted reference for a +// newly created workspace, shared by `new-session` and `new-window`. +func tmuxPrintWorkspaceRef(rc *rpcContext, p *tmuxParsed, wsId string) { + if !p.hasFlag("-P") { + return + } + ctx, err := tmuxFormatContext(rc, wsId, "", "") + if err != nil { + fmt.Printf("@%s\n", wsId) + return + } + fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, "@"+wsId)) +} + +func tmuxNewSession(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-s"}, []string{"-A", "-d", "-P"}) + if p.hasFlag("-A") { + return fmt.Errorf("new-session -A is not supported") + } + title := firstNonEmpty(p.value("-n"), p.value("-s")) + wsId, err := tmuxCreateWorkspace(rc, p, title, "") + if err != nil { + return err + } + tmuxPrintWorkspaceRef(rc, p, wsId) + return nil +} + +func tmuxNewWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-t"}, []string{"-d", "-P"}) + + targetWsId := "" + if raw := strings.TrimSpace(p.value("-t")); raw != "" { + resolved, err := tmuxResolveWorkspaceTarget(rc, raw) + if err != nil { + return err + } + targetWsId = resolved + } + + wsId, err := tmuxCreateWorkspace(rc, p, p.value("-n"), targetWsId) + if err != nil { + return err + } + tmuxPrintWorkspaceRef(rc, p, wsId) + return nil +} + +func tmuxSplitWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-c", "-F", "-l", "-t"}, []string{"-P", "-b", "-d", "-h", "-v"}) + + targetWs, _, targetSurface, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + + direction := "down" + if p.hasFlag("-h") { + direction = "right" + if p.hasFlag("-b") { + direction = "left" + } + } else if p.hasFlag("-b") { + direction = "up" + } + + // Anchor splits to the leader surface for agent teams. + callerWorkspace := tmuxCallerWorkspaceHandle() + anchoredCallerSurface := "" + if callerWorkspace != "" { + if wsId, err := tmuxResolveWorkspaceId(rc, callerWorkspace); err == nil { + if anchored := tmuxAnchoredSplitTarget(rc, wsId); anchored != nil { + targetWs = wsId + targetSurface = anchored.targetSurfaceId + direction = anchored.direction + anchoredCallerSurface = anchored.callerSurfaceId + } + } + } + + focusNewPane := !p.hasFlag("-d") + created, err := rc.call("surface.split", map[string]any{ + "workspace_id": targetWs, + "surface_id": targetSurface, + "direction": direction, + "focus": focusNewPane, + }) + if err != nil { + return err + } + surfaceId, _ := created["surface_id"].(string) + if surfaceId == "" { + return fmt.Errorf("surface.split did not return surface_id") + } + newPaneId, _ := created["pane_id"].(string) + + // Track for main-vertical layout + store := loadTmuxCompatStore() + store.LastSplitSurface[targetWs] = surfaceId + if _, ok := store.MainVerticalLayouts[targetWs]; ok { + mvs := store.MainVerticalLayouts[targetWs] + mvs.LastColumnSurfaceId = surfaceId + store.MainVerticalLayouts[targetWs] = mvs + } else if direction == "right" && anchoredCallerSurface != "" { + store.MainVerticalLayouts[targetWs] = mainVerticalState{ + MainSurfaceId: anchoredCallerSurface, + LastColumnSurfaceId: surfaceId, + } + } + saveTmuxCompatStore(store) + + // Equalize vertical splits + rc.call("workspace.equalize_splits", map[string]any{ + "workspace_id": targetWs, + "orientation": "vertical", + }) + + if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { + rc.call("surface.send_text", map[string]any{ + "workspace_id": targetWs, + "surface_id": surfaceId, + "text": text, + }) + } + + if p.hasFlag("-P") { + ctx, err := tmuxFormatContext(rc, targetWs, newPaneId, surfaceId) + if err != nil { + fmt.Println(surfaceId) + return nil + } + fallback := surfaceId + if pid, ok := ctx["pane_id"]; ok { + fallback = pid + } + fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) + } + return nil +} + +func tmuxSelectWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("workspace.select", map[string]any{"workspace_id": wsId}) + return err +} + +func tmuxSelectPane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-P", "-T", "-t"}, nil) + // -P (style) and -T (title) are no-ops + if p.value("-P") != "" || p.value("-T") != "" { + return nil + } + wsId, paneId, err := tmuxResolvePaneTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("pane.focus", map[string]any{"workspace_id": wsId, "pane_id": paneId}) + return err +} + +func tmuxKillWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("workspace.close", map[string]any{"workspace_id": wsId}) + if err != nil { + return err + } + _ = tmuxPruneCompatWorkspaceState(wsId) + return nil +} + +func tmuxKillPane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("surface.close", map[string]any{"workspace_id": wsId, "surface_id": surfId}) + if err != nil { + return err + } + _ = tmuxPruneCompatSurfaceState(wsId, surfId) + // Re-equalize after removal + rc.call("workspace.equalize_splits", map[string]any{"workspace_id": wsId, "orientation": "vertical"}) + return nil +} + +func tmuxSendKeys(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, []string{"-l"}) + wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + text := tmuxSendKeysText(p.positional, p.hasFlag("-l")) + if text != "" { + _, err = rc.call("surface.send_text", map[string]any{ + "workspace_id": wsId, + "surface_id": surfId, + "text": text, + }) + } + return err +} + +func tmuxCapturePane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-E", "-S", "-t"}, []string{"-J", "-N", "-p"}) + wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + params := map[string]any{ + "workspace_id": wsId, + "surface_id": surfId, + "scrollback": true, + } + if start := p.value("-S"); start != "" { + if lines := parseInt(start); lines < 0 { + params["lines"] = int(math.Abs(float64(lines))) + } + } + payload, err := rc.call("surface.read_text", params) + if err != nil { + return err + } + text, _ := payload["text"].(string) + if p.hasFlag("-p") { + fmt.Print(text) + } else { + store := loadTmuxCompatStore() + store.Buffers["default"] = text + saveTmuxCompatStore(store) + } + return nil +} + +func tmuxDisplayMessage(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-F", "-t"}, []string{"-p"}) + wsId, paneId, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + ctx, err := tmuxFormatContext(rc, wsId, paneId, surfId) + if err != nil { + ctx = map[string]string{} + } + + // Enrich with geometry + panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) + if err == nil { + panes, _ := panePayload["panes"].([]any) + containerFrame, _ := panePayload["container_frame"].(map[string]any) + var matchingPane map[string]any + if paneId != "" { + for _, p := range panes { + pn, _ := p.(map[string]any) + if pid, _ := pn["id"].(string); pid == paneId { + matchingPane = pn + break + } + } + } + if matchingPane == nil { + for _, p := range panes { + pn, _ := p.(map[string]any) + if focused, _ := pn["focused"].(bool); focused { + matchingPane = pn + break + } + } + } + if matchingPane == nil && len(panes) > 0 { + matchingPane, _ = panes[0].(map[string]any) + } + if matchingPane != nil { + tmuxEnrichContextWithGeometry(ctx, matchingPane, containerFrame) + } + } + + format := p.value("-F") + if len(p.positional) > 0 { + format = strings.Join(p.positional, " ") + } + rendered := tmuxRenderFormat(format, ctx, "") + if p.hasFlag("-p") || rendered != "" { + fmt.Println(rendered) + } + return nil +} + +func tmuxListWindows(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-F", "-t"}, nil) + items, err := tmuxWorkspaceItems(rc) + if err != nil { + return err + } + for _, item := range items { + wsId, _ := item["id"].(string) + if wsId == "" { + continue + } + ctx, err := tmuxFormatContext(rc, wsId, "", "") + if err != nil { + continue + } + fallback := "" + if idx, ok := ctx["window_index"]; ok { + fallback = idx + } else { + fallback = "?" + } + if name, ok := ctx["window_name"]; ok { + fallback += " " + name + } else { + fallback += " " + wsId + } + fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) + } + return nil +} + +func tmuxListPanes(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-F", "-t"}, nil) + + target := p.value("-t") + var wsId string + var err error + + if target != "" && tmuxPaneSelector(target) != "" { + wsId, _, err = tmuxResolvePaneTarget(rc, target) + } else { + wsId, err = tmuxResolveWorkspaceTarget(rc, target) + } + if err != nil { + return err + } + + payload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) + if err != nil { + return err + } + panes, _ := payload["panes"].([]any) + containerFrame, _ := payload["container_frame"].(map[string]any) + + for _, p2 := range panes { + pane, _ := p2.(map[string]any) + if pane == nil { + continue + } + paneId, _ := pane["id"].(string) + if paneId == "" { + continue + } + ctx, err := tmuxFormatContext(rc, wsId, paneId, "") + if err != nil { + continue + } + tmuxEnrichContextWithGeometry(ctx, pane, containerFrame) + fallback := "%" + paneId + if pid, ok := ctx["pane_id"]; ok { + fallback = pid + } + fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) + } + return nil +} + +func tmuxRenameWindow(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + title := strings.TrimSpace(strings.Join(p.positional, " ")) + if title == "" { + return fmt.Errorf("rename-window requires a title") + } + wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) + return err +} + +func tmuxResizePane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t", "-x", "-y"}, []string{"-D", "-L", "-R", "-U"}) + wsId, paneId, err := tmuxResolvePaneTarget(rc, p.value("-t")) + if err != nil { + return err + } + + hasDirectional := p.hasFlag("-L") || p.hasFlag("-R") || p.hasFlag("-U") || p.hasFlag("-D") + + if !hasDirectional { + if absWidthStr := p.value("-x"); absWidthStr != "" { + absWidth := parseInt(strings.ReplaceAll(absWidthStr, "%", "")) + // Get current width to compute delta + panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) + if err != nil { + return err + } + panes, _ := panePayload["panes"].([]any) + for _, pp := range panes { + pane, _ := pp.(map[string]any) + if pane == nil { + continue + } + if pid, _ := pane["id"].(string); pid == paneId { + cellW := intFromAnyGo(pane["cell_width_px"]) + currentCols := intFromAnyGo(pane["columns"]) + if cellW > 0 && currentCols >= 0 { + delta := absWidth - currentCols + if delta != 0 { + dir := "right" + if delta < 0 { + dir = "left" + delta = -delta + } + rc.call("pane.resize", map[string]any{ + "workspace_id": wsId, + "pane_id": paneId, + "direction": dir, + "amount": delta * cellW, + }) + } + } + break + } + } + return nil + } + } + + if hasDirectional { + dir := "right" + if p.hasFlag("-L") { + dir = "left" + } else if p.hasFlag("-U") { + dir = "up" + } else if p.hasFlag("-D") { + dir = "down" + } + rawAmount := firstNonEmpty(p.value("-x"), p.value("-y"), "5") + rawAmount = strings.ReplaceAll(rawAmount, "%", "") + amount := parseInt(rawAmount) + if amount <= 0 { + amount = 5 + } + _, err := rc.call("pane.resize", map[string]any{ + "workspace_id": wsId, + "pane_id": paneId, + "direction": dir, + "amount": amount, + }) + return err + } + return nil +} + +func tmuxWaitFor(_ *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"--timeout"}, []string{"-S"}) + name := "" + for _, pos := range p.positional { + if !strings.HasPrefix(pos, "-") { + name = pos + break + } + } + if name == "" { + return fmt.Errorf("wait-for requires a name") + } + + signalPath := tmuxWaitForSignalPath(name) + + if p.hasFlag("-S") { + // Signal mode: create the file + os.WriteFile(signalPath, []byte{}, 0644) + fmt.Println("OK") + return nil + } + + // Wait mode: poll for the file + timeoutStr := p.value("--timeout") + timeout := 30.0 + if timeoutStr != "" { + if t := parseFloat(timeoutStr); t > 0 { + timeout = t + } + } + + deadline := time.Now().Add(time.Duration(timeout * float64(time.Second))) + for time.Now().Before(deadline) { + if _, err := os.Stat(signalPath); err == nil { + os.Remove(signalPath) + return nil + } + time.Sleep(50 * time.Millisecond) + } + return fmt.Errorf("wait-for timeout: %s", name) +} + +func tmuxLastPane(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + if err != nil { + return err + } + _, err = rc.call("pane.last", map[string]any{"workspace_id": wsId}) + return err +} + +func tmuxHasSession(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + _, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) + return err +} + +func tmuxSelectLayout(rc *rpcContext, args []string) error { + p := parseTmuxArgs(args, []string{"-t"}, nil) + layoutName := "" + if len(p.positional) > 0 { + layoutName = p.positional[0] + } + + // Resolve workspace from target (may be a pane reference) + var wsId string + var err error + if target := p.value("-t"); target != "" { + if tmuxPaneSelector(target) != "" { + wsId, _, err = tmuxResolvePaneTarget(rc, target) + } else { + wsId, err = tmuxResolveWorkspaceTarget(rc, target) + } + } else { + wsId, err = tmuxResolveWorkspaceTarget(rc, "") + } + if err != nil { + return err + } + + if layoutName == "main-vertical" || layoutName == "main-horizontal" { + orientation := "vertical" + if layoutName == "main-horizontal" { + orientation = "horizontal" + } + rc.call("workspace.equalize_splits", map[string]any{ + "workspace_id": wsId, + "orientation": orientation, + }) + } else { + rc.call("workspace.equalize_splits", map[string]any{"workspace_id": wsId}) + } + + if layoutName == "main-vertical" { + if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" { + store := loadTmuxCompatStore() + existingColumn := "" + if existing, ok := store.MainVerticalLayouts[wsId]; ok { + existingColumn = existing.LastColumnSurfaceId + } + seedColumn := existingColumn + if seedColumn == "" { + seedColumn = store.LastSplitSurface[wsId] + } + store.MainVerticalLayouts[wsId] = mainVerticalState{ + MainSurfaceId: callerSurface, + LastColumnSurfaceId: seedColumn, + } + saveTmuxCompatStore(store) + } + } else if layoutName != "" { + _ = tmuxPruneCompatWorkspaceState(wsId) + } + + return nil +} + +func tmuxShowBuffer(args []string) error { + p := parseTmuxArgs(args, []string{"-b"}, nil) + name := p.value("-b") + if name == "" { + name = "default" + } + store := loadTmuxCompatStore() + if buf, ok := store.Buffers[name]; ok { + fmt.Print(buf) + } + return nil +} + +func tmuxSaveBuffer(args []string) error { + p := parseTmuxArgs(args, []string{"-b"}, nil) + name := p.value("-b") + if name == "" { + name = "default" + } + store := loadTmuxCompatStore() + buf, ok := store.Buffers[name] + if !ok { + return fmt.Errorf("buffer not found: %s", name) + } + if len(p.positional) > 0 { + outputPath := strings.TrimSpace(p.positional[len(p.positional)-1]) + if outputPath != "" { + return os.WriteFile(outputPath, []byte(buf), 0644) + } + } + fmt.Print(buf) + return nil +} diff --git a/daemon/remote/cmd/programad-remote/tmux_compat.go b/daemon/remote/cmd/programad-remote/tmux_compat.go index 5c4e1111744..5612913f043 100644 --- a/daemon/remote/cmd/programad-remote/tmux_compat.go +++ b/daemon/remote/cmd/programad-remote/tmux_compat.go @@ -3,12 +3,7 @@ package main import ( "encoding/json" "fmt" - "math" "os" - "path/filepath" - "regexp" - "strings" - "time" ) // runTmuxCompat handles `programa __tmux-compat `, translating tmux @@ -48,1008 +43,14 @@ func (rc *rpcContext) call(method string, params map[string]any) (map[string]any return result, nil } -// --- Tmux argument parsing --- - -type tmuxParsed struct { - flags map[string]bool // boolean flags like -d, -P - options map[string][]string // value flags like -t - positional []string -} - -func (p *tmuxParsed) hasFlag(f string) bool { - return p.flags[f] -} - -func (p *tmuxParsed) value(f string) string { - vals := p.options[f] - if len(vals) == 0 { - return "" - } - return vals[len(vals)-1] -} - -func splitTmuxCmd(args []string) (string, []string, error) { - globalValueFlags := map[string]bool{"-L": true, "-S": true, "-f": true} - globalBoolFlags := map[string]bool{"-V": true, "-v": true} - - i := 0 - for i < len(args) { - arg := args[i] - if !strings.HasPrefix(arg, "-") || arg == "-" { - return strings.ToLower(arg), args[i+1:], nil - } - if arg == "--" { - break - } - if globalBoolFlags[arg] { - return arg, nil, nil - } - if globalValueFlags[arg] { - // Skip the value - i++ - } - i++ - } - return "", nil, fmt.Errorf("tmux shim requires a command") -} - -func parseTmuxArgs(args []string, valueFlags, boolFlags []string) *tmuxParsed { - vSet := make(map[string]bool, len(valueFlags)) - for _, f := range valueFlags { - vSet[f] = true - } - bSet := make(map[string]bool, len(boolFlags)) - for _, f := range boolFlags { - bSet[f] = true - } - - p := &tmuxParsed{ - flags: make(map[string]bool), - options: make(map[string][]string), - } - pastTerminator := false - - for i := 0; i < len(args); i++ { - arg := args[i] - if pastTerminator { - p.positional = append(p.positional, arg) - continue - } - if arg == "--" { - pastTerminator = true - continue - } - if !strings.HasPrefix(arg, "-") || arg == "-" { - p.positional = append(p.positional, arg) - continue - } - if strings.HasPrefix(arg, "--") { - p.positional = append(p.positional, arg) - continue - } - - // Cluster parsing: -dPh etc. - cluster := []rune(arg[1:]) - cursor := 0 - recognized := false - for cursor < len(cluster) { - flag := "-" + string(cluster[cursor]) - if bSet[flag] { - p.flags[flag] = true - cursor++ - recognized = true - continue - } - if vSet[flag] { - remainder := string(cluster[cursor+1:]) - var value string - if remainder != "" { - value = remainder - } else if i+1 < len(args) { - i++ - value = args[i] - } - p.options[flag] = append(p.options[flag], value) - recognized = true - cursor = len(cluster) - continue - } - recognized = false - break - } - if !recognized { - p.positional = append(p.positional, arg) - } - } - return p -} - -// --- Format string rendering --- - -var tmuxFormatVarRe = regexp.MustCompile(`#\{[^}]+\}`) - -func tmuxRenderFormat(format string, context map[string]string, fallback string) string { - if format == "" { - return fallback - } - rendered := format - for key, value := range context { - rendered = strings.ReplaceAll(rendered, "#{"+key+"}", value) - } - // Remove any remaining unresolved #{...} variables - rendered = tmuxFormatVarRe.ReplaceAllString(rendered, "") - rendered = strings.TrimSpace(rendered) - if rendered == "" { - return fallback - } - return rendered -} - -// --- Format context building --- - -func tmuxFormatContext(rc *rpcContext, workspaceId string, paneId string, surfaceId string) (map[string]string, error) { - canonicalWsId, err := tmuxResolveWorkspaceId(rc, workspaceId) - if err != nil { - return nil, err - } - - ctx := map[string]string{ - "session_name": "programa", - "session_id": "$0", - "window_id": "@" + canonicalWsId, - "window_uuid": canonicalWsId, - "window_active": "1", - "window_flags": "*", - "pane_active": "1", - } - - // Get workspace list for index/title - workspaces, err := tmuxWorkspaceItems(rc) - if err == nil { - for _, ws := range workspaces { - wsId, _ := ws["id"].(string) - wsRef, _ := ws["ref"].(string) - if wsId == canonicalWsId || wsRef == workspaceId { - if idx := intFromAnyGo(ws["index"]); idx >= 0 { - ctx["window_index"] = fmt.Sprintf("%d", idx) - } - if title, _ := ws["title"].(string); strings.TrimSpace(title) != "" { - ctx["window_name"] = strings.TrimSpace(title) - } - if paneCount := intFromAnyGo(ws["pane_count"]); paneCount >= 0 { - ctx["window_panes"] = fmt.Sprintf("%d", paneCount) - } - break - } - } - } - - // Get current surface info - currentPayload, err := rc.call("surface.current", map[string]any{"workspace_id": canonicalWsId}) - if err != nil { - return ctx, nil - } - - resolvedPaneId := paneId - if resolvedPaneId == "" { - if pid, ok := currentPayload["pane_id"].(string); ok { - resolvedPaneId = pid - } else if pref, ok := currentPayload["pane_ref"].(string); ok { - resolvedPaneId = pref - } - } - - resolvedSurfaceId := surfaceId - if resolvedSurfaceId == "" && resolvedPaneId != "" { - if sid, err := tmuxSelectedSurfaceId(rc, canonicalWsId, resolvedPaneId); err == nil { - resolvedSurfaceId = sid - } - } - if resolvedSurfaceId == "" { - if sid, ok := currentPayload["surface_id"].(string); ok { - resolvedSurfaceId = sid - } - } - - if resolvedPaneId != "" { - ctx["pane_id"] = "%" + resolvedPaneId - ctx["pane_uuid"] = resolvedPaneId - - panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": canonicalWsId}) - if err == nil { - panes, _ := panePayload["panes"].([]any) - for _, p := range panes { - pane, _ := p.(map[string]any) - if pane == nil { - continue - } - if pid, _ := pane["id"].(string); pid == resolvedPaneId { - if idx := intFromAnyGo(pane["index"]); idx >= 0 { - ctx["pane_index"] = fmt.Sprintf("%d", idx) - } - break - } - } - } - } - - if resolvedSurfaceId != "" { - ctx["surface_id"] = resolvedSurfaceId - surfacePayload, err := rc.call("surface.list", map[string]any{"workspace_id": canonicalWsId}) - if err == nil { - surfaces, _ := surfacePayload["surfaces"].([]any) - for _, s := range surfaces { - surface, _ := s.(map[string]any) - if surface == nil { - continue - } - if sid, _ := surface["id"].(string); sid == resolvedSurfaceId { - if title, _ := surface["title"].(string); strings.TrimSpace(title) != "" { - ctx["pane_title"] = strings.TrimSpace(title) - if _, ok := ctx["window_name"]; !ok { - ctx["window_name"] = strings.TrimSpace(title) - } - } - break - } - } - } - } - - return ctx, nil -} - -func tmuxEnrichContextWithGeometry(ctx map[string]string, pane map[string]any, containerFrame map[string]any) { - isFocused, _ := pane["focused"].(bool) - if isFocused { - ctx["pane_active"] = "1" - } else { - ctx["pane_active"] = "0" - } - - columns := intFromAnyGo(pane["columns"]) - rows := intFromAnyGo(pane["rows"]) - if columns < 0 || rows < 0 { - return - } - ctx["pane_width"] = fmt.Sprintf("%d", columns) - ctx["pane_height"] = fmt.Sprintf("%d", rows) - - cellW := intFromAnyGo(pane["cell_width_px"]) - cellH := intFromAnyGo(pane["cell_height_px"]) - if cellW <= 0 || cellH <= 0 { - return - } - - if frame, ok := pane["pixel_frame"].(map[string]any); ok { - px := floatFromAny(frame["x"]) - py := floatFromAny(frame["y"]) - ctx["pane_left"] = fmt.Sprintf("%d", int(px)/cellW) - ctx["pane_top"] = fmt.Sprintf("%d", int(py)/cellH) - } - - if containerFrame != nil { - cw := floatFromAny(containerFrame["width"]) - ch := floatFromAny(containerFrame["height"]) - ww := int(cw) / cellW - wh := int(ch) / cellH - if ww < 1 { - ww = 1 - } - if wh < 1 { - wh = 1 - } - ctx["window_width"] = fmt.Sprintf("%d", ww) - ctx["window_height"] = fmt.Sprintf("%d", wh) - } -} - -func floatFromAny(v any) float64 { - switch t := v.(type) { - case float64: - return t - case int: - return float64(t) - case json.Number: - f, _ := t.Float64() - return f - } - return 0 -} - -func intFromAnyGo(v any) int { - switch t := v.(type) { - case float64: - return int(t) - case int: - return t - case json.Number: - i, err := t.Int64() - if err != nil { - return -1 - } - return int(i) - } - return -1 -} - -// --- Target resolution --- - -func tmuxCallerWorkspaceHandle() string { - return strings.TrimSpace(os.Getenv("PROGRAMA_WORKSPACE_ID")) -} - -func tmuxCallerSurfaceHandle() string { - return strings.TrimSpace(os.Getenv("PROGRAMA_SURFACE_ID")) -} - -func tmuxResolvedCallerWorkspaceId(rc *rpcContext) string { - caller := tmuxCallerWorkspaceHandle() - if caller == "" { - return "" - } - wsId, err := tmuxResolveWorkspaceId(rc, caller) - if err != nil { - return "" - } - return wsId -} - -func tmuxCallerPaneHandle() string { - for _, key := range []string{"TMUX_PANE", "PROGRAMA_PANE_ID"} { - v := strings.TrimSpace(os.Getenv(key)) - if v != "" { - return strings.TrimPrefix(v, "%") - } - } - return "" -} - -func tmuxWorkspaceItems(rc *rpcContext) ([]map[string]any, error) { - payload, err := rc.call("workspace.list", nil) - if err != nil { - return nil, err - } - items, _ := payload["workspaces"].([]any) - var result []map[string]any - for _, item := range items { - if m, ok := item.(map[string]any); ok { - result = append(result, m) - } - } - return result, nil -} - -func isUUIDish(s string) bool { - // Simple UUID check: 8-4-4-4-12 hex - if len(s) != 36 { - return false - } - for i, c := range s { - if i == 8 || i == 13 || i == 18 || i == 23 { - if c != '-' { - return false - } - } else if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { - return false - } - } - return true -} - -func tmuxResolveWorkspaceId(rc *rpcContext, raw string) (string, error) { - if raw == "" || raw == "current" { - if caller := tmuxCallerWorkspaceHandle(); caller != "" { - if isUUIDish(caller) { - return caller, nil - } - // Resolve ref - return tmuxResolveWorkspaceId(rc, caller) - } - payload, err := rc.call("workspace.current", nil) - if err != nil { - return "", fmt.Errorf("no workspace selected: %w", err) - } - if wsId, ok := payload["workspace_id"].(string); ok { - return wsId, nil - } - return "", fmt.Errorf("no workspace selected") - } - - if isUUIDish(raw) { - return raw, nil - } - - // Try to resolve as ref or index - items, err := tmuxWorkspaceItems(rc) - if err != nil { - return "", err - } - for _, item := range items { - if ref, _ := item["ref"].(string); ref == raw { - if id, _ := item["id"].(string); id != "" { - return id, nil - } - } - } - - // Try name match - needle := strings.TrimSpace(raw) - for _, item := range items { - title, _ := item["title"].(string) - if strings.TrimSpace(title) == needle { - if id, _ := item["id"].(string); id != "" { - return id, nil - } - } - } - - return "", fmt.Errorf("workspace not found: %s", raw) -} - -func tmuxResolveWorkspaceTarget(rc *rpcContext, raw string) (string, error) { - raw = strings.TrimSpace(raw) - if raw == "" { - if caller := tmuxCallerWorkspaceHandle(); caller != "" { - return tmuxResolveWorkspaceId(rc, caller) - } - return tmuxResolveWorkspaceId(rc, "") - } - - if raw == "!" || raw == "^" || raw == "-" { - payload, err := rc.call("workspace.last", nil) - if err != nil { - return "", fmt.Errorf("previous workspace not found: %w", err) - } - if wsId, ok := payload["workspace_id"].(string); ok { - return wsId, nil - } - return "", fmt.Errorf("previous workspace not found") - } - - // Strip session:window.pane format - token := raw - if dot := strings.LastIndex(token, "."); dot >= 0 { - token = token[:dot] - } - if colon := strings.LastIndex(token, ":"); colon >= 0 { - suffix := token[colon+1:] - if suffix != "" { - token = suffix - } else { - token = token[:colon] - } - } - token = strings.TrimPrefix(token, "@") - - return tmuxResolveWorkspaceId(rc, token) -} - -func tmuxPaneSelector(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(raw, "%") { - return raw[1:] - } - if strings.HasPrefix(raw, "pane:") { - return raw - } - if dot := strings.LastIndex(raw, "."); dot >= 0 { - return raw[dot+1:] - } - return "" -} - -func tmuxWindowSelector(raw string) string { - raw = strings.TrimSpace(raw) - if raw == "" { - return "" - } - if strings.HasPrefix(raw, "%") || strings.HasPrefix(raw, "pane:") { - return "" - } - if dot := strings.LastIndex(raw, "."); dot >= 0 { - return raw[:dot] - } - return raw -} - -func tmuxCanonicalPaneId(rc *rpcContext, handle string, workspaceId string) (string, error) { - if isUUIDish(handle) { - return handle, nil - } - payload, err := rc.call("pane.list", map[string]any{"workspace_id": workspaceId}) - if err != nil { - return "", err - } - panes, _ := payload["panes"].([]any) - for _, p := range panes { - pane, _ := p.(map[string]any) - if pane == nil { - continue - } - if ref, _ := pane["ref"].(string); ref == handle { - if id, _ := pane["id"].(string); id != "" { - return id, nil - } - } - if id, _ := pane["id"].(string); id == handle { - return id, nil - } - } - return "", fmt.Errorf("pane not found: %s", handle) -} - -func tmuxCanonicalSurfaceId(rc *rpcContext, handle string, workspaceId string) (string, error) { - payload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId}) - if err != nil { - return "", err - } - surfaces, _ := payload["surfaces"].([]any) - for _, s := range surfaces { - surface, _ := s.(map[string]any) - if surface == nil { - continue - } - if ref, _ := surface["ref"].(string); ref == handle { - if id, _ := surface["id"].(string); id != "" { - return id, nil - } - } - if id, _ := surface["id"].(string); id == handle { - return id, nil - } - } - return "", fmt.Errorf("surface not found: %s", handle) -} - -func tmuxFocusedPaneId(rc *rpcContext, workspaceId string) (string, error) { - payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId}) - if err != nil { - return "", err - } - if pid, ok := payload["pane_id"].(string); ok { - return pid, nil - } - if pref, ok := payload["pane_ref"].(string); ok { - return tmuxCanonicalPaneId(rc, pref, workspaceId) - } - return "", fmt.Errorf("pane not found") -} - -func tmuxWorkspaceIdForPaneHandle(rc *rpcContext, handle string) (string, error) { - if !isUUIDish(handle) { - return "", fmt.Errorf("not a UUID") - } - workspaces, err := tmuxWorkspaceItems(rc) - if err != nil { - return "", err - } - for _, ws := range workspaces { - wsId, _ := ws["id"].(string) - if wsId == "" { - continue - } - payload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) - if err != nil { - continue - } - panes, _ := payload["panes"].([]any) - for _, p := range panes { - pane, _ := p.(map[string]any) - if pane == nil { - continue - } - if pid, _ := pane["id"].(string); pid == handle { - return wsId, nil - } - if pref, _ := pane["ref"].(string); pref == handle { - return wsId, nil - } - } - } - return "", fmt.Errorf("pane not found in any workspace") -} - -func tmuxResolvePaneTarget(rc *rpcContext, raw string) (workspaceId string, paneId string, err error) { - raw = strings.TrimSpace(raw) - paneSelector := tmuxPaneSelector(raw) - windowSelector := tmuxWindowSelector(raw) - - if windowSelector != "" { - workspaceId, err = tmuxResolveWorkspaceTarget(rc, windowSelector) - if err != nil { - return "", "", err - } - } else if paneSelector != "" { - workspaceId, err = tmuxWorkspaceIdForPaneHandle(rc, paneSelector) - if err != nil { - workspaceId, err = tmuxResolveWorkspaceTarget(rc, "") - if err != nil { - return "", "", err - } - } - } else { - workspaceId, err = tmuxResolveWorkspaceTarget(rc, "") - if err != nil { - return "", "", err - } - } - - if paneSelector != "" { - paneId, err = tmuxCanonicalPaneId(rc, paneSelector, workspaceId) - if err != nil { - return "", "", err - } - } else if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId { - if callerPane := tmuxCallerPaneHandle(); callerPane != "" { - if pid, err2 := tmuxCanonicalPaneId(rc, callerPane, workspaceId); err2 == nil { - paneId = pid - } - } - } - - if paneId == "" { - paneId, err = tmuxFocusedPaneId(rc, workspaceId) - if err != nil { - return "", "", err - } - } - return workspaceId, paneId, nil -} - -func tmuxSelectedSurfaceId(rc *rpcContext, workspaceId string, paneId string) (string, error) { - payload, err := rc.call("pane.surfaces", map[string]any{"workspace_id": workspaceId, "pane_id": paneId}) - if err != nil { - return "", err - } - surfaces, _ := payload["surfaces"].([]any) - for _, s := range surfaces { - surface, _ := s.(map[string]any) - if surface == nil { - continue - } - if sel, _ := surface["selected"].(bool); sel { - if id, _ := surface["id"].(string); id != "" { - return id, nil - } - } - } - // Fall back to first surface - if len(surfaces) > 0 { - if surface, ok := surfaces[0].(map[string]any); ok { - if id, _ := surface["id"].(string); id != "" { - return id, nil - } - } - } - return "", fmt.Errorf("pane has no surface") -} - -func tmuxResolveSurfaceTarget(rc *rpcContext, raw string) (workspaceId string, paneId string, surfaceId string, err error) { - raw = strings.TrimSpace(raw) - - if tmuxPaneSelector(raw) != "" { - workspaceId, paneId, err = tmuxResolvePaneTarget(rc, raw) - if err != nil { - return "", "", "", err - } - // When target pane matches caller's pane, prefer caller's surface - callerPane := tmuxCallerPaneHandle() - callerSurface := tmuxCallerSurfaceHandle() - if callerPane != "" && callerSurface != "" { - canonicalCallerPane, _ := tmuxCanonicalPaneId(rc, callerPane, workspaceId) - if paneId == callerPane || paneId == canonicalCallerPane { - surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId) - if err == nil { - return - } - } - } - surfaceId, err = tmuxSelectedSurfaceId(rc, workspaceId, paneId) - return - } - - winSel := tmuxWindowSelector(raw) - workspaceId, err = tmuxResolveWorkspaceTarget(rc, winSel) - if err != nil { - return "", "", "", err - } - - // When no explicit target and caller workspace matches, use caller's surface - if winSel == "" { - if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId { - if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" { - surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId) - if err == nil { - return - } - } - } - } - - // Fall back to focused surface - payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId}) - if err == nil { - if sid, ok := payload["surface_id"].(string); ok { - surfaceId = sid - return - } - } - - // Last resort: first surface in the workspace - surfPayload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId}) - if err == nil { - surfs, _ := surfPayload["surfaces"].([]any) - for _, s := range surfs { - surf, _ := s.(map[string]any) - if surf == nil { - continue - } - if focused, _ := surf["focused"].(bool); focused { - if id, _ := surf["id"].(string); id != "" { - surfaceId = id - return workspaceId, "", surfaceId, nil - } - } - } - if len(surfs) > 0 { - if surf, ok := surfs[0].(map[string]any); ok { - if id, _ := surf["id"].(string); id != "" { - surfaceId = id - return workspaceId, "", surfaceId, nil - } - } - } - } - - return "", "", "", fmt.Errorf("unable to resolve surface") -} - -type tmuxSplitAnchor struct { - targetSurfaceId string - callerSurfaceId string - direction string -} - -func tmuxAnchoredSplitTarget(rc *rpcContext, workspaceId string) *tmuxSplitAnchor { - store := loadTmuxCompatStore() - if mvState, ok := store.MainVerticalLayouts[workspaceId]; ok && mvState.LastColumnSurfaceId != "" { - lastColumnId, err := tmuxCanonicalSurfaceId(rc, mvState.LastColumnSurfaceId, workspaceId) - if err == nil { - return &tmuxSplitAnchor{ - targetSurfaceId: lastColumnId, - callerSurfaceId: "", - direction: "down", - } - } - - // Right-column anchors can outlive the pane they pointed at. - // Drop stale state and rebuild from the caller surface instead. - mvState.LastColumnSurfaceId = "" - store.MainVerticalLayouts[workspaceId] = mvState - delete(store.LastSplitSurface, workspaceId) - _ = saveTmuxCompatStore(store) - } - - candidateAnchors := []string{tmuxCallerSurfaceHandle()} - if mvState, ok := store.MainVerticalLayouts[workspaceId]; ok && mvState.MainSurfaceId != "" { - candidateAnchors = append(candidateAnchors, mvState.MainSurfaceId) - } - for _, candidate := range candidateAnchors { - if candidate == "" { - continue - } - anchorSurfaceId, err := tmuxCanonicalSurfaceId(rc, candidate, workspaceId) - if err == nil { - return &tmuxSplitAnchor{ - targetSurfaceId: anchorSurfaceId, - callerSurfaceId: anchorSurfaceId, - direction: "right", - } - } - } - - if _, ok := store.MainVerticalLayouts[workspaceId]; ok { - delete(store.MainVerticalLayouts, workspaceId) - delete(store.LastSplitSurface, workspaceId) - _ = saveTmuxCompatStore(store) - } - return nil -} - -// --- TmuxCompatStore (local JSON state) --- - -type mainVerticalState struct { - MainSurfaceId string `json:"mainSurfaceId"` - LastColumnSurfaceId string `json:"lastColumnSurfaceId,omitempty"` -} - -type tmuxCompatStore struct { - Buffers map[string]string `json:"buffers,omitempty"` - Hooks map[string]string `json:"hooks,omitempty"` - MainVerticalLayouts map[string]mainVerticalState `json:"mainVerticalLayouts,omitempty"` - LastSplitSurface map[string]string `json:"lastSplitSurface,omitempty"` -} - -func tmuxCompatStoreURL() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".programaterm", "tmux-compat-store.json") -} - -func loadTmuxCompatStore() tmuxCompatStore { - data, err := os.ReadFile(tmuxCompatStoreURL()) - if err != nil { - return tmuxCompatStore{ - Buffers: make(map[string]string), - Hooks: make(map[string]string), - MainVerticalLayouts: make(map[string]mainVerticalState), - LastSplitSurface: make(map[string]string), - } - } - var store tmuxCompatStore - if err := json.Unmarshal(data, &store); err != nil { - return tmuxCompatStore{ - Buffers: make(map[string]string), - Hooks: make(map[string]string), - MainVerticalLayouts: make(map[string]mainVerticalState), - LastSplitSurface: make(map[string]string), - } - } - if store.Buffers == nil { - store.Buffers = make(map[string]string) - } - if store.Hooks == nil { - store.Hooks = make(map[string]string) - } - if store.MainVerticalLayouts == nil { - store.MainVerticalLayouts = make(map[string]mainVerticalState) - } - if store.LastSplitSurface == nil { - store.LastSplitSurface = make(map[string]string) - } - return store -} - -func saveTmuxCompatStore(store tmuxCompatStore) error { - path := tmuxCompatStoreURL() - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return err - } - data, err := json.Marshal(store) - if err != nil { - return err - } - return os.WriteFile(path, data, 0644) -} - -func tmuxPruneCompatWorkspaceState(workspaceId string) error { - store := loadTmuxCompatStore() - changed := false - if _, ok := store.MainVerticalLayouts[workspaceId]; ok { - delete(store.MainVerticalLayouts, workspaceId) - changed = true - } - if _, ok := store.LastSplitSurface[workspaceId]; ok { - delete(store.LastSplitSurface, workspaceId) - changed = true - } - if changed { - return saveTmuxCompatStore(store) - } - return nil -} - -func tmuxPruneCompatSurfaceState(workspaceId string, surfaceId string) error { - store := loadTmuxCompatStore() - changed := false - if lastSplit := store.LastSplitSurface[workspaceId]; lastSplit == surfaceId { - delete(store.LastSplitSurface, workspaceId) - changed = true - } - if layout, ok := store.MainVerticalLayouts[workspaceId]; ok { - if layout.MainSurfaceId == surfaceId { - delete(store.MainVerticalLayouts, workspaceId) - delete(store.LastSplitSurface, workspaceId) - changed = true - } else if layout.LastColumnSurfaceId == surfaceId { - layout.LastColumnSurfaceId = "" - store.MainVerticalLayouts[workspaceId] = layout - changed = true - } - } - if changed { - return saveTmuxCompatStore(store) - } - return nil -} - -// --- Special key translation --- - -func tmuxSpecialKeyText(token string) string { - switch strings.ToLower(token) { - case "enter", "c-m", "kpenter": - return "\r" - case "tab", "c-i": - return "\t" - case "space": - return " " - case "bspace", "backspace": - return "\x7f" - case "escape", "esc", "c-[": - return "\x1b" - case "c-c": - return "\x03" - case "c-d": - return "\x04" - case "c-z": - return "\x1a" - case "c-l": - return "\x0c" - default: - return "" - } -} - -func tmuxSendKeysText(tokens []string, literal bool) string { - if literal { - return strings.Join(tokens, " ") - } - var result strings.Builder - pendingSpace := false - for _, token := range tokens { - if special := tmuxSpecialKeyText(token); special != "" { - result.WriteString(special) - pendingSpace = false - continue - } - if pendingSpace { - result.WriteByte(' ') - } - result.WriteString(token) - pendingSpace = true - } - return result.String() -} - -func tmuxShellQuote(value string) string { - return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" -} - -func tmuxShellCommandText(positional []string, cwd string) string { - cwd = strings.TrimSpace(cwd) - cmd := strings.TrimSpace(strings.Join(positional, " ")) - if cwd == "" && cmd == "" { - return "" - } - var pieces []string - if cwd != "" { - pieces = append(pieces, "cd -- "+tmuxShellQuote(cwd)) - } - if cmd != "" { - pieces = append(pieces, cmd) - } - return strings.Join(pieces, " && ") + "\r" -} - -// --- Wait-for (filesystem-based signaling) --- - -func tmuxWaitForSignalPath(name string) string { - var sanitized strings.Builder - for _, c := range name { - if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || - c == '.' || c == '_' || c == '-' { - sanitized.WriteRune(c) - } else { - sanitized.WriteByte('_') - } - } - return fmt.Sprintf("/tmp/programa-wait-for-%s.sig", sanitized.String()) -} - // --- Main dispatch --- +// +// The rest of the tmux-compat shim is split by section across this +// package: tmux_args.go (argument parsing), tmux_format.go (format +// string rendering/context), tmux_target.go (session/window/pane target +// resolution), tmux_store.go (local JSON state), tmux_keys.go (special +// key translation), tmux_waitfor.go (wait-for signaling path), tmux_commands.go +// (per-command implementations), and tmux_helpers.go (small shared helpers). func dispatchTmuxCommand(rc *rpcContext, command string, args []string) error { switch command { @@ -1109,693 +110,3 @@ func dispatchTmuxCommand(rc *rpcContext, command string, args []string) error { return fmt.Errorf("unsupported tmux command: %s", command) } } - -// --- Command implementations --- - -func tmuxNewSession(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-s"}, []string{"-A", "-d", "-P"}) - if p.hasFlag("-A") { - return fmt.Errorf("new-session -A is not supported") - } - params := map[string]any{"focus": false} - if cwd := p.value("-c"); cwd != "" { - params["cwd"] = cwd - } - created, err := rc.call("workspace.create", params) - if err != nil { - return err - } - wsId, _ := created["workspace_id"].(string) - if wsId == "" { - return fmt.Errorf("workspace.create did not return workspace_id") - } - if title := firstNonEmpty(p.value("-n"), p.value("-s")); strings.TrimSpace(title) != "" { - rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) - } - if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { - surfaceId, err := tmuxGetFirstSurface(rc, wsId) - if err == nil { - rc.call("surface.send_text", map[string]any{"workspace_id": wsId, "surface_id": surfaceId, "text": text}) - } - } - if p.hasFlag("-P") { - ctx, err := tmuxFormatContext(rc, wsId, "", "") - if err != nil { - fmt.Printf("@%s\n", wsId) - return nil - } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, "@"+wsId)) - } - return nil -} - -func tmuxNewWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-c", "-F", "-n", "-t"}, []string{"-d", "-P"}) - params := map[string]any{"focus": false} - if cwd := p.value("-c"); cwd != "" { - params["cwd"] = cwd - } - created, err := rc.call("workspace.create", params) - if err != nil { - return err - } - wsId, _ := created["workspace_id"].(string) - if wsId == "" { - return fmt.Errorf("workspace.create did not return workspace_id") - } - if title := p.value("-n"); strings.TrimSpace(title) != "" { - rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) - } - if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { - surfaceId, err := tmuxGetFirstSurface(rc, wsId) - if err == nil { - rc.call("surface.send_text", map[string]any{"workspace_id": wsId, "surface_id": surfaceId, "text": text}) - } - } - if p.hasFlag("-P") { - ctx, err := tmuxFormatContext(rc, wsId, "", "") - if err != nil { - fmt.Printf("@%s\n", wsId) - return nil - } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, "@"+wsId)) - } - return nil -} - -func tmuxSplitWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-c", "-F", "-l", "-t"}, []string{"-P", "-b", "-d", "-h", "-v"}) - - targetWs, _, targetSurface, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - - direction := "down" - if p.hasFlag("-h") { - direction = "right" - if p.hasFlag("-b") { - direction = "left" - } - } else if p.hasFlag("-b") { - direction = "up" - } - - // Anchor splits to the leader surface for agent teams. - callerWorkspace := tmuxCallerWorkspaceHandle() - anchoredCallerSurface := "" - if callerWorkspace != "" { - if wsId, err := tmuxResolveWorkspaceId(rc, callerWorkspace); err == nil { - if anchored := tmuxAnchoredSplitTarget(rc, wsId); anchored != nil { - targetWs = wsId - targetSurface = anchored.targetSurfaceId - direction = anchored.direction - anchoredCallerSurface = anchored.callerSurfaceId - } - } - } - - focusNewPane := !p.hasFlag("-d") - created, err := rc.call("surface.split", map[string]any{ - "workspace_id": targetWs, - "surface_id": targetSurface, - "direction": direction, - "focus": focusNewPane, - }) - if err != nil { - return err - } - surfaceId, _ := created["surface_id"].(string) - if surfaceId == "" { - return fmt.Errorf("surface.split did not return surface_id") - } - newPaneId, _ := created["pane_id"].(string) - - // Track for main-vertical layout - store := loadTmuxCompatStore() - store.LastSplitSurface[targetWs] = surfaceId - if _, ok := store.MainVerticalLayouts[targetWs]; ok { - mvs := store.MainVerticalLayouts[targetWs] - mvs.LastColumnSurfaceId = surfaceId - store.MainVerticalLayouts[targetWs] = mvs - } else if direction == "right" && anchoredCallerSurface != "" { - store.MainVerticalLayouts[targetWs] = mainVerticalState{ - MainSurfaceId: anchoredCallerSurface, - LastColumnSurfaceId: surfaceId, - } - } - saveTmuxCompatStore(store) - - // Equalize vertical splits - rc.call("workspace.equalize_splits", map[string]any{ - "workspace_id": targetWs, - "orientation": "vertical", - }) - - if text := tmuxShellCommandText(p.positional, p.value("-c")); text != "" { - rc.call("surface.send_text", map[string]any{ - "workspace_id": targetWs, - "surface_id": surfaceId, - "text": text, - }) - } - - if p.hasFlag("-P") { - ctx, err := tmuxFormatContext(rc, targetWs, newPaneId, surfaceId) - if err != nil { - fmt.Println(surfaceId) - return nil - } - fallback := surfaceId - if pid, ok := ctx["pane_id"]; ok { - fallback = pid - } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) - } - return nil -} - -func tmuxSelectWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("workspace.select", map[string]any{"workspace_id": wsId}) - return err -} - -func tmuxSelectPane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-P", "-T", "-t"}, nil) - // -P (style) and -T (title) are no-ops - if p.value("-P") != "" || p.value("-T") != "" { - return nil - } - wsId, paneId, err := tmuxResolvePaneTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("pane.focus", map[string]any{"workspace_id": wsId, "pane_id": paneId}) - return err -} - -func tmuxKillWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("workspace.close", map[string]any{"workspace_id": wsId}) - if err != nil { - return err - } - _ = tmuxPruneCompatWorkspaceState(wsId) - return nil -} - -func tmuxKillPane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("surface.close", map[string]any{"workspace_id": wsId, "surface_id": surfId}) - if err != nil { - return err - } - _ = tmuxPruneCompatSurfaceState(wsId, surfId) - // Re-equalize after removal - rc.call("workspace.equalize_splits", map[string]any{"workspace_id": wsId, "orientation": "vertical"}) - return nil -} - -func tmuxSendKeys(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, []string{"-l"}) - wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - text := tmuxSendKeysText(p.positional, p.hasFlag("-l")) - if text != "" { - _, err = rc.call("surface.send_text", map[string]any{ - "workspace_id": wsId, - "surface_id": surfId, - "text": text, - }) - } - return err -} - -func tmuxCapturePane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-E", "-S", "-t"}, []string{"-J", "-N", "-p"}) - wsId, _, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - params := map[string]any{ - "workspace_id": wsId, - "surface_id": surfId, - "scrollback": true, - } - if start := p.value("-S"); start != "" { - if lines := parseInt(start); lines < 0 { - params["lines"] = int(math.Abs(float64(lines))) - } - } - payload, err := rc.call("surface.read_text", params) - if err != nil { - return err - } - text, _ := payload["text"].(string) - if p.hasFlag("-p") { - fmt.Print(text) - } else { - store := loadTmuxCompatStore() - store.Buffers["default"] = text - saveTmuxCompatStore(store) - } - return nil -} - -func tmuxDisplayMessage(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-F", "-t"}, []string{"-p"}) - wsId, paneId, surfId, err := tmuxResolveSurfaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - ctx, err := tmuxFormatContext(rc, wsId, paneId, surfId) - if err != nil { - ctx = map[string]string{} - } - - // Enrich with geometry - panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) - if err == nil { - panes, _ := panePayload["panes"].([]any) - containerFrame, _ := panePayload["container_frame"].(map[string]any) - var matchingPane map[string]any - if paneId != "" { - for _, p := range panes { - pn, _ := p.(map[string]any) - if pid, _ := pn["id"].(string); pid == paneId { - matchingPane = pn - break - } - } - } - if matchingPane == nil { - for _, p := range panes { - pn, _ := p.(map[string]any) - if focused, _ := pn["focused"].(bool); focused { - matchingPane = pn - break - } - } - } - if matchingPane == nil && len(panes) > 0 { - matchingPane, _ = panes[0].(map[string]any) - } - if matchingPane != nil { - tmuxEnrichContextWithGeometry(ctx, matchingPane, containerFrame) - } - } - - format := p.value("-F") - if len(p.positional) > 0 { - format = strings.Join(p.positional, " ") - } - rendered := tmuxRenderFormat(format, ctx, "") - if p.hasFlag("-p") || rendered != "" { - fmt.Println(rendered) - } - return nil -} - -func tmuxListWindows(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-F", "-t"}, nil) - items, err := tmuxWorkspaceItems(rc) - if err != nil { - return err - } - for _, item := range items { - wsId, _ := item["id"].(string) - if wsId == "" { - continue - } - ctx, err := tmuxFormatContext(rc, wsId, "", "") - if err != nil { - continue - } - fallback := "" - if idx, ok := ctx["window_index"]; ok { - fallback = idx - } else { - fallback = "?" - } - if name, ok := ctx["window_name"]; ok { - fallback += " " + name - } else { - fallback += " " + wsId - } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) - } - return nil -} - -func tmuxListPanes(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-F", "-t"}, nil) - - target := p.value("-t") - var wsId string - var err error - - if target != "" && tmuxPaneSelector(target) != "" { - wsId, _, err = tmuxResolvePaneTarget(rc, target) - } else { - wsId, err = tmuxResolveWorkspaceTarget(rc, target) - } - if err != nil { - return err - } - - payload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) - if err != nil { - return err - } - panes, _ := payload["panes"].([]any) - containerFrame, _ := payload["container_frame"].(map[string]any) - - for _, p2 := range panes { - pane, _ := p2.(map[string]any) - if pane == nil { - continue - } - paneId, _ := pane["id"].(string) - if paneId == "" { - continue - } - ctx, err := tmuxFormatContext(rc, wsId, paneId, "") - if err != nil { - continue - } - tmuxEnrichContextWithGeometry(ctx, pane, containerFrame) - fallback := "%" + paneId - if pid, ok := ctx["pane_id"]; ok { - fallback = pid - } - fmt.Println(tmuxRenderFormat(p.value("-F"), ctx, fallback)) - } - return nil -} - -func tmuxRenameWindow(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - title := strings.TrimSpace(strings.Join(p.positional, " ")) - if title == "" { - return fmt.Errorf("rename-window requires a title") - } - wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("workspace.rename", map[string]any{"workspace_id": wsId, "title": title}) - return err -} - -func tmuxResizePane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t", "-x", "-y"}, []string{"-D", "-L", "-R", "-U"}) - wsId, paneId, err := tmuxResolvePaneTarget(rc, p.value("-t")) - if err != nil { - return err - } - - hasDirectional := p.hasFlag("-L") || p.hasFlag("-R") || p.hasFlag("-U") || p.hasFlag("-D") - - if !hasDirectional { - if absWidthStr := p.value("-x"); absWidthStr != "" { - absWidth := parseInt(strings.ReplaceAll(absWidthStr, "%", "")) - // Get current width to compute delta - panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) - if err != nil { - return err - } - panes, _ := panePayload["panes"].([]any) - for _, pp := range panes { - pane, _ := pp.(map[string]any) - if pane == nil { - continue - } - if pid, _ := pane["id"].(string); pid == paneId { - cellW := intFromAnyGo(pane["cell_width_px"]) - currentCols := intFromAnyGo(pane["columns"]) - if cellW > 0 && currentCols >= 0 { - delta := absWidth - currentCols - if delta != 0 { - dir := "right" - if delta < 0 { - dir = "left" - delta = -delta - } - rc.call("pane.resize", map[string]any{ - "workspace_id": wsId, - "pane_id": paneId, - "direction": dir, - "amount": delta * cellW, - }) - } - } - break - } - } - return nil - } - } - - if hasDirectional { - dir := "right" - if p.hasFlag("-L") { - dir = "left" - } else if p.hasFlag("-U") { - dir = "up" - } else if p.hasFlag("-D") { - dir = "down" - } - rawAmount := firstNonEmpty(p.value("-x"), p.value("-y"), "5") - rawAmount = strings.ReplaceAll(rawAmount, "%", "") - amount := parseInt(rawAmount) - if amount <= 0 { - amount = 5 - } - _, err := rc.call("pane.resize", map[string]any{ - "workspace_id": wsId, - "pane_id": paneId, - "direction": dir, - "amount": amount, - }) - return err - } - return nil -} - -func tmuxWaitFor(_ *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"--timeout"}, []string{"-S"}) - name := "" - for _, pos := range p.positional { - if !strings.HasPrefix(pos, "-") { - name = pos - break - } - } - if name == "" { - return fmt.Errorf("wait-for requires a name") - } - - signalPath := tmuxWaitForSignalPath(name) - - if p.hasFlag("-S") { - // Signal mode: create the file - os.WriteFile(signalPath, []byte{}, 0644) - fmt.Println("OK") - return nil - } - - // Wait mode: poll for the file - timeoutStr := p.value("--timeout") - timeout := 30.0 - if timeoutStr != "" { - if t := parseFloat(timeoutStr); t > 0 { - timeout = t - } - } - - deadline := time.Now().Add(time.Duration(timeout * float64(time.Second))) - for time.Now().Before(deadline) { - if _, err := os.Stat(signalPath); err == nil { - os.Remove(signalPath) - return nil - } - time.Sleep(50 * time.Millisecond) - } - return fmt.Errorf("wait-for timeout: %s", name) -} - -func tmuxLastPane(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - wsId, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - if err != nil { - return err - } - _, err = rc.call("pane.last", map[string]any{"workspace_id": wsId}) - return err -} - -func tmuxHasSession(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - _, err := tmuxResolveWorkspaceTarget(rc, p.value("-t")) - return err -} - -func tmuxSelectLayout(rc *rpcContext, args []string) error { - p := parseTmuxArgs(args, []string{"-t"}, nil) - layoutName := "" - if len(p.positional) > 0 { - layoutName = p.positional[0] - } - - // Resolve workspace from target (may be a pane reference) - var wsId string - var err error - if target := p.value("-t"); target != "" { - if tmuxPaneSelector(target) != "" { - wsId, _, err = tmuxResolvePaneTarget(rc, target) - } else { - wsId, err = tmuxResolveWorkspaceTarget(rc, target) - } - } else { - wsId, err = tmuxResolveWorkspaceTarget(rc, "") - } - if err != nil { - return err - } - - if layoutName == "main-vertical" || layoutName == "main-horizontal" { - orientation := "vertical" - if layoutName == "main-horizontal" { - orientation = "horizontal" - } - rc.call("workspace.equalize_splits", map[string]any{ - "workspace_id": wsId, - "orientation": orientation, - }) - } else { - rc.call("workspace.equalize_splits", map[string]any{"workspace_id": wsId}) - } - - if layoutName == "main-vertical" { - if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" { - store := loadTmuxCompatStore() - existingColumn := "" - if existing, ok := store.MainVerticalLayouts[wsId]; ok { - existingColumn = existing.LastColumnSurfaceId - } - seedColumn := existingColumn - if seedColumn == "" { - seedColumn = store.LastSplitSurface[wsId] - } - store.MainVerticalLayouts[wsId] = mainVerticalState{ - MainSurfaceId: callerSurface, - LastColumnSurfaceId: seedColumn, - } - saveTmuxCompatStore(store) - } - } else if layoutName != "" { - _ = tmuxPruneCompatWorkspaceState(wsId) - } - - return nil -} - -func tmuxShowBuffer(args []string) error { - p := parseTmuxArgs(args, []string{"-b"}, nil) - name := p.value("-b") - if name == "" { - name = "default" - } - store := loadTmuxCompatStore() - if buf, ok := store.Buffers[name]; ok { - fmt.Print(buf) - } - return nil -} - -func tmuxSaveBuffer(args []string) error { - p := parseTmuxArgs(args, []string{"-b"}, nil) - name := p.value("-b") - if name == "" { - name = "default" - } - store := loadTmuxCompatStore() - buf, ok := store.Buffers[name] - if !ok { - return fmt.Errorf("buffer not found: %s", name) - } - if len(p.positional) > 0 { - outputPath := strings.TrimSpace(p.positional[len(p.positional)-1]) - if outputPath != "" { - return os.WriteFile(outputPath, []byte(buf), 0644) - } - } - fmt.Print(buf) - return nil -} - -// --- Helpers --- - -func tmuxGetFirstSurface(rc *rpcContext, workspaceId string) (string, error) { - payload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId}) - if err != nil { - return "", err - } - surfaces, _ := payload["surfaces"].([]any) - if len(surfaces) == 0 { - return "", fmt.Errorf("workspace has no surfaces") - } - // Prefer focused surface - for _, s := range surfaces { - surf, _ := s.(map[string]any) - if focused, _ := surf["focused"].(bool); focused { - if id, _ := surf["id"].(string); id != "" { - return id, nil - } - } - } - if surf, ok := surfaces[0].(map[string]any); ok { - if id, _ := surf["id"].(string); id != "" { - return id, nil - } - } - return "", fmt.Errorf("workspace has no surfaces") -} - -func firstNonEmpty(values ...string) string { - for _, v := range values { - if v != "" { - return v - } - } - return "" -} - -func parseInt(s string) int { - s = strings.TrimSpace(s) - var n int - fmt.Sscanf(s, "%d", &n) - return n -} - -func parseFloat(s string) float64 { - s = strings.TrimSpace(s) - var f float64 - fmt.Sscanf(s, "%f", &f) - return f -} diff --git a/daemon/remote/cmd/programad-remote/tmux_compat_test.go b/daemon/remote/cmd/programad-remote/tmux_compat_test.go index 72ed21df6b1..f494c71c032 100644 --- a/daemon/remote/cmd/programad-remote/tmux_compat_test.go +++ b/daemon/remote/cmd/programad-remote/tmux_compat_test.go @@ -1,9 +1,13 @@ package main import ( + "bufio" + "encoding/json" + "net" "os" "path/filepath" "strings" + "sync" "testing" ) @@ -434,6 +438,114 @@ func TestTmuxWaitForSignalRoundTrip(t *testing.T) { } } +// startMockNewWindowSocket returns a mock relay socket that resolves +// workspace.list to a single "target" workspace and records the params +// passed to every workspace.create call (guarded by a mutex, since the +// listener services requests on their own goroutines). +func startMockNewWindowSocket(t *testing.T) (sockPath string, createCalls *[]map[string]any, mu *sync.Mutex) { + t.Helper() + sockPath = makeShortUnixSocketPath(t) + calls := []map[string]any{} + var lock sync.Mutex + + ln, err := net.Listen("unix", sockPath) + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + t.Cleanup(func() { ln.Close() }) + + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func(conn net.Conn) { + defer conn.Close() + reader := bufio.NewReader(conn) + line, err := reader.ReadBytes('\n') + if err != nil { + return + } + + var req map[string]any + if err := json.Unmarshal(line, &req); err != nil { + _, _ = conn.Write([]byte(`{"ok":false,"error":{"code":"parse","message":"bad json"}}` + "\n")) + return + } + + method, _ := req["method"].(string) + params, _ := req["params"].(map[string]any) + resp := map[string]any{"id": req["id"], "ok": true} + + switch method { + case "workspace.list": + resp["result"] = map[string]any{ + "workspaces": []map[string]any{{ + "id": "22222222-2222-4222-8222-222222222222", + "ref": "workspace:9", + "index": 9, + "title": "target-session", + }}, + } + case "workspace.create": + lock.Lock() + calls = append(calls, params) + lock.Unlock() + resp["result"] = map[string]any{"workspace_id": "33333333-3333-4333-8333-333333333333"} + case "workspace.rename": + resp["result"] = map[string]any{"ok": true} + default: + resp["ok"] = false + resp["error"] = map[string]any{"code": "unsupported", "message": method} + } + + payload, _ := json.Marshal(resp) + _, _ = conn.Write(append(payload, '\n')) + }(conn) + } + }() + + return sockPath, &calls, &lock +} + +func TestTmuxNewWindowHonorsTargetSession(t *testing.T) { + sockPath, calls, mu := startMockNewWindowSocket(t) + rc := &rpcContext{socketPath: sockPath} + + if err := dispatchTmuxCommand(rc, "new-window", []string{"-t", "target-session"}); err != nil { + t.Fatalf("new-window: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(*calls) != 1 { + t.Fatalf("workspace.create calls = %d, want 1", len(*calls)) + } + got, _ := (*calls)[0]["workspace_id"].(string) + if got != "22222222-2222-4222-8222-222222222222" { + t.Errorf("workspace.create routed workspace_id = %q, want the resolved -t target", got) + } +} + +func TestTmuxNewWindowWithoutTargetDoesNotRoute(t *testing.T) { + sockPath, calls, mu := startMockNewWindowSocket(t) + rc := &rpcContext{socketPath: sockPath} + + if err := dispatchTmuxCommand(rc, "new-window", nil); err != nil { + t.Fatalf("new-window: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if len(*calls) != 1 { + t.Fatalf("workspace.create calls = %d, want 1", len(*calls)) + } + if _, ok := (*calls)[0]["workspace_id"]; ok { + t.Errorf("workspace.create params should not carry workspace_id routing when -t is absent, got %v", (*calls)[0]) + } +} + func TestTmuxShowBuffer(t *testing.T) { tmpDir := t.TempDir() origHome := os.Getenv("HOME") diff --git a/daemon/remote/cmd/programad-remote/tmux_format.go b/daemon/remote/cmd/programad-remote/tmux_format.go new file mode 100644 index 00000000000..482050fe248 --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_format.go @@ -0,0 +1,217 @@ +package main + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" +) + +// --- Format string rendering --- + +var tmuxFormatVarRe = regexp.MustCompile(`#\{[^}]+\}`) + +func tmuxRenderFormat(format string, context map[string]string, fallback string) string { + if format == "" { + return fallback + } + rendered := format + for key, value := range context { + rendered = strings.ReplaceAll(rendered, "#{"+key+"}", value) + } + // Remove any remaining unresolved #{...} variables + rendered = tmuxFormatVarRe.ReplaceAllString(rendered, "") + rendered = strings.TrimSpace(rendered) + if rendered == "" { + return fallback + } + return rendered +} + +// --- Format context building --- + +func tmuxFormatContext(rc *rpcContext, workspaceId string, paneId string, surfaceId string) (map[string]string, error) { + canonicalWsId, err := tmuxResolveWorkspaceId(rc, workspaceId) + if err != nil { + return nil, err + } + + ctx := map[string]string{ + "session_name": "programa", + "session_id": "$0", + "window_id": "@" + canonicalWsId, + "window_uuid": canonicalWsId, + "window_active": "1", + "window_flags": "*", + "pane_active": "1", + } + + // Get workspace list for index/title + workspaces, err := tmuxWorkspaceItems(rc) + if err == nil { + for _, ws := range workspaces { + wsId, _ := ws["id"].(string) + wsRef, _ := ws["ref"].(string) + if wsId == canonicalWsId || wsRef == workspaceId { + if idx := intFromAnyGo(ws["index"]); idx >= 0 { + ctx["window_index"] = fmt.Sprintf("%d", idx) + } + if title, _ := ws["title"].(string); strings.TrimSpace(title) != "" { + ctx["window_name"] = strings.TrimSpace(title) + } + if paneCount := intFromAnyGo(ws["pane_count"]); paneCount >= 0 { + ctx["window_panes"] = fmt.Sprintf("%d", paneCount) + } + break + } + } + } + + // Get current surface info + currentPayload, err := rc.call("surface.current", map[string]any{"workspace_id": canonicalWsId}) + if err != nil { + return ctx, nil + } + + resolvedPaneId := paneId + if resolvedPaneId == "" { + if pid, ok := currentPayload["pane_id"].(string); ok { + resolvedPaneId = pid + } else if pref, ok := currentPayload["pane_ref"].(string); ok { + resolvedPaneId = pref + } + } + + resolvedSurfaceId := surfaceId + if resolvedSurfaceId == "" && resolvedPaneId != "" { + if sid, err := tmuxSelectedSurfaceId(rc, canonicalWsId, resolvedPaneId); err == nil { + resolvedSurfaceId = sid + } + } + if resolvedSurfaceId == "" { + if sid, ok := currentPayload["surface_id"].(string); ok { + resolvedSurfaceId = sid + } + } + + if resolvedPaneId != "" { + ctx["pane_id"] = "%" + resolvedPaneId + ctx["pane_uuid"] = resolvedPaneId + + panePayload, err := rc.call("pane.list", map[string]any{"workspace_id": canonicalWsId}) + if err == nil { + panes, _ := panePayload["panes"].([]any) + for _, p := range panes { + pane, _ := p.(map[string]any) + if pane == nil { + continue + } + if pid, _ := pane["id"].(string); pid == resolvedPaneId { + if idx := intFromAnyGo(pane["index"]); idx >= 0 { + ctx["pane_index"] = fmt.Sprintf("%d", idx) + } + break + } + } + } + } + + if resolvedSurfaceId != "" { + ctx["surface_id"] = resolvedSurfaceId + surfacePayload, err := rc.call("surface.list", map[string]any{"workspace_id": canonicalWsId}) + if err == nil { + surfaces, _ := surfacePayload["surfaces"].([]any) + for _, s := range surfaces { + surface, _ := s.(map[string]any) + if surface == nil { + continue + } + if sid, _ := surface["id"].(string); sid == resolvedSurfaceId { + if title, _ := surface["title"].(string); strings.TrimSpace(title) != "" { + ctx["pane_title"] = strings.TrimSpace(title) + if _, ok := ctx["window_name"]; !ok { + ctx["window_name"] = strings.TrimSpace(title) + } + } + break + } + } + } + } + + return ctx, nil +} + +func tmuxEnrichContextWithGeometry(ctx map[string]string, pane map[string]any, containerFrame map[string]any) { + isFocused, _ := pane["focused"].(bool) + if isFocused { + ctx["pane_active"] = "1" + } else { + ctx["pane_active"] = "0" + } + + columns := intFromAnyGo(pane["columns"]) + rows := intFromAnyGo(pane["rows"]) + if columns < 0 || rows < 0 { + return + } + ctx["pane_width"] = fmt.Sprintf("%d", columns) + ctx["pane_height"] = fmt.Sprintf("%d", rows) + + cellW := intFromAnyGo(pane["cell_width_px"]) + cellH := intFromAnyGo(pane["cell_height_px"]) + if cellW <= 0 || cellH <= 0 { + return + } + + if frame, ok := pane["pixel_frame"].(map[string]any); ok { + px := floatFromAny(frame["x"]) + py := floatFromAny(frame["y"]) + ctx["pane_left"] = fmt.Sprintf("%d", int(px)/cellW) + ctx["pane_top"] = fmt.Sprintf("%d", int(py)/cellH) + } + + if containerFrame != nil { + cw := floatFromAny(containerFrame["width"]) + ch := floatFromAny(containerFrame["height"]) + ww := int(cw) / cellW + wh := int(ch) / cellH + if ww < 1 { + ww = 1 + } + if wh < 1 { + wh = 1 + } + ctx["window_width"] = fmt.Sprintf("%d", ww) + ctx["window_height"] = fmt.Sprintf("%d", wh) + } +} + +func floatFromAny(v any) float64 { + switch t := v.(type) { + case float64: + return t + case int: + return float64(t) + case json.Number: + f, _ := t.Float64() + return f + } + return 0 +} + +func intFromAnyGo(v any) int { + switch t := v.(type) { + case float64: + return int(t) + case int: + return t + case json.Number: + i, err := t.Int64() + if err != nil { + return -1 + } + return int(i) + } + return -1 +} diff --git a/daemon/remote/cmd/programad-remote/tmux_helpers.go b/daemon/remote/cmd/programad-remote/tmux_helpers.go new file mode 100644 index 00000000000..f1b8052adb1 --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_helpers.go @@ -0,0 +1,57 @@ +package main + +import ( + "fmt" + "strings" +) + +// --- Helpers --- + +func tmuxGetFirstSurface(rc *rpcContext, workspaceId string) (string, error) { + payload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId}) + if err != nil { + return "", err + } + surfaces, _ := payload["surfaces"].([]any) + if len(surfaces) == 0 { + return "", fmt.Errorf("workspace has no surfaces") + } + // Prefer focused surface + for _, s := range surfaces { + surf, _ := s.(map[string]any) + if focused, _ := surf["focused"].(bool); focused { + if id, _ := surf["id"].(string); id != "" { + return id, nil + } + } + } + if surf, ok := surfaces[0].(map[string]any); ok { + if id, _ := surf["id"].(string); id != "" { + return id, nil + } + } + return "", fmt.Errorf("workspace has no surfaces") +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +func parseInt(s string) int { + s = strings.TrimSpace(s) + var n int + fmt.Sscanf(s, "%d", &n) + return n +} + +func parseFloat(s string) float64 { + s = strings.TrimSpace(s) + var f float64 + fmt.Sscanf(s, "%f", &f) + return f +} diff --git a/daemon/remote/cmd/programad-remote/tmux_keys.go b/daemon/remote/cmd/programad-remote/tmux_keys.go new file mode 100644 index 00000000000..6aa64ffd4af --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_keys.go @@ -0,0 +1,71 @@ +package main + +import "strings" + +// --- Special key translation --- + +func tmuxSpecialKeyText(token string) string { + switch strings.ToLower(token) { + case "enter", "c-m", "kpenter": + return "\r" + case "tab", "c-i": + return "\t" + case "space": + return " " + case "bspace", "backspace": + return "\x7f" + case "escape", "esc", "c-[": + return "\x1b" + case "c-c": + return "\x03" + case "c-d": + return "\x04" + case "c-z": + return "\x1a" + case "c-l": + return "\x0c" + default: + return "" + } +} + +func tmuxSendKeysText(tokens []string, literal bool) string { + if literal { + return strings.Join(tokens, " ") + } + var result strings.Builder + pendingSpace := false + for _, token := range tokens { + if special := tmuxSpecialKeyText(token); special != "" { + result.WriteString(special) + pendingSpace = false + continue + } + if pendingSpace { + result.WriteByte(' ') + } + result.WriteString(token) + pendingSpace = true + } + return result.String() +} + +func tmuxShellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + +func tmuxShellCommandText(positional []string, cwd string) string { + cwd = strings.TrimSpace(cwd) + cmd := strings.TrimSpace(strings.Join(positional, " ")) + if cwd == "" && cmd == "" { + return "" + } + var pieces []string + if cwd != "" { + pieces = append(pieces, "cd -- "+tmuxShellQuote(cwd)) + } + if cmd != "" { + pieces = append(pieces, cmd) + } + return strings.Join(pieces, " && ") + "\r" +} diff --git a/daemon/remote/cmd/programad-remote/tmux_store.go b/daemon/remote/cmd/programad-remote/tmux_store.go new file mode 100644 index 00000000000..e5353ad27af --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_store.go @@ -0,0 +1,113 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// --- TmuxCompatStore (local JSON state) --- + +type mainVerticalState struct { + MainSurfaceId string `json:"mainSurfaceId"` + LastColumnSurfaceId string `json:"lastColumnSurfaceId,omitempty"` +} + +type tmuxCompatStore struct { + Buffers map[string]string `json:"buffers,omitempty"` + Hooks map[string]string `json:"hooks,omitempty"` + MainVerticalLayouts map[string]mainVerticalState `json:"mainVerticalLayouts,omitempty"` + LastSplitSurface map[string]string `json:"lastSplitSurface,omitempty"` +} + +func tmuxCompatStoreURL() string { + home, _ := os.UserHomeDir() + return filepath.Join(home, ".programaterm", "tmux-compat-store.json") +} + +func loadTmuxCompatStore() tmuxCompatStore { + data, err := os.ReadFile(tmuxCompatStoreURL()) + if err != nil { + return tmuxCompatStore{ + Buffers: make(map[string]string), + Hooks: make(map[string]string), + MainVerticalLayouts: make(map[string]mainVerticalState), + LastSplitSurface: make(map[string]string), + } + } + var store tmuxCompatStore + if err := json.Unmarshal(data, &store); err != nil { + return tmuxCompatStore{ + Buffers: make(map[string]string), + Hooks: make(map[string]string), + MainVerticalLayouts: make(map[string]mainVerticalState), + LastSplitSurface: make(map[string]string), + } + } + if store.Buffers == nil { + store.Buffers = make(map[string]string) + } + if store.Hooks == nil { + store.Hooks = make(map[string]string) + } + if store.MainVerticalLayouts == nil { + store.MainVerticalLayouts = make(map[string]mainVerticalState) + } + if store.LastSplitSurface == nil { + store.LastSplitSurface = make(map[string]string) + } + return store +} + +func saveTmuxCompatStore(store tmuxCompatStore) error { + path := tmuxCompatStoreURL() + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return err + } + data, err := json.Marshal(store) + if err != nil { + return err + } + return os.WriteFile(path, data, 0644) +} + +func tmuxPruneCompatWorkspaceState(workspaceId string) error { + store := loadTmuxCompatStore() + changed := false + if _, ok := store.MainVerticalLayouts[workspaceId]; ok { + delete(store.MainVerticalLayouts, workspaceId) + changed = true + } + if _, ok := store.LastSplitSurface[workspaceId]; ok { + delete(store.LastSplitSurface, workspaceId) + changed = true + } + if changed { + return saveTmuxCompatStore(store) + } + return nil +} + +func tmuxPruneCompatSurfaceState(workspaceId string, surfaceId string) error { + store := loadTmuxCompatStore() + changed := false + if lastSplit := store.LastSplitSurface[workspaceId]; lastSplit == surfaceId { + delete(store.LastSplitSurface, workspaceId) + changed = true + } + if layout, ok := store.MainVerticalLayouts[workspaceId]; ok { + if layout.MainSurfaceId == surfaceId { + delete(store.MainVerticalLayouts, workspaceId) + delete(store.LastSplitSurface, workspaceId) + changed = true + } else if layout.LastColumnSurfaceId == surfaceId { + layout.LastColumnSurfaceId = "" + store.MainVerticalLayouts[workspaceId] = layout + changed = true + } + } + if changed { + return saveTmuxCompatStore(store) + } + return nil +} diff --git a/daemon/remote/cmd/programad-remote/tmux_target.go b/daemon/remote/cmd/programad-remote/tmux_target.go new file mode 100644 index 00000000000..3d09f97af2e --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_target.go @@ -0,0 +1,494 @@ +package main + +import ( + "fmt" + "os" + "strings" +) + +// --- Target resolution --- + +func tmuxCallerWorkspaceHandle() string { + return strings.TrimSpace(os.Getenv("PROGRAMA_WORKSPACE_ID")) +} + +func tmuxCallerSurfaceHandle() string { + return strings.TrimSpace(os.Getenv("PROGRAMA_SURFACE_ID")) +} + +func tmuxResolvedCallerWorkspaceId(rc *rpcContext) string { + caller := tmuxCallerWorkspaceHandle() + if caller == "" { + return "" + } + wsId, err := tmuxResolveWorkspaceId(rc, caller) + if err != nil { + return "" + } + return wsId +} + +func tmuxCallerPaneHandle() string { + for _, key := range []string{"TMUX_PANE", "PROGRAMA_PANE_ID"} { + v := strings.TrimSpace(os.Getenv(key)) + if v != "" { + return strings.TrimPrefix(v, "%") + } + } + return "" +} + +func tmuxWorkspaceItems(rc *rpcContext) ([]map[string]any, error) { + payload, err := rc.call("workspace.list", nil) + if err != nil { + return nil, err + } + items, _ := payload["workspaces"].([]any) + var result []map[string]any + for _, item := range items { + if m, ok := item.(map[string]any); ok { + result = append(result, m) + } + } + return result, nil +} + +func isUUIDish(s string) bool { + // Simple UUID check: 8-4-4-4-12 hex + if len(s) != 36 { + return false + } + for i, c := range s { + if i == 8 || i == 13 || i == 18 || i == 23 { + if c != '-' { + return false + } + } else if !((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')) { + return false + } + } + return true +} + +func tmuxResolveWorkspaceId(rc *rpcContext, raw string) (string, error) { + if raw == "" || raw == "current" { + if caller := tmuxCallerWorkspaceHandle(); caller != "" { + if isUUIDish(caller) { + return caller, nil + } + // Resolve ref + return tmuxResolveWorkspaceId(rc, caller) + } + payload, err := rc.call("workspace.current", nil) + if err != nil { + return "", fmt.Errorf("no workspace selected: %w", err) + } + if wsId, ok := payload["workspace_id"].(string); ok { + return wsId, nil + } + return "", fmt.Errorf("no workspace selected") + } + + if isUUIDish(raw) { + return raw, nil + } + + // Try to resolve as ref or index + items, err := tmuxWorkspaceItems(rc) + if err != nil { + return "", err + } + for _, item := range items { + if ref, _ := item["ref"].(string); ref == raw { + if id, _ := item["id"].(string); id != "" { + return id, nil + } + } + } + + // Try name match + needle := strings.TrimSpace(raw) + for _, item := range items { + title, _ := item["title"].(string) + if strings.TrimSpace(title) == needle { + if id, _ := item["id"].(string); id != "" { + return id, nil + } + } + } + + return "", fmt.Errorf("workspace not found: %s", raw) +} + +func tmuxResolveWorkspaceTarget(rc *rpcContext, raw string) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + if caller := tmuxCallerWorkspaceHandle(); caller != "" { + return tmuxResolveWorkspaceId(rc, caller) + } + return tmuxResolveWorkspaceId(rc, "") + } + + if raw == "!" || raw == "^" || raw == "-" { + payload, err := rc.call("workspace.last", nil) + if err != nil { + return "", fmt.Errorf("previous workspace not found: %w", err) + } + if wsId, ok := payload["workspace_id"].(string); ok { + return wsId, nil + } + return "", fmt.Errorf("previous workspace not found") + } + + // Strip session:window.pane format + token := raw + if dot := strings.LastIndex(token, "."); dot >= 0 { + token = token[:dot] + } + if colon := strings.LastIndex(token, ":"); colon >= 0 { + suffix := token[colon+1:] + if suffix != "" { + token = suffix + } else { + token = token[:colon] + } + } + token = strings.TrimPrefix(token, "@") + + return tmuxResolveWorkspaceId(rc, token) +} + +func tmuxPaneSelector(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.HasPrefix(raw, "%") { + return raw[1:] + } + if strings.HasPrefix(raw, "pane:") { + return raw + } + if dot := strings.LastIndex(raw, "."); dot >= 0 { + return raw[dot+1:] + } + return "" +} + +func tmuxWindowSelector(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + if strings.HasPrefix(raw, "%") || strings.HasPrefix(raw, "pane:") { + return "" + } + if dot := strings.LastIndex(raw, "."); dot >= 0 { + return raw[:dot] + } + return raw +} + +func tmuxCanonicalPaneId(rc *rpcContext, handle string, workspaceId string) (string, error) { + if isUUIDish(handle) { + return handle, nil + } + payload, err := rc.call("pane.list", map[string]any{"workspace_id": workspaceId}) + if err != nil { + return "", err + } + panes, _ := payload["panes"].([]any) + for _, p := range panes { + pane, _ := p.(map[string]any) + if pane == nil { + continue + } + if ref, _ := pane["ref"].(string); ref == handle { + if id, _ := pane["id"].(string); id != "" { + return id, nil + } + } + if id, _ := pane["id"].(string); id == handle { + return id, nil + } + } + return "", fmt.Errorf("pane not found: %s", handle) +} + +func tmuxCanonicalSurfaceId(rc *rpcContext, handle string, workspaceId string) (string, error) { + payload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId}) + if err != nil { + return "", err + } + surfaces, _ := payload["surfaces"].([]any) + for _, s := range surfaces { + surface, _ := s.(map[string]any) + if surface == nil { + continue + } + if ref, _ := surface["ref"].(string); ref == handle { + if id, _ := surface["id"].(string); id != "" { + return id, nil + } + } + if id, _ := surface["id"].(string); id == handle { + return id, nil + } + } + return "", fmt.Errorf("surface not found: %s", handle) +} + +func tmuxFocusedPaneId(rc *rpcContext, workspaceId string) (string, error) { + payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId}) + if err != nil { + return "", err + } + if pid, ok := payload["pane_id"].(string); ok { + return pid, nil + } + if pref, ok := payload["pane_ref"].(string); ok { + return tmuxCanonicalPaneId(rc, pref, workspaceId) + } + return "", fmt.Errorf("pane not found") +} + +func tmuxWorkspaceIdForPaneHandle(rc *rpcContext, handle string) (string, error) { + if !isUUIDish(handle) { + return "", fmt.Errorf("not a UUID") + } + workspaces, err := tmuxWorkspaceItems(rc) + if err != nil { + return "", err + } + for _, ws := range workspaces { + wsId, _ := ws["id"].(string) + if wsId == "" { + continue + } + payload, err := rc.call("pane.list", map[string]any{"workspace_id": wsId}) + if err != nil { + continue + } + panes, _ := payload["panes"].([]any) + for _, p := range panes { + pane, _ := p.(map[string]any) + if pane == nil { + continue + } + if pid, _ := pane["id"].(string); pid == handle { + return wsId, nil + } + if pref, _ := pane["ref"].(string); pref == handle { + return wsId, nil + } + } + } + return "", fmt.Errorf("pane not found in any workspace") +} + +func tmuxResolvePaneTarget(rc *rpcContext, raw string) (workspaceId string, paneId string, err error) { + raw = strings.TrimSpace(raw) + paneSelector := tmuxPaneSelector(raw) + windowSelector := tmuxWindowSelector(raw) + + if windowSelector != "" { + workspaceId, err = tmuxResolveWorkspaceTarget(rc, windowSelector) + if err != nil { + return "", "", err + } + } else if paneSelector != "" { + workspaceId, err = tmuxWorkspaceIdForPaneHandle(rc, paneSelector) + if err != nil { + workspaceId, err = tmuxResolveWorkspaceTarget(rc, "") + if err != nil { + return "", "", err + } + } + } else { + workspaceId, err = tmuxResolveWorkspaceTarget(rc, "") + if err != nil { + return "", "", err + } + } + + if paneSelector != "" { + paneId, err = tmuxCanonicalPaneId(rc, paneSelector, workspaceId) + if err != nil { + return "", "", err + } + } else if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId { + if callerPane := tmuxCallerPaneHandle(); callerPane != "" { + if pid, err2 := tmuxCanonicalPaneId(rc, callerPane, workspaceId); err2 == nil { + paneId = pid + } + } + } + + if paneId == "" { + paneId, err = tmuxFocusedPaneId(rc, workspaceId) + if err != nil { + return "", "", err + } + } + return workspaceId, paneId, nil +} + +func tmuxSelectedSurfaceId(rc *rpcContext, workspaceId string, paneId string) (string, error) { + payload, err := rc.call("pane.surfaces", map[string]any{"workspace_id": workspaceId, "pane_id": paneId}) + if err != nil { + return "", err + } + surfaces, _ := payload["surfaces"].([]any) + for _, s := range surfaces { + surface, _ := s.(map[string]any) + if surface == nil { + continue + } + if sel, _ := surface["selected"].(bool); sel { + if id, _ := surface["id"].(string); id != "" { + return id, nil + } + } + } + // Fall back to first surface + if len(surfaces) > 0 { + if surface, ok := surfaces[0].(map[string]any); ok { + if id, _ := surface["id"].(string); id != "" { + return id, nil + } + } + } + return "", fmt.Errorf("pane has no surface") +} + +func tmuxResolveSurfaceTarget(rc *rpcContext, raw string) (workspaceId string, paneId string, surfaceId string, err error) { + raw = strings.TrimSpace(raw) + + if tmuxPaneSelector(raw) != "" { + workspaceId, paneId, err = tmuxResolvePaneTarget(rc, raw) + if err != nil { + return "", "", "", err + } + // When target pane matches caller's pane, prefer caller's surface + callerPane := tmuxCallerPaneHandle() + callerSurface := tmuxCallerSurfaceHandle() + if callerPane != "" && callerSurface != "" { + canonicalCallerPane, _ := tmuxCanonicalPaneId(rc, callerPane, workspaceId) + if paneId == callerPane || paneId == canonicalCallerPane { + surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId) + if err == nil { + return + } + } + } + surfaceId, err = tmuxSelectedSurfaceId(rc, workspaceId, paneId) + return + } + + winSel := tmuxWindowSelector(raw) + workspaceId, err = tmuxResolveWorkspaceTarget(rc, winSel) + if err != nil { + return "", "", "", err + } + + // When no explicit target and caller workspace matches, use caller's surface + if winSel == "" { + if callerWs := tmuxResolvedCallerWorkspaceId(rc); callerWs == workspaceId { + if callerSurface := tmuxCallerSurfaceHandle(); callerSurface != "" { + surfaceId, err = tmuxCanonicalSurfaceId(rc, callerSurface, workspaceId) + if err == nil { + return + } + } + } + } + + // Fall back to focused surface + payload, err := rc.call("surface.current", map[string]any{"workspace_id": workspaceId}) + if err == nil { + if sid, ok := payload["surface_id"].(string); ok { + surfaceId = sid + return + } + } + + // Last resort: first surface in the workspace + surfPayload, err := rc.call("surface.list", map[string]any{"workspace_id": workspaceId}) + if err == nil { + surfs, _ := surfPayload["surfaces"].([]any) + for _, s := range surfs { + surf, _ := s.(map[string]any) + if surf == nil { + continue + } + if focused, _ := surf["focused"].(bool); focused { + if id, _ := surf["id"].(string); id != "" { + surfaceId = id + return workspaceId, "", surfaceId, nil + } + } + } + if len(surfs) > 0 { + if surf, ok := surfs[0].(map[string]any); ok { + if id, _ := surf["id"].(string); id != "" { + surfaceId = id + return workspaceId, "", surfaceId, nil + } + } + } + } + + return "", "", "", fmt.Errorf("unable to resolve surface") +} + +type tmuxSplitAnchor struct { + targetSurfaceId string + callerSurfaceId string + direction string +} + +func tmuxAnchoredSplitTarget(rc *rpcContext, workspaceId string) *tmuxSplitAnchor { + store := loadTmuxCompatStore() + if mvState, ok := store.MainVerticalLayouts[workspaceId]; ok && mvState.LastColumnSurfaceId != "" { + lastColumnId, err := tmuxCanonicalSurfaceId(rc, mvState.LastColumnSurfaceId, workspaceId) + if err == nil { + return &tmuxSplitAnchor{ + targetSurfaceId: lastColumnId, + callerSurfaceId: "", + direction: "down", + } + } + + // Right-column anchors can outlive the pane they pointed at. + // Drop stale state and rebuild from the caller surface instead. + mvState.LastColumnSurfaceId = "" + store.MainVerticalLayouts[workspaceId] = mvState + delete(store.LastSplitSurface, workspaceId) + _ = saveTmuxCompatStore(store) + } + + candidateAnchors := []string{tmuxCallerSurfaceHandle()} + if mvState, ok := store.MainVerticalLayouts[workspaceId]; ok && mvState.MainSurfaceId != "" { + candidateAnchors = append(candidateAnchors, mvState.MainSurfaceId) + } + for _, candidate := range candidateAnchors { + if candidate == "" { + continue + } + anchorSurfaceId, err := tmuxCanonicalSurfaceId(rc, candidate, workspaceId) + if err == nil { + return &tmuxSplitAnchor{ + targetSurfaceId: anchorSurfaceId, + callerSurfaceId: anchorSurfaceId, + direction: "right", + } + } + } + + if _, ok := store.MainVerticalLayouts[workspaceId]; ok { + delete(store.MainVerticalLayouts, workspaceId) + delete(store.LastSplitSurface, workspaceId) + _ = saveTmuxCompatStore(store) + } + return nil +} diff --git a/daemon/remote/cmd/programad-remote/tmux_waitfor.go b/daemon/remote/cmd/programad-remote/tmux_waitfor.go new file mode 100644 index 00000000000..d098160c78d --- /dev/null +++ b/daemon/remote/cmd/programad-remote/tmux_waitfor.go @@ -0,0 +1,21 @@ +package main + +import ( + "fmt" + "strings" +) + +// --- Wait-for (filesystem-based signaling) --- + +func tmuxWaitForSignalPath(name string) string { + var sanitized strings.Builder + for _, c := range name { + if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || + c == '.' || c == '_' || c == '-' { + sanitized.WriteRune(c) + } else { + sanitized.WriteByte('_') + } + } + return fmt.Sprintf("/tmp/programa-wait-for-%s.sig", sanitized.String()) +} diff --git a/daemon/remote/go.mod b/daemon/remote/go.mod index f4b93baa23c..92389552a9d 100644 --- a/daemon/remote/go.mod +++ b/daemon/remote/go.mod @@ -1,3 +1,3 @@ -module github.com/manaflow-ai/cmux/daemon/remote +module github.com/darkroomengineering/programa/daemon/remote go 1.22