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)
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":
writeResponse(conn, Response{OK: true})
d.Stop()

14
main.go
View File

@@ -148,6 +148,18 @@ func main() {
}
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":
runDaemon(sockPath)
@@ -208,8 +220,8 @@ Commands:
label <id> <text> Assign a label to a session
focus <minutes> Suppress notifications for N minutes
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
daemon Run the daemon in foreground (used internally)
Flags:
--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
}