fix: detect permission prompts as NeedsInput when tool_use is stale (>10s)

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).
This commit is contained in:
Pierre Martin
2026-03-23 18:29:32 +01:00
parent 170790fcda
commit 8594c48f84
5 changed files with 162 additions and 5 deletions

View File

@@ -25,7 +25,8 @@
"text_mode": false, "text_mode": false,
"research_before_questions": false, "research_before_questions": false,
"discuss_mode": "discuss", "discuss_mode": "discuss",
"skip_discuss": false "skip_discuss": false,
"_auto_chain_active": false
}, },
"hooks": { "hooks": {
"context_warnings": true "context_warnings": true

View File

@@ -0,0 +1,118 @@
---
phase: 01-session-discovery
plan: 02
subsystem: cli
tags: [go, jsonl, process-detection, tui, ansi]
requires:
- phase: 01-session-discovery/01-01
provides: "Process, Session, SessionState types + FindClaudeProcesses + EncodePath"
provides:
- "TailReadJSONL: reverse-read JSONL sans charger le fichier entier"
- "FindSessionForProcess: matching PID -> JSONL le plus recent"
- "DetectState: heuristique Working/NeedsInput/Idle basee sur stop_reason"
- "ExtractPreview: apercu des 3 dernieres lignes assistant"
- "DisplaySessions: affichage CLI avec couleurs ANSI"
- "vmux list: commande CLI fonctionnelle de bout en bout"
affects: [02-i3-integration, 03-daemon-mode]
tech-stack:
added: []
patterns: [tail-read-jsonl, injectable-time-for-tests, io-writer-for-testable-output]
key-files:
created: [session.go, session_test.go, state.go, state_test.go, display.go, display_test.go, .gitignore]
modified: [main.go]
key-decisions:
- "IdleThreshold = 60s (constante, configurable plus tard)"
- "--no-color accepte avant et apres la sous-commande list"
- "Subagent JSONL exclus via filepath.Dir check"
patterns-established:
- "TailRead: Seek+ReadBlock en arriere pour les gros fichiers JSONL"
- "now time.Time injectable pour tester le seuil Idle sans horloge"
- "io.Writer pour capturer la sortie display dans les tests"
requirements-completed: [DISC-03, STATE-01, STATE-02]
duration: 4min
completed: 2026-03-23
---
# Phase 01 Plan 02: Session Discovery Summary
**Pipeline complet JSONL parsing + heuristique d'etat + CLI `vmux list` avec couleurs ANSI, 27 tests**
## Performance
- **Duration:** 4 min
- **Started:** 2026-03-23T12:27:33Z
- **Completed:** 2026-03-23T12:31:33Z
- **Tasks:** 2
- **Files modified:** 8
## Accomplishments
- TailReadJSONL lit les N derniers messages JSONL en reverse-seek (blocs de 8KB), gere les lignes tronquees
- FindSessionForProcess croise PID/cwd avec le JSONL le plus recent, exclut les subagents
- DetectState applique l'heuristique d'etat (end_turn, tool_use/AskUserQuestion, progress, tool_result, idle threshold)
- ExtractPreview extrait les 3 premieres lignes du dernier texte assistant (tronque a 200 chars)
- DisplaySessions affiche chaque session avec couleurs ANSI, branche git, worktree et apercu
- `vmux list` fonctionne en production (5 sessions detectees sur le poste)
## Task Commits
1. **Task 1: Matching PID->JSONL + tail-read + heuristique d'etat** - `e7ced9c` (test+feat, TDD)
2. **Task 2: Affichage CLI + main.go + test d'integration** - `f1dcee0` (feat)
3. **Cleanup: .gitignore** - `766ce54` (chore)
## Files Created/Modified
- `session.go` - JSONLMessage types, TailReadJSONL, FindSessionForProcess
- `session_test.go` - 7 tests (tail-read, find session, subagent exclusion)
- `state.go` - DetectState heuristic, ExtractPreview, IdleThreshold
- `state_test.go` - 10 tests (chaque pattern d'etat + preview)
- `display.go` - DisplaySessions avec couleurs ANSI, stateColor helper
- `display_test.go` - 5 tests (sessions, noColor, empty, worktree, noBranch)
- `main.go` - Point d'entree CLI avec pipeline complet
- `.gitignore` - Exclut le binaire vmux
## Decisions Made
- IdleThreshold fixe a 60s comme constante (recommandation RESEARCH)
- `--no-color` accepte avant et apres `list` (Go flag parse s'arrete au premier non-flag)
- Subagents exclus par verification filepath.Dir (pas de glob recursif)
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 1 - Bug] --no-color apres la sous-commande list**
- **Found during:** Task 2
- **Issue:** Go `flag.Parse()` s'arrete au premier argument non-flag. `vmux list --no-color` ne parsait pas le flag.
- **Fix:** Scan manuel des args restants apres flag.Args() pour detecter `--no-color`
- **Files modified:** main.go
- **Verification:** `./vmux list --no-color` produit une sortie sans codes ANSI
- **Committed in:** f1dcee0
---
**Total deviations:** 1 auto-fixed (1 bug)
**Impact on plan:** Fix necessaire pour l'ergonomie CLI. Pas de scope creep.
## Issues Encountered
None
## User Setup Required
None
## Next Phase Readiness
- `vmux list` est fonctionnel de bout en bout
- Pret pour Phase 02 (integration i3 workspace)
- Le binaire se build avec `nix-shell --run "go build -o vmux ./..."`
---
*Phase: 01-session-discovery*
*Completed: 2026-03-23*
## Self-Check: PASSED
All 8 files found. All 3 commits verified.

View File

@@ -333,13 +333,19 @@ func (d *Daemon) handleConnection(conn net.Conn) {
writeResponse(conn, Response{Error: "invalid label args: " + err.Error()}) writeResponse(conn, Response{Error: "invalid label args: " + err.Error()})
return return
} }
if err := d.labels.Set(args.SessionID, args.Label); err != nil { // Resolve SessionID via fuzzy match if it's not a UUID
sessionID := args.SessionID
sessions := d.registry.List()
match := FuzzyMatch(sessionID, sessions)
if match != nil {
sessionID = match.SessionID
}
if err := d.labels.Set(sessionID, args.Label); err != nil {
writeResponse(conn, Response{Error: "set label: " + err.Error()}) writeResponse(conn, Response{Error: "set label: " + err.Error()})
return return
} }
// Update registry with new label
d.registry.mu.Lock() d.registry.mu.Lock()
if ts, ok := d.registry.sessions[args.SessionID]; ok { if ts, ok := d.registry.sessions[sessionID]; ok {
ts.Info.Label = args.Label ts.Info.Label = args.Label
} }
d.registry.mu.Unlock() d.registry.mu.Unlock()

View File

@@ -7,6 +7,10 @@ import (
const IdleThreshold = 60 * time.Second 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. // DetectState determines the session state from the last JSONL messages.
// The now parameter enables deterministic testing of the idle threshold. // The now parameter enables deterministic testing of the idle threshold.
func DetectState(messages []JSONLMessage, now time.Time) SessionState { func DetectState(messages []JSONLMessage, now time.Time) SessionState {
@@ -33,6 +37,13 @@ func DetectState(messages []JSONLMessage, now time.Time) SessionState {
return NeedsInput 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 return Working
} }
} }

View File

@@ -45,9 +45,10 @@ func TestDetectState_ToolUseAskUser(t *testing.T) {
} }
func TestDetectState_ToolUseOther(t *testing.T) { func TestDetectState_ToolUseOther(t *testing.T) {
// Recent tool_use (< PermissionStallThreshold) → Working
msgs := []JSONLMessage{{ msgs := []JSONLMessage{{
Type: "assistant", Type: "assistant",
Timestamp: "2026-03-23T12:00:00Z", Timestamp: "2026-03-23T12:00:25Z", // 5s before testNow
Message: &MessagePayload{ Message: &MessagePayload{
Role: "assistant", Role: "assistant",
Content: []ContentBlock{ Content: []ContentBlock{
@@ -63,6 +64,26 @@ func TestDetectState_ToolUseOther(t *testing.T) {
} }
} }
func TestDetectState_ToolUseStale(t *testing.T) {
// tool_use older than PermissionStallThreshold → permission prompt
msgs := []JSONLMessage{{
Type: "assistant",
Timestamp: "2026-03-23T12:00:10Z", // 20s before testNow
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 stale tool_use, got %v", state)
}
}
func TestDetectState_Progress(t *testing.T) { func TestDetectState_Progress(t *testing.T) {
msgs := []JSONLMessage{{ msgs := []JSONLMessage{{
Type: "progress", Type: "progress",