When Claude requests a tool permission approval, the last JSONL entry is type=assistant with stop_reason=tool_use. Previously this was always classified as Working. Now, if the tool_use entry is older than 10s with no new activity, it's classified as NeedsInput. Also fix vmux label to accept fuzzy match queries (not just session UUIDs).
95 lines
2.2 KiB
Go
95 lines
2.2 KiB
Go
package main
|
|
|
|
import (
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const IdleThreshold = 60 * time.Second
|
|
|
|
// PermissionStallThreshold: if a tool_use has been pending this long
|
|
// without any new JSONL entry, Claude is likely waiting for user approval.
|
|
const PermissionStallThreshold = 10 * time.Second
|
|
|
|
// DetectState determines the session state from the last JSONL messages.
|
|
// The now parameter enables deterministic testing of the idle threshold.
|
|
func DetectState(messages []JSONLMessage, now time.Time) SessionState {
|
|
if len(messages) == 0 {
|
|
return Unknown
|
|
}
|
|
|
|
last := messages[len(messages)-1]
|
|
|
|
// Check idle threshold first
|
|
if ts, err := time.Parse(time.RFC3339, last.Timestamp); err == nil {
|
|
if now.Sub(ts) > IdleThreshold {
|
|
return Idle
|
|
}
|
|
}
|
|
|
|
if last.Type == "assistant" && last.Message != nil {
|
|
switch last.Message.StopReason {
|
|
case "end_turn":
|
|
return NeedsInput
|
|
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
|
|
}
|
|
}
|
|
return Working
|
|
}
|
|
}
|
|
|
|
if last.Type == "progress" {
|
|
return Working
|
|
}
|
|
|
|
if last.Type == "user" && last.Message != nil {
|
|
for _, block := range last.Message.Content {
|
|
if block.Type == "tool_result" {
|
|
return Working
|
|
}
|
|
}
|
|
}
|
|
|
|
return Unknown
|
|
}
|
|
|
|
// ExtractPreview finds the last assistant text content and returns the first
|
|
// 3 lines, truncated to 200 characters.
|
|
func ExtractPreview(messages []JSONLMessage) string {
|
|
for i := len(messages) - 1; i >= 0; i-- {
|
|
msg := messages[i]
|
|
if msg.Type != "assistant" || msg.Message == nil {
|
|
continue
|
|
}
|
|
for _, block := range msg.Message.Content {
|
|
if block.Type == "text" && block.Text != "" {
|
|
return truncatePreview(block.Text)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func truncatePreview(text string) string {
|
|
lines := strings.SplitN(text, "\n", 4)
|
|
if len(lines) > 3 {
|
|
lines = lines[:3]
|
|
}
|
|
result := strings.Join(lines, "\n")
|
|
|
|
if len(result) > 200 {
|
|
result = result[:200] + "..."
|
|
}
|
|
return result
|
|
}
|