feat: add vmux setup and vmux hook for Claude Code integration

- vmux setup: idempotently adds hooks to ~/.claude/settings.json
- vmux hook: receives Claude Code hook events via env vars and forwards to daemon
- Daemon handles "hook" action on Unix socket (no HTTP server needed)
- Hooks provide instant state detection (permission/question/idle)
This commit is contained in:
Pierre Martin
2026-03-23 23:11:47 +01:00
parent 994b78aee5
commit 0e4ced5b1f
3 changed files with 128 additions and 1 deletions

View File

@@ -452,6 +452,15 @@ func (d *Daemon) handleConnection(conn net.Conn) {
d.focus.Set(time.Duration(args.Minutes) * time.Minute) d.focus.Set(time.Duration(args.Minutes) * time.Minute)
writeResponse(conn, Response{OK: true, FocusRemaining: d.focus.Remaining().Minutes()}) writeResponse(conn, Response{OK: true, FocusRemaining: d.focus.Remaining().Minutes()})
case "hook":
var event HookEvent
if err := json.Unmarshal(req.Args, &event); err != nil {
writeResponse(conn, Response{Error: "invalid hook args: " + err.Error()})
return
}
d.processHookEvent(event)
writeResponse(conn, Response{OK: true})
case "stop": case "stop":
writeResponse(conn, Response{OK: true}) writeResponse(conn, Response{OK: true})
d.Stop() d.Stop()

14
main.go
View File

@@ -148,6 +148,18 @@ func main() {
} }
runI3Bar(sockPath) runI3Bar(sockPath)
case "hook":
event := parseHookEnv()
if event.HookEventName == "" {
os.Exit(0)
}
client := NewClient(sockPath)
hookArgs, _ := json.Marshal(event)
client.Send(Request{Action: "hook", Args: hookArgs})
case "setup":
runSetup()
case "daemon": case "daemon":
runDaemon(sockPath) runDaemon(sockPath)
@@ -208,8 +220,8 @@ Commands:
label <id> <text> Assign a label to a session label <id> <text> Assign a label to a session
focus <minutes> Suppress notifications for N minutes focus <minutes> Suppress notifications for N minutes
i3bar Output i3bar JSON (use as status_command in i3 config) i3bar Output i3bar JSON (use as status_command in i3 config)
setup Add vmux hooks to Claude Code settings (idempotent)
stop Stop the vmux daemon stop Stop the vmux daemon
daemon Run the daemon in foreground (used internally)
Flags: Flags:
--no-color Disable colored output --no-color Disable colored output

106
setup.go Normal file
View File

@@ -0,0 +1,106 @@
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
// parseHookEnv builds a HookEvent from Claude Code hook environment variables.
func parseHookEnv() HookEvent {
return HookEvent{
SessionID: os.Getenv("SESSION_ID"),
Cwd: os.Getenv("SESSION_CWD"),
HookEventName: os.Getenv("HOOK_EVENT_NAME"),
NotificationType: os.Getenv("NOTIFICATION_TYPE"),
ToolName: os.Getenv("TOOL_NAME"),
}
}
// runSetup adds vmux hooks to ~/.claude/settings.json (idempotent).
func runSetup() {
home := os.Getenv("HOME")
settingsPath := filepath.Join(home, ".claude", "settings.json")
vmuxBin, err := os.Executable()
if err != nil {
vmuxBin = filepath.Join(home, "Code", "vibe", "vmux", "vmux")
}
data, err := os.ReadFile(settingsPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot read %s: %v\n", settingsPath, err)
os.Exit(1)
}
var settings map[string]interface{}
if err := json.Unmarshal(data, &settings); err != nil {
fmt.Fprintf(os.Stderr, "Cannot parse %s: %v\n", settingsPath, err)
os.Exit(1)
}
hooks, _ := settings["hooks"].(map[string]interface{})
if hooks == nil {
hooks = make(map[string]interface{})
settings["hooks"] = hooks
}
hookCommand := vmuxBin + " hook"
vmuxHook := map[string]interface{}{
"type": "command",
"command": hookCommand,
"timeout": 5,
}
events := []string{"Notification", "Stop", "PostToolUse", "PreToolUse"}
changed := false
for _, event := range events {
entries, _ := hooks[event].([]interface{})
if hasVmuxHook(entries, hookCommand) {
continue
}
entry := map[string]interface{}{
"hooks": []interface{}{vmuxHook},
}
hooks[event] = append(entries, entry)
changed = true
}
if !changed {
fmt.Println("vmux hooks already configured.")
return
}
out, err := json.MarshalIndent(settings, "", " ")
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot marshal settings: %v\n", err)
os.Exit(1)
}
if err := os.WriteFile(settingsPath, out, 0o644); err != nil {
fmt.Fprintf(os.Stderr, "Cannot write %s: %v\n", settingsPath, err)
os.Exit(1)
}
fmt.Printf("vmux hooks added to %s for events: %v\n", settingsPath, events)
fmt.Println("Restart Claude Code sessions for hooks to take effect.")
}
func hasVmuxHook(entries []interface{}, hookCommand string) bool {
for _, entry := range entries {
e, _ := entry.(map[string]interface{})
hookList, _ := e["hooks"].([]interface{})
for _, h := range hookList {
hm, _ := h.(map[string]interface{})
if cmd, _ := hm["command"].(string); cmd == hookCommand {
return true
}
}
}
return false
}