From 001c45346266accb6f11a925f0b9431de35857e5 Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Tue, 24 Mar 2026 11:29:26 +0100 Subject: [PATCH] fix: improve state detection accuracy and notification UX - Hook reads JSON from stdin (not env vars) matching Claude Code protocol - end_turn = Idle (not NeedsInput); real questions come from hooks - Permission prompt (stale tool_use) never becomes Idle - Notifications auto-expire after 10s (--expire-time) Co-Authored-By: Claude Opus 4.6 (1M context) --- main.go | 2 +- notify.go | 1 + setup.go | 14 +++++--------- state.go | 23 ++++++++++++----------- state_test.go | 24 ++++++++++++++++++++++-- 5 files changed, 41 insertions(+), 23 deletions(-) diff --git a/main.go b/main.go index f464fc2..6babe03 100644 --- a/main.go +++ b/main.go @@ -149,7 +149,7 @@ func main() { runI3Bar(sockPath) case "hook": - event := parseHookEnv() + event := parseHookStdin() if event.HookEventName == "" { os.Exit(0) } diff --git a/notify.go b/notify.go index df91988..ee869fd 100644 --- a/notify.go +++ b/notify.go @@ -22,6 +22,7 @@ func (n *ExecNotifier) Notify(title, body string) error { return exec.CommandContext(ctx, "notify-send", "--urgency=critical", "--app-name=vmux", + "--expire-time=10000", title, body, ).Run() diff --git a/setup.go b/setup.go index 2362d47..0b222bc 100644 --- a/setup.go +++ b/setup.go @@ -7,15 +7,11 @@ import ( "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"), - } +// parseHookStdin reads a HookEvent from stdin (Claude Code sends JSON on stdin). +func parseHookStdin() HookEvent { + var event HookEvent + json.NewDecoder(os.Stdin).Decode(&event) + return event } // runSetup adds vmux hooks to ~/.claude/settings.json (idempotent). diff --git a/state.go b/state.go index 2fc520e..af8c45b 100644 --- a/state.go +++ b/state.go @@ -20,34 +20,35 @@ func DetectState(messages []JSONLMessage, now time.Time) SessionState { last := messages[len(messages)-1] - // Check idle threshold first + age := time.Duration(0) if ts, err := time.Parse(time.RFC3339, last.Timestamp); err == nil { - if now.Sub(ts) > IdleThreshold { - return Idle - } + age = now.Sub(ts) } if last.Type == "assistant" && last.Message != nil { switch last.Message.StopReason { case "end_turn": - return NeedsInput + return Idle case "tool_use": for _, block := range last.Message.Content { if block.Type == "tool_use" && block.Name == "AskUserQuestion" { return NeedsInput } } - // If tool_use is stale (no new JSONL activity), Claude is likely - // waiting for a permission prompt approval. - if ts, err := time.Parse(time.RFC3339, last.Timestamp); err == nil { - if now.Sub(ts) > PermissionStallThreshold { - return NeedsInput - } + // Stale tool_use = permission prompt waiting for approval. + // Never becomes Idle: a pending permission is always NeedsInput. + if age > PermissionStallThreshold { + return NeedsInput } return Working } } + // Non-assistant messages (progress, user): idle if old enough + if age > IdleThreshold { + return Idle + } + if last.Type == "progress" { return Working } diff --git a/state_test.go b/state_test.go index 9437aed..e2bc906 100644 --- a/state_test.go +++ b/state_test.go @@ -8,6 +8,8 @@ import ( var testNow = time.Date(2026, 3, 23, 12, 0, 30, 0, time.UTC) func TestDetectState_EndTurnText(t *testing.T) { + // end_turn = Claude finished, prompt waiting. This is Idle, not NeedsInput. + // Real NeedsInput comes from hooks (Stop, Notification) or AskUserQuestion. msgs := []JSONLMessage{{ Type: "assistant", Timestamp: "2026-03-23T12:00:00Z", @@ -19,8 +21,8 @@ func TestDetectState_EndTurnText(t *testing.T) { }} state := DetectState(msgs, testNow) - if state != NeedsInput { - t.Errorf("expected NeedsInput, got %v", state) + if state != Idle { + t.Errorf("expected Idle for end_turn, got %v", state) } } @@ -84,6 +86,24 @@ func TestDetectState_ToolUseStale(t *testing.T) { } } +func TestDetectState_ToolUseStaleNeverIdle(t *testing.T) { + // A tool_use older than IdleThreshold should still be NeedsInput, not Idle + msgs := []JSONLMessage{{ + Type: "assistant", + Timestamp: "2026-03-23T11:58:00Z", // 2min30s before testNow (> IdleThreshold) + Message: &MessagePayload{ + Role: "assistant", + Content: []ContentBlock{{Type: "tool_use", Name: "Bash"}}, + StopReason: "tool_use", + }, + }} + + state := DetectState(msgs, testNow) + if state != NeedsInput { + t.Errorf("expected NeedsInput for very old tool_use (permission), got %v", state) + } +} + func TestDetectState_Progress(t *testing.T) { msgs := []JSONLMessage{{ Type: "progress",