diff --git a/.planning/config.json b/.planning/config.json index 6f954d1..e480cca 100644 --- a/.planning/config.json +++ b/.planning/config.json @@ -25,7 +25,8 @@ "text_mode": false, "research_before_questions": false, "discuss_mode": "discuss", - "skip_discuss": false + "skip_discuss": false, + "_auto_chain_active": false }, "hooks": { "context_warnings": true diff --git a/.planning/phases/01-session-discovery/01-02-SUMMARY.md b/.planning/phases/01-session-discovery/01-02-SUMMARY.md new file mode 100644 index 0000000..24c4f63 --- /dev/null +++ b/.planning/phases/01-session-discovery/01-02-SUMMARY.md @@ -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. diff --git a/daemon.go b/daemon.go index 9698799..7dd0d6b 100644 --- a/daemon.go +++ b/daemon.go @@ -333,13 +333,19 @@ func (d *Daemon) handleConnection(conn net.Conn) { writeResponse(conn, Response{Error: "invalid label args: " + err.Error()}) 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()}) return } - // Update registry with new label 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 } d.registry.mu.Unlock() diff --git a/state.go b/state.go index 2da6fd7..6993167 100644 --- a/state.go +++ b/state.go @@ -7,6 +7,10 @@ import ( 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 { @@ -33,6 +37,13 @@ func DetectState(messages []JSONLMessage, now time.Time) SessionState { 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 } } diff --git a/state_test.go b/state_test.go index 34d96e3..66ce4ba 100644 --- a/state_test.go +++ b/state_test.go @@ -45,9 +45,10 @@ func TestDetectState_ToolUseAskUser(t *testing.T) { } func TestDetectState_ToolUseOther(t *testing.T) { + // Recent tool_use (< PermissionStallThreshold) → Working msgs := []JSONLMessage{{ Type: "assistant", - Timestamp: "2026-03-23T12:00:00Z", + Timestamp: "2026-03-23T12:00:25Z", // 5s before testNow Message: &MessagePayload{ Role: "assistant", 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) { msgs := []JSONLMessage{{ Type: "progress",