Skip to content

Commit 0aa8267

Browse files
AnnatarHeclaude
andcommitted
feat(track): add terminal tracking via PPID resolution
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 <noreply@anthropic.com>
1 parent 4631e37 commit 0aa8267

5 files changed

Lines changed: 211 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: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ 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 := ResolveTerminal(syncMsg.Data[0].PPID)
40+
syncMsg.Meta.Terminal = terminal
41+
slog.Debug("Resolved terminal", slog.String("terminal", terminal), slog.Int("ppid", syncMsg.Data[0].PPID))
42+
}
43+
3744
// set as daemon
3845
syncMsg.Meta.Source = 1
3946

daemon/terminal_resolver.go

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
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. Returns raw process names.
62+
// Format: "terminal" or "terminal -> multiplexer" for multiplexed sessions
63+
func ResolveTerminal(ppid int) string {
64+
if ppid <= 0 {
65+
return ""
66+
}
67+
68+
var terminal string
69+
var multiplexer string
70+
71+
currentPID := ppid
72+
visited := make(map[int]bool)
73+
74+
// Walk up the process tree (max 10 levels to prevent infinite loops)
75+
for i := 0; i < 10; i++ {
76+
if currentPID <= 1 || visited[currentPID] {
77+
break
78+
}
79+
visited[currentPID] = true
80+
81+
processName := getProcessName(currentPID)
82+
if processName == "" {
83+
break
84+
}
85+
86+
// Check for multiplexers first (they're closer to the shell)
87+
if multiplexer == "" && knownMultiplexers[processName] {
88+
multiplexer = processName
89+
}
90+
91+
// Check for terminals
92+
if terminal == "" && knownTerminals[processName] {
93+
terminal = processName
94+
}
95+
96+
// Check for remote connections
97+
if terminal == "" && knownRemote[processName] {
98+
terminal = processName
99+
}
100+
101+
// If we found a terminal, we can stop
102+
if terminal != "" {
103+
break
104+
}
105+
106+
// Get parent PID and continue
107+
parentPID := getParentPID(currentPID)
108+
if parentPID <= 1 || parentPID == currentPID {
109+
break
110+
}
111+
currentPID = parentPID
112+
}
113+
114+
// Build result string
115+
if terminal == "" && multiplexer == "" {
116+
return "unknown"
117+
}
118+
119+
if terminal != "" && multiplexer != "" {
120+
return terminal + " -> " + multiplexer
121+
}
122+
123+
if terminal != "" {
124+
return terminal
125+
}
126+
127+
return multiplexer
128+
}
129+
130+
// getProcessName returns the process name for the given PID
131+
func getProcessName(pid int) string {
132+
switch runtime.GOOS {
133+
case "darwin":
134+
// macOS: ps -p <pid> -o comm=
135+
out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "comm=").Output()
136+
if err != nil {
137+
return ""
138+
}
139+
name := strings.TrimSpace(string(out))
140+
// Remove path prefix if present
141+
if idx := strings.LastIndex(name, "/"); idx >= 0 {
142+
name = name[idx+1:]
143+
}
144+
return name
145+
146+
case "linux":
147+
// Linux: /proc/<pid>/comm
148+
out, err := exec.Command("cat", "/proc/"+strconv.Itoa(pid)+"/comm").Output()
149+
if err != nil {
150+
return ""
151+
}
152+
return strings.TrimSpace(string(out))
153+
}
154+
155+
return ""
156+
}
157+
158+
// getParentPID returns the parent process ID for the given PID
159+
func getParentPID(pid int) int {
160+
switch runtime.GOOS {
161+
case "darwin":
162+
out, err := exec.Command("ps", "-p", strconv.Itoa(pid), "-o", "ppid=").Output()
163+
if err != nil {
164+
return 0
165+
}
166+
ppid, err := strconv.Atoi(strings.TrimSpace(string(out)))
167+
if err != nil {
168+
return 0
169+
}
170+
return ppid
171+
172+
case "linux":
173+
out, err := exec.Command("cat", "/proc/"+strconv.Itoa(pid)+"/stat").Output()
174+
if err != nil {
175+
return 0
176+
}
177+
// /proc/pid/stat format: pid (comm) state ppid ...
178+
// Find the closing ) and get the 4th field after it
179+
data := string(out)
180+
idx := strings.LastIndex(data, ")")
181+
if idx < 0 {
182+
return 0
183+
}
184+
fields := strings.Fields(data[idx+1:])
185+
if len(fields) < 2 {
186+
return 0
187+
}
188+
ppid, _ := strconv.Atoi(fields[1])
189+
return ppid
190+
}
191+
192+
return 0
193+
}

model/api.go

Lines changed: 2 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,7 @@ type TrackingMetaData struct {
2829
OS string `json:"os"`
2930
OSVersion string `json:"osVersion"`
3031
Shell string `json:"shell"`
32+
Terminal string `json:"terminal,omitempty"`
3133

3234
// 0: cli, 1: daemon
3335
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)