Skip to content

Commit 52347a8

Browse files
committed
dd hook socket for reliable agent status via IPC
- Introduce Unix socket server to receive agent status events directly, bypassing fragile PTY output scanning - Add hook client (--agent-event flag) to forward events from stdin to the socket - Auto-install Claude Code hooks on startup - Pass AIPILOT_HOOK_SOCKET env var to agent process - Upgrade ANSI escape parser to proper state machine (CSI/OSC) - Disable PTY scanning when hook socket is active
1 parent 0a24afd commit 52347a8

6 files changed

Lines changed: 475 additions & 27 deletions

File tree

agent_status.go

Lines changed: 68 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ const (
1616
agentStatusBufSize = 256
1717
)
1818

19+
// ANSI escape sequence parser states
20+
const (
21+
escNone = 0 // Normal text
22+
escStart = 1 // Just saw ESC (0x1b)
23+
escCSI = 2 // In CSI sequence: ESC [ ... letter
24+
escOSC = 3 // In OSC sequence: ESC ] ... (BEL or ST)
25+
escOSCESC = 4 // In OSC, saw ESC (awaiting \ for ST terminator)
26+
)
27+
1928
// busyPattern is the lowercase pattern to detect in PTY output
2029
var busyPattern = []byte("esc to ")
2130

@@ -27,32 +36,73 @@ func (d *Daemon) initAgentStatus() {
2736
// scanAgentStatus scans PTY output for the busy pattern.
2837
// Called from startPTYReader on every read — must be fast.
2938
func (d *Daemon) scanAgentStatus(data []byte) {
30-
// Append printable chars (lowercased) to rolling buffer, skip ANSI sequences
31-
inEscape := d.agentStatusInEscape
39+
// Skip PTY scanning when receiving status via hook socket
40+
if d.agentStatusViaSocket {
41+
return
42+
}
43+
44+
state := d.agentEscState
3245
buf := d.agentStatusBuf
3346

3447
for _, b := range data {
35-
if inEscape {
36-
// Inside ANSI escape: wait for terminating letter
48+
switch state {
49+
case escNone:
50+
if b == 0x1b {
51+
state = escStart
52+
continue
53+
}
54+
// Only keep printable ASCII, lowercase on the fly
55+
if b >= 0x20 && b <= 0x7e {
56+
if b >= 'A' && b <= 'Z' {
57+
b += 0x20
58+
}
59+
buf = append(buf, b)
60+
}
61+
62+
case escStart:
63+
// Byte right after ESC determines the sequence type
64+
if b == '[' {
65+
state = escCSI
66+
} else if b == ']' {
67+
state = escOSC
68+
} else {
69+
// Two-byte escape (e.g. ESC c, ESC M) — done
70+
state = escNone
71+
}
72+
73+
case escCSI:
74+
// CSI: ESC [ (params) letter — letter terminates
3775
if (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z') {
38-
inEscape = false
76+
state = escNone
3977
}
40-
continue
41-
}
42-
if b == 0x1b {
43-
inEscape = true
44-
continue
45-
}
46-
// Only keep printable ASCII
47-
if b >= 0x20 && b <= 0x7e {
48-
// Lowercase on the fly
49-
if b >= 'A' && b <= 'Z' {
50-
b += 0x20
78+
// else: parameters (digits, ;, ?, etc.) — keep consuming
79+
80+
case escOSC:
81+
// OSC: ESC ] ... terminated by BEL (0x07) or ST (ESC \)
82+
if b == 0x07 {
83+
state = escNone
84+
} else if b == 0x1b {
85+
state = escOSCESC
86+
}
87+
// else: OSC content — keep consuming
88+
89+
case escOSCESC:
90+
// Inside OSC, saw ESC — if next is \, it's ST (end of OSC)
91+
if b == '\\' {
92+
state = escNone
93+
} else {
94+
// Not ST — treat as new escape sequence
95+
if b == '[' {
96+
state = escCSI
97+
} else if b == ']' {
98+
state = escOSC
99+
} else {
100+
state = escNone
101+
}
51102
}
52-
buf = append(buf, b)
53103
}
54104
}
55-
d.agentStatusInEscape = inEscape
105+
d.agentEscState = state
56106

57107
// Trim buffer to max size (keep tail)
58108
if len(buf) > agentStatusBufSize {

hook_claude.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"os"
7+
"path/filepath"
8+
)
9+
10+
// Claude Code specific hook installation.
11+
// This file contains only Claude-specific code. When another agent
12+
// supports hooks, create a similar hook_<agent>.go file.
13+
14+
// claudeSettingsPath returns the path to Claude Code's settings.json
15+
func claudeSettingsPath() string {
16+
home, err := os.UserHomeDir()
17+
if err != nil {
18+
return ""
19+
}
20+
return filepath.Join(home, ".claude", "settings.json")
21+
}
22+
23+
// hookCommand is the command that Claude Code will execute for each hook event
24+
const hookCommand = "aipilot-cli --agent-event"
25+
26+
// claudeHookEvents lists the Claude Code events we want to hook into
27+
var claudeHookEvents = []string{"UserPromptSubmit", "Stop", "StopFailure"}
28+
29+
// ensureClaudeHooksInstalled reads ~/.claude/settings.json and adds
30+
// aipilot hook entries for agent status detection if not already present.
31+
func ensureClaudeHooksInstalled() {
32+
settingsPath := claudeSettingsPath()
33+
if settingsPath == "" {
34+
return
35+
}
36+
37+
// Read existing settings
38+
settings, err := readClaudeSettings(settingsPath)
39+
if err != nil {
40+
fmt.Printf("%s[hook] Cannot read Claude settings: %v%s\n", dim, err, reset)
41+
return
42+
}
43+
44+
// Ensure hooks section exists
45+
hooks, ok := settings["hooks"].(map[string]interface{})
46+
if !ok {
47+
hooks = make(map[string]interface{})
48+
settings["hooks"] = hooks
49+
}
50+
51+
modified := false
52+
for _, eventName := range claudeHookEvents {
53+
if addClaudeHookIfMissing(hooks, eventName) {
54+
modified = true
55+
}
56+
}
57+
58+
if !modified {
59+
return
60+
}
61+
62+
// Write back
63+
if err := writeClaudeSettings(settingsPath, settings); err != nil {
64+
fmt.Printf("%s[hook] Cannot write Claude settings: %v%s\n", dim, err, reset)
65+
return
66+
}
67+
68+
fmt.Printf("%s[hook] Installed aipilot hooks in %s%s\n", dim, settingsPath, reset)
69+
}
70+
71+
// addClaudeHookIfMissing adds an aipilot hook entry to the given event array
72+
// if one doesn't already exist. Returns true if the settings were modified.
73+
func addClaudeHookIfMissing(hooks map[string]interface{}, eventName string) bool {
74+
// Get or create the event array
75+
var eventEntries []interface{}
76+
if existing, ok := hooks[eventName]; ok {
77+
if arr, ok := existing.([]interface{}); ok {
78+
eventEntries = arr
79+
}
80+
}
81+
82+
// Check if aipilot hook is already installed
83+
for _, entry := range eventEntries {
84+
entryMap, ok := entry.(map[string]interface{})
85+
if !ok {
86+
continue
87+
}
88+
hooksList, ok := entryMap["hooks"].([]interface{})
89+
if !ok {
90+
continue
91+
}
92+
for _, h := range hooksList {
93+
hookMap, ok := h.(map[string]interface{})
94+
if !ok {
95+
continue
96+
}
97+
if cmd, ok := hookMap["command"].(string); ok && cmd == hookCommand {
98+
return false // Already installed
99+
}
100+
}
101+
}
102+
103+
// Add new entry
104+
newEntry := map[string]interface{}{
105+
"matcher": "",
106+
"hooks": []interface{}{
107+
map[string]interface{}{
108+
"type": "command",
109+
"command": hookCommand,
110+
"timeout": 5,
111+
"async": true,
112+
},
113+
},
114+
}
115+
116+
hooks[eventName] = append(eventEntries, newEntry)
117+
return true
118+
}
119+
120+
// readClaudeSettings reads and parses Claude Code's settings.json
121+
func readClaudeSettings(path string) (map[string]interface{}, error) {
122+
data, err := os.ReadFile(path)
123+
if err != nil {
124+
if os.IsNotExist(err) {
125+
// No settings file yet — start fresh
126+
return make(map[string]interface{}), nil
127+
}
128+
return nil, err
129+
}
130+
131+
var settings map[string]interface{}
132+
if err := json.Unmarshal(data, &settings); err != nil {
133+
return nil, err
134+
}
135+
return settings, nil
136+
}
137+
138+
// writeClaudeSettings writes settings back to Claude Code's settings.json
139+
func writeClaudeSettings(path string, settings map[string]interface{}) error {
140+
// Ensure directory exists
141+
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
142+
return err
143+
}
144+
145+
data, err := json.MarshalIndent(settings, "", " ")
146+
if err != nil {
147+
return err
148+
}
149+
150+
return os.WriteFile(path, append(data, '\n'), 0644)
151+
}

hook_client.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net"
8+
"os"
9+
)
10+
11+
// agentEventMain is the entry point for --hook mode.
12+
// It reads a Claude Code hook payload from stdin, maps it to a generic
13+
// HookMessage, and sends it to the AIPILOT_HOOK_SOCKET Unix socket.
14+
// Exits silently with code 0 in all cases (must not block the agent).
15+
func agentEventMain() {
16+
socketPath := os.Getenv("AIPILOT_HOOK_SOCKET")
17+
if socketPath == "" {
18+
// Not running under aipilot — exit silently
19+
os.Exit(0)
20+
}
21+
22+
// Read hook payload from stdin
23+
input, err := io.ReadAll(os.Stdin)
24+
if err != nil || len(input) == 0 {
25+
os.Exit(0)
26+
}
27+
28+
// Extract hook_event_name from the payload
29+
var payload struct {
30+
HookEventName string `json:"hook_event_name"`
31+
}
32+
if err := json.Unmarshal(input, &payload); err != nil {
33+
os.Exit(0)
34+
}
35+
36+
// Map agent-specific event to generic hook message
37+
msg := mapHookEvent(payload.HookEventName)
38+
if msg == nil {
39+
// Unknown or unhandled event — ignore
40+
os.Exit(0)
41+
}
42+
43+
// Send to socket
44+
data, err := json.Marshal(msg)
45+
if err != nil {
46+
os.Exit(0)
47+
}
48+
49+
conn, err := net.Dial("unix", socketPath)
50+
if err != nil {
51+
// Socket not available (CLI may have exited) — exit silently
52+
os.Exit(0)
53+
}
54+
defer conn.Close()
55+
56+
fmt.Fprintf(conn, "%s\n", data)
57+
}
58+
59+
// mapHookEvent maps a hook event name to a generic HookMessage.
60+
// Currently supports Claude Code events. Add other agents here.
61+
func mapHookEvent(eventName string) *HookMessage {
62+
switch eventName {
63+
case "UserPromptSubmit":
64+
return newAgentStatusMessage("busy")
65+
case "Stop", "StopFailure":
66+
return newAgentStatusMessage("idle")
67+
default:
68+
return nil
69+
}
70+
}
71+
72+
// newAgentStatusMessage creates a HookMessage for agent_status events
73+
func newAgentStatusMessage(status string) *HookMessage {
74+
data, _ := json.Marshal(AgentStatusData{Status: status})
75+
return &HookMessage{
76+
Event: "agent_status",
77+
Data: data,
78+
}
79+
}

0 commit comments

Comments
 (0)