Skip to content

Commit fa5227a

Browse files
authored
Merge pull request #200 from shelltime/feat/terminal-tracking
feat(track): add terminal tracking via PPID resolution
2 parents 4631e37 + c38b9c0 commit fa5227a

5 files changed

Lines changed: 202 additions & 0 deletions

File tree

commands/track.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ var TrackCommand *cli.Command = &cli.Command{
4646
Aliases: []string{"r"},
4747
Usage: "Exit code of last command",
4848
},
49+
&cli.IntFlag{
50+
Name: "ppid",
51+
Value: 0,
52+
Usage: "Parent process ID of the shell (for terminal detection)",
53+
},
4954
},
5055
Action: commandTrack,
5156
OnUsageError: func(cCtx *cli.Context, err error, isSubcommand bool) error {
@@ -78,6 +83,7 @@ func commandTrack(c *cli.Context) error {
7883
cmdCommand := c.String("command")
7984
cmdPhase := c.String("phase")
8085
result := c.Int("result")
86+
ppid := c.Int("ppid")
8187

8288
instance := &model.Command{
8389
Shell: shell,
@@ -87,6 +93,7 @@ func commandTrack(c *cli.Context) error {
8793
Username: username,
8894
Time: time.Now(),
8995
Phase: model.CommandPhasePre,
96+
PPID: ppid,
9097
}
9198

9299
// Check if command should be excluded
@@ -215,6 +222,7 @@ func trySyncLocalToServer(
215222
EndTime: postCommand.Time.Unix(),
216223
EndTimeNano: postCommand.Time.UnixNano(),
217224
Result: postCommand.Result,
225+
PPID: postCommand.PPID,
218226
}
219227

220228
// data masking

daemon/handlers.sync.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ func handlePubSubSync(ctx context.Context, socketMsgPayload interface{}) error {
3434
return err
3535
}
3636

37+
// Resolve terminal from PPID (use first data item's PPID)
38+
if len(syncMsg.Data) > 0 && syncMsg.Data[0].PPID > 0 {
39+
terminal, multiplexer := ResolveTerminal(syncMsg.Data[0].PPID)
40+
syncMsg.Meta.Terminal = terminal
41+
syncMsg.Meta.Multiplexer = multiplexer
42+
slog.Debug("Resolved terminal", slog.String("terminal", terminal), slog.String("multiplexer", multiplexer), slog.Int("ppid", syncMsg.Data[0].PPID))
43+
}
44+
3745
// set as daemon
3846
syncMsg.Meta.Source = 1
3947

daemon/terminal_resolver.go

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
package daemon
2+
3+
import (
4+
"os/exec"
5+
"runtime"
6+
"strconv"
7+
"strings"
8+
)
9+
10+
// Known terminal emulator process names
11+
var knownTerminals = map[string]bool{
12+
// macOS
13+
"Terminal": true,
14+
"iTerm2": true,
15+
"Alacritty": true,
16+
"alacritty": true,
17+
"kitty": true,
18+
"WezTerm": true,
19+
"wezterm": true,
20+
"wezterm-gui": true,
21+
"Hyper": true,
22+
"Tabby": true,
23+
"Warp": true,
24+
"Ghostty": true,
25+
"ghostty": true,
26+
// Linux
27+
"gnome-terminal": true,
28+
"gnome-terminal-": true, // gnome-terminal-server
29+
"konsole": true,
30+
"xfce4-terminal": true,
31+
"xterm": true,
32+
"urxvt": true,
33+
"rxvt": true,
34+
"terminator": true,
35+
"tilix": true,
36+
"st": true,
37+
"foot": true,
38+
"footclient": true,
39+
// IDE terminals
40+
"code": true,
41+
"Code": true,
42+
"cursor": true,
43+
"Cursor": true,
44+
}
45+
46+
// Known terminal multiplexer process names
47+
var knownMultiplexers = map[string]bool{
48+
"tmux": true,
49+
"screen": true,
50+
"zellij": true,
51+
}
52+
53+
// Known remote/container process names
54+
var knownRemote = map[string]bool{
55+
"sshd": true,
56+
"docker": true,
57+
"containerd": true,
58+
}
59+
60+
// ResolveTerminal walks up the process tree starting from ppid
61+
// to find the terminal emulator and multiplexer separately.
62+
// Returns (terminal, multiplexer) as separate values.
63+
func ResolveTerminal(ppid int) (terminal string, multiplexer string) {
64+
if ppid <= 0 {
65+
return "", ""
66+
}
67+
68+
currentPID := ppid
69+
visited := make(map[int]bool)
70+
71+
// Walk up the process tree (max 10 levels to prevent infinite loops)
72+
for i := 0; i < 10; i++ {
73+
if currentPID <= 1 || visited[currentPID] {
74+
break
75+
}
76+
visited[currentPID] = true
77+
78+
processName := getProcessName(currentPID)
79+
if processName == "" {
80+
break
81+
}
82+
83+
// Check for multiplexers first (they're closer to the shell)
84+
if multiplexer == "" && knownMultiplexers[processName] {
85+
multiplexer = processName
86+
}
87+
88+
// Check for terminals
89+
if terminal == "" && knownTerminals[processName] {
90+
terminal = processName
91+
}
92+
93+
// Check for remote connections
94+
if terminal == "" && knownRemote[processName] {
95+
terminal = processName
96+
}
97+
98+
// If we found a terminal, we can stop
99+
if terminal != "" {
100+
break
101+
}
102+
103+
// Get parent PID and continue
104+
parentPID := getParentPID(currentPID)
105+
if parentPID <= 1 || parentPID == currentPID {
106+
break
107+
}
108+
currentPID = parentPID
109+
}
110+
111+
// If neither found, return "unknown" for terminal
112+
if terminal == "" && multiplexer == "" {
113+
return "unknown", ""
114+
}
115+
116+
return terminal, multiplexer
117+
}
118+
119+
// getProcessName returns the process name for the given PID
120+
func getProcessName(pid int) string {
121+
switch runtime.GOOS {
122+
case "darwin":
123+
// macOS: ps -p <pid> -o comm=
124+
out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "comm=").Output()
125+
if err != nil {
126+
return ""
127+
}
128+
name := strings.TrimSpace(string(out))
129+
// Remove path prefix if present
130+
if idx := strings.LastIndex(name, "/"); idx >= 0 {
131+
name = name[idx+1:]
132+
}
133+
return name
134+
135+
case "linux":
136+
// Linux: /proc/<pid>/comm
137+
out, err := exec.Command("cat", "/proc/"+strconv.Itoa(pid)+"/comm").Output()
138+
if err != nil {
139+
return ""
140+
}
141+
return strings.TrimSpace(string(out))
142+
}
143+
144+
return ""
145+
}
146+
147+
// getParentPID returns the parent process ID for the given PID
148+
func getParentPID(pid int) int {
149+
switch runtime.GOOS {
150+
case "darwin":
151+
out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "ppid=").Output()
152+
if err != nil {
153+
return 0
154+
}
155+
ppid, err := strconv.Atoi(strings.TrimSpace(string(out)))
156+
if err != nil {
157+
return 0
158+
}
159+
return ppid
160+
161+
case "linux":
162+
out, err := exec.Command("cat", "/proc/"+strconv.Itoa(pid)+"/stat").Output()
163+
if err != nil {
164+
return 0
165+
}
166+
// /proc/pid/stat format: pid (comm) state ppid ...
167+
// Find the closing ) and get the 4th field after it
168+
data := string(out)
169+
idx := strings.LastIndex(data, ")")
170+
if idx < 0 {
171+
return 0
172+
}
173+
fields := strings.Fields(data[idx+1:])
174+
if len(fields) < 2 {
175+
return 0
176+
}
177+
ppid, _ := strconv.Atoi(fields[1])
178+
return ppid
179+
}
180+
181+
return 0
182+
}

model/api.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type TrackingData struct {
2020
StartTimeNano int64 `json:"startTimeNano"`
2121
EndTimeNano int64 `json:"endTimeNano"`
2222
Result int `json:"result"`
23+
PPID int `json:"ppid,omitempty"`
2324
}
2425

2526
type TrackingMetaData struct {
@@ -28,6 +29,8 @@ type TrackingMetaData struct {
2829
OS string `json:"os"`
2930
OSVersion string `json:"osVersion"`
3031
Shell string `json:"shell"`
32+
Terminal string `json:"terminal,omitempty"`
33+
Multiplexer string `json:"multiplexer,omitempty"`
3134

3235
// 0: cli, 1: daemon
3336
Source int `json:"source"`

model/command.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type Command struct {
3636
EndTime time.Time `json:"et"`
3737
Result int `json:"result"`
3838
Phase CommandPhase `json:"phase"`
39+
PPID int `json:"ppid,omitempty"`
3940

4041
// Only work in file
4142
RecordingTime time.Time `json:"-"`

0 commit comments

Comments
 (0)