diff --git a/commands/track.go b/commands/track.go index fe46a3d..80682db 100644 --- a/commands/track.go +++ b/commands/track.go @@ -46,6 +46,11 @@ var TrackCommand *cli.Command = &cli.Command{ Aliases: []string{"r"}, Usage: "Exit code of last command", }, + &cli.IntFlag{ + Name: "ppid", + Value: 0, + Usage: "Parent process ID of the shell (for terminal detection)", + }, }, Action: commandTrack, OnUsageError: func(cCtx *cli.Context, err error, isSubcommand bool) error { @@ -78,6 +83,7 @@ func commandTrack(c *cli.Context) error { cmdCommand := c.String("command") cmdPhase := c.String("phase") result := c.Int("result") + ppid := c.Int("ppid") instance := &model.Command{ Shell: shell, @@ -87,6 +93,7 @@ func commandTrack(c *cli.Context) error { Username: username, Time: time.Now(), Phase: model.CommandPhasePre, + PPID: ppid, } // Check if command should be excluded @@ -215,6 +222,7 @@ func trySyncLocalToServer( EndTime: postCommand.Time.Unix(), EndTimeNano: postCommand.Time.UnixNano(), Result: postCommand.Result, + PPID: postCommand.PPID, } // data masking diff --git a/daemon/handlers.sync.go b/daemon/handlers.sync.go index 0d34034..b3e21cc 100644 --- a/daemon/handlers.sync.go +++ b/daemon/handlers.sync.go @@ -34,6 +34,14 @@ func handlePubSubSync(ctx context.Context, socketMsgPayload interface{}) error { return err } + // Resolve terminal from PPID (use first data item's PPID) + if len(syncMsg.Data) > 0 && syncMsg.Data[0].PPID > 0 { + terminal, multiplexer := ResolveTerminal(syncMsg.Data[0].PPID) + syncMsg.Meta.Terminal = terminal + syncMsg.Meta.Multiplexer = multiplexer + slog.Debug("Resolved terminal", slog.String("terminal", terminal), slog.String("multiplexer", multiplexer), slog.Int("ppid", syncMsg.Data[0].PPID)) + } + // set as daemon syncMsg.Meta.Source = 1 diff --git a/daemon/terminal_resolver.go b/daemon/terminal_resolver.go new file mode 100644 index 0000000..c1962d6 --- /dev/null +++ b/daemon/terminal_resolver.go @@ -0,0 +1,182 @@ +package daemon + +import ( + "os/exec" + "runtime" + "strconv" + "strings" +) + +// Known terminal emulator process names +var knownTerminals = map[string]bool{ + // macOS + "Terminal": true, + "iTerm2": true, + "Alacritty": true, + "alacritty": true, + "kitty": true, + "WezTerm": true, + "wezterm": true, + "wezterm-gui": true, + "Hyper": true, + "Tabby": true, + "Warp": true, + "Ghostty": true, + "ghostty": true, + // Linux + "gnome-terminal": true, + "gnome-terminal-": true, // gnome-terminal-server + "konsole": true, + "xfce4-terminal": true, + "xterm": true, + "urxvt": true, + "rxvt": true, + "terminator": true, + "tilix": true, + "st": true, + "foot": true, + "footclient": true, + // IDE terminals + "code": true, + "Code": true, + "cursor": true, + "Cursor": true, +} + +// Known terminal multiplexer process names +var knownMultiplexers = map[string]bool{ + "tmux": true, + "screen": true, + "zellij": true, +} + +// Known remote/container process names +var knownRemote = map[string]bool{ + "sshd": true, + "docker": true, + "containerd": true, +} + +// ResolveTerminal walks up the process tree starting from ppid +// to find the terminal emulator and multiplexer separately. +// Returns (terminal, multiplexer) as separate values. +func ResolveTerminal(ppid int) (terminal string, multiplexer string) { + if ppid <= 0 { + return "", "" + } + + currentPID := ppid + visited := make(map[int]bool) + + // Walk up the process tree (max 10 levels to prevent infinite loops) + for i := 0; i < 10; i++ { + if currentPID <= 1 || visited[currentPID] { + break + } + visited[currentPID] = true + + processName := getProcessName(currentPID) + if processName == "" { + break + } + + // Check for multiplexers first (they're closer to the shell) + if multiplexer == "" && knownMultiplexers[processName] { + multiplexer = processName + } + + // Check for terminals + if terminal == "" && knownTerminals[processName] { + terminal = processName + } + + // Check for remote connections + if terminal == "" && knownRemote[processName] { + terminal = processName + } + + // If we found a terminal, we can stop + if terminal != "" { + break + } + + // Get parent PID and continue + parentPID := getParentPID(currentPID) + if parentPID <= 1 || parentPID == currentPID { + break + } + currentPID = parentPID + } + + // If neither found, return "unknown" for terminal + if terminal == "" && multiplexer == "" { + return "unknown", "" + } + + return terminal, multiplexer +} + +// getProcessName returns the process name for the given PID +func getProcessName(pid int) string { + switch runtime.GOOS { + case "darwin": + // macOS: ps -p -o comm= + out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "comm=").Output() + if err != nil { + return "" + } + name := strings.TrimSpace(string(out)) + // Remove path prefix if present + if idx := strings.LastIndex(name, "/"); idx >= 0 { + name = name[idx+1:] + } + return name + + case "linux": + // Linux: /proc//comm + out, err := exec.Command("cat", "/proc/"+strconv.Itoa(pid)+"/comm").Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) + } + + return "" +} + +// getParentPID returns the parent process ID for the given PID +func getParentPID(pid int) int { + switch runtime.GOOS { + case "darwin": + out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "ppid=").Output() + if err != nil { + return 0 + } + ppid, err := strconv.Atoi(strings.TrimSpace(string(out))) + if err != nil { + return 0 + } + return ppid + + case "linux": + out, err := exec.Command("cat", "/proc/"+strconv.Itoa(pid)+"/stat").Output() + if err != nil { + return 0 + } + // /proc/pid/stat format: pid (comm) state ppid ... + // Find the closing ) and get the 4th field after it + data := string(out) + idx := strings.LastIndex(data, ")") + if idx < 0 { + return 0 + } + fields := strings.Fields(data[idx+1:]) + if len(fields) < 2 { + return 0 + } + ppid, _ := strconv.Atoi(fields[1]) + return ppid + } + + return 0 +} diff --git a/model/api.go b/model/api.go index 8266b47..27ceaaa 100644 --- a/model/api.go +++ b/model/api.go @@ -20,6 +20,7 @@ type TrackingData struct { StartTimeNano int64 `json:"startTimeNano"` EndTimeNano int64 `json:"endTimeNano"` Result int `json:"result"` + PPID int `json:"ppid,omitempty"` } type TrackingMetaData struct { @@ -28,6 +29,8 @@ type TrackingMetaData struct { OS string `json:"os"` OSVersion string `json:"osVersion"` Shell string `json:"shell"` + Terminal string `json:"terminal,omitempty"` + Multiplexer string `json:"multiplexer,omitempty"` // 0: cli, 1: daemon Source int `json:"source"` diff --git a/model/command.go b/model/command.go index c6574e2..8340e79 100644 --- a/model/command.go +++ b/model/command.go @@ -36,6 +36,7 @@ type Command struct { EndTime time.Time `json:"et"` Result int `json:"result"` Phase CommandPhase `json:"phase"` + PPID int `json:"ppid,omitempty"` // Only work in file RecordingTime time.Time `json:"-"`