Files
vmux/.planning/phases/01-session-discovery/01-02-PLAN.md
2026-03-23 13:09:19 +01:00

17 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
01-session-discovery 02 execute 2
01-01
session.go
session_test.go
state.go
state_test.go
display.go
display_test.go
main.go
true
DISC-03
STATE-01
STATE-02
truths artifacts key_links
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
path provides exports
session.go Matching PID->JSONL, lecture metadonnees JSONL
FindSessionForProcess
TailReadJSONL
path provides exports
state.go Heuristique d'etat basee sur le dernier message JSONL
DetectState
ExtractPreview
path provides exports
display.go Formatage et affichage CLI avec couleurs
DisplaySessions
path provides contains
main.go Point d'entree CLI vmux list func main
from to via pattern
session.go ~/.claude/projects/<encoded-cwd>/*.jsonl EncodePath + filepath.Glob EncodePath.*Glob
from to via pattern
state.go session.go TailReadJSONL fournit les messages au DetectState DetectState.*JSONLMessage
from to via pattern
main.go proc.go + session.go + state.go + display.go Pipeline FindClaudeProcesses -> FindSessionForProcess -> DetectState -> DisplaySessions 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.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.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:

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:

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 :

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/<encoded>/ (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 :

{"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) :

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) :

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 ./..." <acceptance_criteria> - 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) </acceptance_criteria> 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

<success_criteria>

  • 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) </success_criteria>
After completion, create `.planning/phases/01-session-discovery/01-02-SUMMARY.md`