--- phase: 01-session-discovery plan: 02 type: execute wave: 2 depends_on: - 01-01 files_modified: - session.go - session_test.go - state.go - state_test.go - display.go - display_test.go - main.go autonomous: true requirements: - DISC-03 - STATE-01 - STATE-02 must_haves: truths: - "vmux affiche la branche git de chaque session" - "vmux detecte si une session travaille, attend input ou est idle" - "vmux affiche un apercu des dernieres lignes de sortie" - "vmux list affiche toutes les sessions avec leur etat, cwd, branche et apercu" artifacts: - path: "session.go" provides: "Matching PID->JSONL, lecture metadonnees JSONL" exports: ["FindSessionForProcess", "TailReadJSONL"] - path: "state.go" provides: "Heuristique d'etat basee sur le dernier message JSONL" exports: ["DetectState", "ExtractPreview"] - path: "display.go" provides: "Formatage et affichage CLI avec couleurs" exports: ["DisplaySessions"] - path: "main.go" provides: "Point d'entree CLI vmux list" contains: "func main" key_links: - from: "session.go" to: "~/.claude/projects//*.jsonl" via: "EncodePath + filepath.Glob" pattern: "EncodePath.*Glob" - from: "state.go" to: "session.go" via: "TailReadJSONL fournit les messages au DetectState" pattern: "DetectState.*JSONLMessage" - from: "main.go" to: "proc.go + session.go + state.go + display.go" via: "Pipeline FindClaudeProcesses -> FindSessionForProcess -> DetectState -> DisplaySessions" pattern: "FindClaudeProcesses.*FindSession.*DetectState.*Display" --- Parsing JSONL, heuristique d'etat et CLI `vmux list` fonctionnel. Purpose: Completer le pipeline de detection : pour chaque processus Claude trouve en Plan 01, trouver le JSONL correspondant, determiner l'etat, extraire un apercu, et afficher le tout. Output: `vmux list` fonctionnel qui affiche toutes les sessions Claude Code avec etat, cwd, branche git et apercu. @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md @.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/01-session-discovery/01-CONTEXT.md @.planning/phases/01-session-discovery/01-RESEARCH.md @.planning/phases/01-session-discovery/01-01-SUMMARY.md From types.go: ```go package main type SessionState int const ( Working SessionState = iota NeedsInput Idle Unknown ) func (s SessionState) String() string type Process struct { PID int Cmd []string Cwd string } type Session struct { Process Process SessionID string GitBranch string State SessionState Preview string CwdPath string Worktree string } ``` From proc.go: ```go func FindClaudeProcesses(procDir string) ([]Process, error) func EncodePath(path string) string ``` Task 1: Matching PID->JSONL + tail-read + heuristique d'etat session.go, session_test.go, state.go, state_test.go types.go proc.go .planning/phases/01-session-discovery/01-RESEARCH.md - TestTailReadJSONL: fichier JSONL de 50 lignes, lire les 3 dernieres -> retourne exactement 3 messages JSON valides. - TestTailReadJSONL_TruncatedLastLine: JSONL dont la derniere ligne est incomplete (pas de \n final) -> ignore la ligne tronquee, retourne les lignes completes precedentes. - TestTailReadJSONL_EmptyFile: fichier vide -> retourne slice vide sans erreur. - TestTailReadJSONL_SmallFile: fichier de 2 lignes, demander 10 -> retourne les 2 lignes. - TestFindSessionForProcess: dossier ~/.claude/projects/ simule avec 2 fichiers JSONL, retourne celui avec le mtime le plus recent. - TestFindSessionForProcess_NoMatch: aucun dossier correspondant -> retourne erreur. - TestDetectState_EndTurnText: message assistant avec stop_reason="end_turn" et content type "text" -> NeedsInput. - TestDetectState_ToolUseAskUser: message assistant avec stop_reason="tool_use" et tool name "AskUserQuestion" -> NeedsInput. - TestDetectState_ToolUseOther: message assistant avec stop_reason="tool_use" et tool name "Read" -> Working. - TestDetectState_Progress: message type="progress" avec data.type="agent_progress" -> Working. - TestDetectState_ToolResult: message type="user" avec content type "tool_result" -> Working. - TestDetectState_IdleThreshold: message timestamp > 60s dans le passe -> Idle. - TestExtractPreview: message assistant avec content text "Voici le resultat..." -> retourne les 3 premieres lignes du texte. **session.go** (package main) : Structure intermediaire pour le parsing JSONL : ```go type JSONLMessage struct { Type string `json:"type"` // "assistant", "user", "progress", "system" Timestamp string `json:"timestamp"` // ISO 8601 SessionID string `json:"sessionId"` Cwd string `json:"cwd"` GitBranch string `json:"gitBranch"` Message *MessagePayload `json:"message,omitempty"` Data *ProgressData `json:"data,omitempty"` } type MessagePayload struct { Role string `json:"role"` Content []ContentBlock `json:"content"` StopReason string `json:"stop_reason"` } type ContentBlock struct { Type string `json:"type"` // "text", "tool_use", "tool_result" Text string `json:"text,omitempty"` Name string `json:"name,omitempty"` // pour tool_use : nom de l'outil } type ProgressData struct { Type string `json:"type"` // "agent_progress", "hook_progress" } ``` `TailReadJSONL(path string, n int) ([]JSONLMessage, error)` : - Ouvrir le fichier, `Seek(0, io.SeekEnd)` pour obtenir la taille. - Lire des blocs de 8192 octets en reculant depuis la fin. - Accumuler les lignes completes (terminees par `\n`). Ignorer la derniere ligne si elle ne se termine pas par `\n` (per pitfall 3 : race condition). - Arreter quand on a `n` lignes completes ou qu'on atteint le debut du fichier. - Parser chaque ligne en `JSONLMessage` via `json.Unmarshal`. Ignorer les lignes qui ne parsent pas (lignes vides, corruptions). - Retourner les messages dans l'ordre chronologique (le plus ancien en premier). `FindSessionForProcess(claudeDir string, proc Process) (string, []JSONLMessage, error)` : - `claudeDir` = chemin vers `~/.claude/projects/` (parametre pour testabilite). En production : `os.Getenv("HOME") + "/.claude/projects/"`. - Encoder `proc.Cwd` via `EncodePath(proc.Cwd)` pour obtenir le nom du dossier. - Lister les `*.jsonl` dans `claudeDir//` (PAS les sous-dossiers, per pitfall 5 : exclure subagents/). - Utiliser `filepath.Glob(filepath.Join(claudeDir, encoded, "*.jsonl"))` puis filtrer ceux qui sont dans un sous-dossier. - Trier par mtime (le plus recent en premier). Prendre le premier = session active (per RESEARCH Pattern 5). - Appeler `TailReadJSONL(path, 5)` sur le JSONL trouve. - Extraire `sessionId` et `gitBranch` depuis la premiere entree retournee. - Retourner le chemin JSONL, les messages, et nil. Si aucun JSONL trouve, retourner une erreur. **state.go** (package main) : `DetectState(messages []JSONLMessage, now time.Time) SessionState` : - Si `messages` est vide, retourner `Unknown`. - Prendre le dernier message (index len-1). - Si le timestamp du dernier message est > 60 secondes avant `now` -> `Idle` (const `IdleThreshold = 60 * time.Second`). - Si `type == "assistant"` et `message.stop_reason == "end_turn"` -> `NeedsInput` (per D-05 : Claude a fini, attend l'utilisateur). - Si `type == "assistant"` et `message.stop_reason == "tool_use"` : - Scanner `message.content` pour un `ContentBlock` de type `"tool_use"`. - Si `name == "AskUserQuestion"` -> `NeedsInput` (per D-05). - Sinon -> `Working` (outil en cours d'execution, per D-05). - Si `type == "progress"` -> `Working` (per D-05 : hook ou subagent en cours). - Si `type == "user"` et un ContentBlock a `type == "tool_result"` -> `Working` (resultat envoye, Claude va repondre). - Sinon -> `Unknown`. Le parametre `now time.Time` permet de tester le seuil Idle sans dependre de l'horloge. `ExtractPreview(messages []JSONLMessage) string` : - Parcourir les messages en ordre inverse pour trouver le dernier `type == "assistant"` avec un `ContentBlock` de type `"text"`. - Extraire le champ `text` de ce ContentBlock. - Tronquer a 3 lignes (split sur `\n`, prendre les 3 premieres, rejoin). - Si le texte depasse 200 caracteres, tronquer a 200 et ajouter "...". - Si aucun texte trouve, retourner "" (vide). **session_test.go** et **state_test.go** : Utiliser des fixtures JSONL dans des fichiers temporaires. Pour chaque test : - Creer un `t.TempDir()`. - Ecrire des lignes JSON valides representant les cas decrits dans behavior. - Les fixtures JSONL doivent reproduire le format exact documente dans RESEARCH (voir section "Code Examples"). Exemple de fixture pour un message assistant end_turn : ```json {"type":"assistant","timestamp":"2026-03-23T12:00:00Z","sessionId":"abc","cwd":"/tmp","gitBranch":"main","message":{"role":"assistant","content":[{"type":"text","text":"Done."}],"stop_reason":"end_turn"}} ``` cd /home/pierre/Code/vibe/vmux && nix-shell --run "go test -v -run 'TestTailRead|TestFindSession|TestDetectState|TestExtractPreview' ./..." - session.go contient `func TailReadJSONL(path string, n int) ([]JSONLMessage, error)` - session.go contient `func FindSessionForProcess(claudeDir string, proc Process) (string, []JSONLMessage, error)` - state.go contient `func DetectState(messages []JSONLMessage, now time.Time) SessionState` - state.go contient `func ExtractPreview(messages []JSONLMessage) string` - state.go contient `IdleThreshold` comme constante a 60 secondes - session_test.go contient `TestTailReadJSONL` et `TestFindSessionForProcess` - state_test.go contient `TestDetectState_EndTurnText`, `TestDetectState_ToolUseAskUser`, `TestDetectState_ToolUseOther`, `TestDetectState_IdleThreshold` - state_test.go contient `TestExtractPreview` - Tous les tests passent TailReadJSONL lit les N derniers messages sans charger le fichier entier. FindSessionForProcess fait le matching PID->JSONL. DetectState applique l'heuristique d'etat. ExtractPreview extrait un apercu. Tous les tests passent. Task 2: Affichage CLI + main.go + test d'integration display.go, display_test.go, main.go types.go proc.go session.go state.go **display.go** (package main) : Constantes ANSI (per D-07, pas de lib externe) : ```go const ( colorReset = "\033[0m" colorGreen = "\033[32m" // Working colorYellow = "\033[33m" // Needs Input colorRed = "\033[31m" // Needs Input (AskUserQuestion) colorGray = "\033[90m" // Idle / Unknown colorBold = "\033[1m" ) ``` `DisplaySessions(w io.Writer, sessions []Session, noColor bool)` : - Si `sessions` est vide, ecrire "No active Claude Code sessions found.\n" et retourner. - Pour chaque session, afficher un bloc : ``` [STATE] cwd (branch) Preview text here... ``` - Le `[STATE]` est colore selon l'etat (sauf si `noColor == true`) : - Working -> colorGreen + "Working" - NeedsInput -> colorYellow + "Needs Input" - Idle -> colorGray + "Idle" - Unknown -> colorGray + "Unknown" - Le `cwd` est le `Session.CwdPath`. Afficher aussi le `Worktree` si different du cwd. - Le `(branch)` est `Session.GitBranch`. Si vide, omettre les parentheses. - Le `Preview` est indente de 9 espaces (alignement avec le texte apres [STATE]). Chaque ligne du preview est prefixee de ces espaces. - Separer les sessions par une ligne vide. - Ecrire dans `w` (io.Writer) pour la testabilite. En production : `os.Stdout`. `stateColor(state SessionState, noColor bool) string` : helper qui retourne le code ANSI pour un etat, ou "" si noColor. **display_test.go** (package main) : - `TestDisplaySessions_WithSessions` : 2 sessions (Working + NeedsInput), verifier que la sortie contient les cwds, branches et etats. - `TestDisplaySessions_NoColor` : meme test avec noColor=true, verifier qu'aucun code ANSI n'est present (pas de `\033`). - `TestDisplaySessions_Empty` : sessions vide -> "No active Claude Code sessions found." Utiliser un `bytes.Buffer` comme io.Writer pour capturer la sortie. **main.go** (package main) : ```go func main() { noColor := flag.Bool("no-color", false, "Disable colored output") flag.Parse() // Aussi detecter NO_COLOR env var (convention standard) if os.Getenv("NO_COLOR") != "" { *noColor = true } args := flag.Args() if len(args) == 0 || args[0] != "list" { fmt.Fprintf(os.Stderr, "Usage: vmux list [--no-color]\n") os.Exit(1) } processes, err := FindClaudeProcesses("/proc") if err != nil { fmt.Fprintf(os.Stderr, "Error scanning processes: %v\n", err) os.Exit(1) } claudeDir := filepath.Join(os.Getenv("HOME"), ".claude", "projects") now := time.Now() var sessions []Session for _, proc := range processes { jsonlPath, messages, err := FindSessionForProcess(claudeDir, proc) if err != nil { // Processus sans JSONL -> afficher avec etat Unknown (per pitfall 4) sessions = append(sessions, Session{ Process: proc, State: Unknown, CwdPath: proc.Cwd, }) continue } state := DetectState(messages, now) preview := ExtractPreview(messages) // Extraire metadonnees du JSONL var sessionID, gitBranch, worktree string for _, msg := range messages { if msg.SessionID != "" { sessionID = msg.SessionID } if msg.GitBranch != "" { gitBranch = msg.GitBranch } } // Worktree = git rev-parse --show-toplevel depuis le cwd worktree = resolveWorktree(proc.Cwd) _ = jsonlPath // utilise pour le debug futur sessions = append(sessions, Session{ Process: proc, SessionID: sessionID, GitBranch: gitBranch, State: state, Preview: preview, CwdPath: proc.Cwd, Worktree: worktree, }) } DisplaySessions(os.Stdout, sessions, *noColor) } // resolveWorktree utilise git pour trouver le worktree. // Retourne le cwd si git echoue (pas un repo git). func resolveWorktree(cwd string) string { cmd := exec.Command("git", "-C", cwd, "rev-parse", "--show-toplevel") out, err := cmd.Output() if err != nil { return cwd } return strings.TrimSpace(string(out)) } ``` Imports necessaires : `flag`, `fmt`, `os`, `os/exec`, `path/filepath`, `strings`, `time`. cd /home/pierre/Code/vibe/vmux && nix-shell --run "go test -v ./... && go build -o vmux ./..." - display.go contient `func DisplaySessions(w io.Writer, sessions []Session, noColor bool)` - display.go contient les constantes ANSI colorGreen, colorYellow, colorGray - display_test.go contient `TestDisplaySessions_WithSessions` - display_test.go contient `TestDisplaySessions_NoColor` - display_test.go contient `TestDisplaySessions_Empty` - main.go contient `func main()` avec `flag.Parse()` et sous-commande "list" - main.go contient `FindClaudeProcesses("/proc")` - main.go contient `resolveWorktree` qui appelle `git rev-parse --show-toplevel` - main.go gere le flag `--no-color` et la variable `NO_COLOR` - `go build -o vmux ./...` produit un binaire vmux - `go test -v ./...` : tous les tests passent (proc, session, state, display) vmux list fonctionne : detecte les processus Claude, lit les JSONL, determine l'etat, affiche le resultat avec couleurs. Le binaire compile. Tous les tests passent. - `go test -v -race ./...` : tous les tests passent sans race condition - `go build -o vmux ./...` : le binaire compile - `./vmux list` : affiche les sessions Claude Code actives sur le poste (test manuel) - `./vmux list --no-color` : meme sortie sans codes ANSI - `vmux list` affiche toutes les sessions Claude Code actives (DISC-01) - Chaque session affiche son cwd et worktree git (DISC-02) - Chaque session affiche sa branche git (DISC-03) - Chaque session affiche son etat Working/Needs Input/Idle (STATE-01) - Chaque session affiche un apercu de la derniere sortie (STATE-02) - Couleurs actives par defaut, desactivables avec --no-color (D-07) After completion, create `.planning/phases/01-session-discovery/01-02-SUMMARY.md`