From 0aa82678cb4e671ef78299b320508c19fe379c6e Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Tue, 6 Jan 2026 01:26:09 +0800 Subject: [PATCH 1/2] feat(track): add terminal tracking via PPID resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capture parent process ID in shell hooks and resolve to terminal emulator name (iTerm2, Terminal.app, tmux, etc.) in daemon service. - Add PPID field to Command and TrackingData structs - Add Terminal field to TrackingMetaData - Add --ppid flag to track command - Create terminal_resolver.go for process tree walking - Integrate resolver in daemon sync handler 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- commands/track.go | 8 ++ daemon/handlers.sync.go | 7 ++ daemon/terminal_resolver.go | 193 ++++++++++++++++++++++++++++++++++++ model/api.go | 2 + model/command.go | 1 + 5 files changed, 211 insertions(+) create mode 100644 daemon/terminal_resolver.go 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..b9b63ad 100644 --- a/daemon/handlers.sync.go +++ b/daemon/handlers.sync.go @@ -34,6 +34,13 @@ 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 := ResolveTerminal(syncMsg.Data[0].PPID) + syncMsg.Meta.Terminal = terminal + slog.Debug("Resolved terminal", slog.String("terminal", terminal), 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..bd9f175 --- /dev/null +++ b/daemon/terminal_resolver.go @@ -0,0 +1,193 @@ +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. Returns raw process names. +// Format: "terminal" or "terminal -> multiplexer" for multiplexed sessions +func ResolveTerminal(ppid int) string { + if ppid <= 0 { + return "" + } + + var terminal string + var multiplexer string + + 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 + } + + // Build result string + if terminal == "" && multiplexer == "" { + return "unknown" + } + + if terminal != "" && multiplexer != "" { + return terminal + " -> " + multiplexer + } + + if terminal != "" { + return terminal + } + + return 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..946bb19 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,7 @@ type TrackingMetaData struct { OS string `json:"os"` OSVersion string `json:"osVersion"` Shell string `json:"shell"` + Terminal string `json:"terminal,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:"-"` From c38b9c0f51dfd06f2061cb1133a0b78adfd7da51 Mon Sep 17 00:00:00 2001 From: AnnatarHe Date: Tue, 6 Jan 2026 21:34:12 +0800 Subject: [PATCH 2/2] refactor(track): split terminal field into terminal and multiplexer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separate the combined "terminal -> multiplexer" format into two distinct fields for cleaner server-side storage and querying. - Change ResolveTerminal to return (terminal, multiplexer) tuple - Add Multiplexer field to TrackingMetaData struct - Update sync handler to populate both fields 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- daemon/handlers.sync.go | 5 +++-- daemon/terminal_resolver.go | 25 +++++++------------------ model/api.go | 3 ++- 3 files changed, 12 insertions(+), 21 deletions(-) diff --git a/daemon/handlers.sync.go b/daemon/handlers.sync.go index b9b63ad..b3e21cc 100644 --- a/daemon/handlers.sync.go +++ b/daemon/handlers.sync.go @@ -36,9 +36,10 @@ func handlePubSubSync(ctx context.Context, socketMsgPayload interface{}) error { // Resolve terminal from PPID (use first data item's PPID) if len(syncMsg.Data) > 0 && syncMsg.Data[0].PPID > 0 { - terminal := ResolveTerminal(syncMsg.Data[0].PPID) + terminal, multiplexer := ResolveTerminal(syncMsg.Data[0].PPID) syncMsg.Meta.Terminal = terminal - slog.Debug("Resolved terminal", slog.String("terminal", terminal), slog.Int("ppid", syncMsg.Data[0].PPID)) + 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 diff --git a/daemon/terminal_resolver.go b/daemon/terminal_resolver.go index bd9f175..c1962d6 100644 --- a/daemon/terminal_resolver.go +++ b/daemon/terminal_resolver.go @@ -58,16 +58,13 @@ var knownRemote = map[string]bool{ } // ResolveTerminal walks up the process tree starting from ppid -// to find the terminal emulator. Returns raw process names. -// Format: "terminal" or "terminal -> multiplexer" for multiplexed sessions -func ResolveTerminal(ppid int) string { +// 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 "" + return "", "" } - var terminal string - var multiplexer string - currentPID := ppid visited := make(map[int]bool) @@ -111,20 +108,12 @@ func ResolveTerminal(ppid int) string { currentPID = parentPID } - // Build result string + // If neither found, return "unknown" for terminal if terminal == "" && multiplexer == "" { - return "unknown" - } - - if terminal != "" && multiplexer != "" { - return terminal + " -> " + multiplexer - } - - if terminal != "" { - return terminal + return "unknown", "" } - return multiplexer + return terminal, multiplexer } // getProcessName returns the process name for the given PID diff --git a/model/api.go b/model/api.go index 946bb19..27ceaaa 100644 --- a/model/api.go +++ b/model/api.go @@ -29,7 +29,8 @@ type TrackingMetaData struct { OS string `json:"os"` OSVersion string `json:"osVersion"` Shell string `json:"shell"` - Terminal string `json:"terminal,omitempty"` + Terminal string `json:"terminal,omitempty"` + Multiplexer string `json:"multiplexer,omitempty"` // 0: cli, 1: daemon Source int `json:"source"`