Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions commands/track.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -215,6 +222,7 @@ func trySyncLocalToServer(
EndTime: postCommand.Time.Unix(),
EndTimeNano: postCommand.Time.UnixNano(),
Result: postCommand.Result,
PPID: postCommand.PPID,
}

// data masking
Expand Down
8 changes: 8 additions & 0 deletions daemon/handlers.sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Comment on lines +38 to +43
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The current implementation only checks the PPID of the first data item (syncMsg.Data[0]). If that item happens to have a PPID of 0, but subsequent items have a valid PPID, the terminal will not be resolved. It would be more robust to iterate through the data items and use the first valid PPID found.

	var ppid int
	for _, d := range syncMsg.Data {
		if d.PPID > 0 {
			ppid = d.PPID
			break
		}
	}

	if ppid > 0 {
		terminal := ResolveTerminal(ppid)
		syncMsg.Meta.Terminal = terminal
		slog.Debug("Resolved terminal", slog.String("terminal", terminal), slog.Int("ppid", ppid))
	}


// set as daemon
syncMsg.Meta.Source = 1

Expand Down
182 changes: 182 additions & 0 deletions daemon/terminal_resolver.go
Original file line number Diff line number Diff line change
@@ -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 <pid> -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/<pid>/comm
out, err := exec.Command("cat", "/proc/"+strconv.Itoa(pid)+"/comm").Output()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better performance, consider reading from the /proc filesystem directly using os.ReadFile instead of shelling out to cat. This avoids the overhead of creating a new process.

		out, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/comm")

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
Comment on lines +162 to +178
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

There are two issues here:

  1. For better performance, it's preferable to read /proc/<pid>/stat directly using os.ReadFile instead of shelling out to cat.
  2. The error from strconv.Atoi is ignored. This could lead to incorrect behavior if the parsed value is not a valid integer, although unlikely for /proc/pid/stat. It's best practice to handle this error.
		out, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
		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, err := strconv.Atoi(fields[1])
		if err != nil {
			return 0
		}
		return ppid

}

return 0
}
3 changes: 3 additions & 0 deletions model/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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"`
Expand Down
1 change: 1 addition & 0 deletions model/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
Expand Down
Loading