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) <noreply@anthropic.com>
This commit is contained in:
2
main.go
2
main.go
@@ -149,7 +149,7 @@ func main() {
|
|||||||
runI3Bar(sockPath)
|
runI3Bar(sockPath)
|
||||||
|
|
||||||
case "hook":
|
case "hook":
|
||||||
event := parseHookEnv()
|
event := parseHookStdin()
|
||||||
if event.HookEventName == "" {
|
if event.HookEventName == "" {
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ func (n *ExecNotifier) Notify(title, body string) error {
|
|||||||
return exec.CommandContext(ctx, "notify-send",
|
return exec.CommandContext(ctx, "notify-send",
|
||||||
"--urgency=critical",
|
"--urgency=critical",
|
||||||
"--app-name=vmux",
|
"--app-name=vmux",
|
||||||
|
"--expire-time=10000",
|
||||||
title,
|
title,
|
||||||
body,
|
body,
|
||||||
).Run()
|
).Run()
|
||||||
|
|||||||
14
setup.go
14
setup.go
@@ -7,15 +7,11 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseHookEnv builds a HookEvent from Claude Code hook environment variables.
|
// parseHookStdin reads a HookEvent from stdin (Claude Code sends JSON on stdin).
|
||||||
func parseHookEnv() HookEvent {
|
func parseHookStdin() HookEvent {
|
||||||
return HookEvent{
|
var event HookEvent
|
||||||
SessionID: os.Getenv("SESSION_ID"),
|
json.NewDecoder(os.Stdin).Decode(&event)
|
||||||
Cwd: os.Getenv("SESSION_CWD"),
|
return event
|
||||||
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).
|
// runSetup adds vmux hooks to ~/.claude/settings.json (idempotent).
|
||||||
|
|||||||
23
state.go
23
state.go
@@ -20,34 +20,35 @@ func DetectState(messages []JSONLMessage, now time.Time) SessionState {
|
|||||||
|
|
||||||
last := messages[len(messages)-1]
|
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 ts, err := time.Parse(time.RFC3339, last.Timestamp); err == nil {
|
||||||
if now.Sub(ts) > IdleThreshold {
|
age = now.Sub(ts)
|
||||||
return Idle
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if last.Type == "assistant" && last.Message != nil {
|
if last.Type == "assistant" && last.Message != nil {
|
||||||
switch last.Message.StopReason {
|
switch last.Message.StopReason {
|
||||||
case "end_turn":
|
case "end_turn":
|
||||||
return NeedsInput
|
return Idle
|
||||||
case "tool_use":
|
case "tool_use":
|
||||||
for _, block := range last.Message.Content {
|
for _, block := range last.Message.Content {
|
||||||
if block.Type == "tool_use" && block.Name == "AskUserQuestion" {
|
if block.Type == "tool_use" && block.Name == "AskUserQuestion" {
|
||||||
return NeedsInput
|
return NeedsInput
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// If tool_use is stale (no new JSONL activity), Claude is likely
|
// Stale tool_use = permission prompt waiting for approval.
|
||||||
// waiting for a permission prompt approval.
|
// Never becomes Idle: a pending permission is always NeedsInput.
|
||||||
if ts, err := time.Parse(time.RFC3339, last.Timestamp); err == nil {
|
if age > PermissionStallThreshold {
|
||||||
if now.Sub(ts) > PermissionStallThreshold {
|
return NeedsInput
|
||||||
return NeedsInput
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return Working
|
return Working
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Non-assistant messages (progress, user): idle if old enough
|
||||||
|
if age > IdleThreshold {
|
||||||
|
return Idle
|
||||||
|
}
|
||||||
|
|
||||||
if last.Type == "progress" {
|
if last.Type == "progress" {
|
||||||
return Working
|
return Working
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
var testNow = time.Date(2026, 3, 23, 12, 0, 30, 0, time.UTC)
|
var testNow = time.Date(2026, 3, 23, 12, 0, 30, 0, time.UTC)
|
||||||
|
|
||||||
func TestDetectState_EndTurnText(t *testing.T) {
|
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{{
|
msgs := []JSONLMessage{{
|
||||||
Type: "assistant",
|
Type: "assistant",
|
||||||
Timestamp: "2026-03-23T12:00:00Z",
|
Timestamp: "2026-03-23T12:00:00Z",
|
||||||
@@ -19,8 +21,8 @@ func TestDetectState_EndTurnText(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
|
|
||||||
state := DetectState(msgs, testNow)
|
state := DetectState(msgs, testNow)
|
||||||
if state != NeedsInput {
|
if state != Idle {
|
||||||
t.Errorf("expected NeedsInput, got %v", state)
|
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) {
|
func TestDetectState_Progress(t *testing.T) {
|
||||||
msgs := []JSONLMessage{{
|
msgs := []JSONLMessage{{
|
||||||
Type: "progress",
|
Type: "progress",
|
||||||
|
|||||||
Reference in New Issue
Block a user