docs(02): research daemon et i3 bridge phase domain
This commit is contained in:
637
.planning/phases/02-daemon-et-i3-bridge/02-RESEARCH.md
Normal file
637
.planning/phases/02-daemon-et-i3-bridge/02-RESEARCH.md
Normal file
@@ -0,0 +1,637 @@
|
||||
# Phase 2: Daemon et i3 Bridge - Research
|
||||
|
||||
**Researched:** 2026-03-23
|
||||
**Domain:** Go daemon (Unix socket IPC) + i3 IPC + X11 PID resolution
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Summary
|
||||
|
||||
La Phase 2 transforme vmux d'un CLI one-shot en un daemon persistant (vmuxd) qui maintient un registre des sessions a jour. Le daemon communique avec le CLI via un Unix socket JSON. L'integration i3 permet de mapper chaque session Claude Code a son workspace et de switcher en une action.
|
||||
|
||||
**Decouverte critique :** i3 GetTree() ne contient PAS de champ PID dans ses nodes. Le contexte (D-04) suppose que "i3 GetTree() donne le PID de chaque fenetre terminal", ce qui est faux. Le champ `Window` (X11 window ID) est present, et il faut lire `_NET_WM_PID` via X11 pour obtenir le PID du terminal. La bonne nouvelle : `go.i3wm.org/i3/v4` depend deja de `BurntSushi/xgbutil` qui expose `ewmh.WmPidGet()`. Aucune dependance supplementaire.
|
||||
|
||||
L'algorithme de mapping est : i3 GetTree() -> Node.Window -> `ewmh.WmPidGet()` -> terminal PID -> walk up /proc PPID chain depuis chaque Claude PID jusqu'a trouver un terminal PID connu.
|
||||
|
||||
**Recommandation principale :** Utiliser `go.i3wm.org/i3/v4` + `BurntSushi/xgbutil/ewmh` (deja en transitive dep) pour le mapping PID-workspace. Pas de shell-out vers xdotool/xprop.
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
- **D-01:** vmuxd communique avec le CLI via un Unix socket (`~/.vmux/vmux.sock`). Protocole JSON sur le socket.
|
||||
- **D-02:** vmuxd poll /proc + JSONL toutes les 5 secondes pour mettre a jour le registre des sessions. La Phase 3 (hooks) reduira la latence a < 1s.
|
||||
- **D-03:** Autostart : `vmux list` (ou tout autre commande) demarre vmuxd automatiquement s'il ne tourne pas. `vmux stop` pour arreter proprement.
|
||||
- **D-04:** Mapping session -> workspace via le PID. i3 GetTree() donne le PID de chaque fenetre terminal ; on croise avec le PID du processus Claude Code pour trouver le workspace. **NOTE RECHERCHE : i3 GetTree() n'a PAS de champ PID. Utiliser Node.Window + _NET_WM_PID via X11. Meme resultat final.**
|
||||
- **D-05:** Si plusieurs sessions Claude Code sont dans le meme workspace i3, elles sont toutes listees avec le meme workspace.
|
||||
- **D-06:** Utiliser `go.i3wm.org/i3/v4` (lib officielle Go pour i3 IPC). Premiere dependance externe du projet. API : GetTree, GetWorkspaces, RunCommand.
|
||||
- **D-07:** `vmux switch` utilise du fuzzy match : `vmux switch auth` matche la premiere session contenant "auth" dans branche git, label ou cwd.
|
||||
- **D-08:** `vmux label <session> "texte"` attribue un label humain. Le label est stocke en memoire dans vmuxd (persiste sur disque dans `~/.vmux/labels.json` pour survivre aux redemarrages).
|
||||
- **D-09:** Le daemon track les transitions d'etat. Quand une session passe a NeedsInput, il enregistre le timestamp. `vmux list` affiche "depuis X min".
|
||||
|
||||
### Claude's Discretion
|
||||
- Format de communication JSON sur le Unix socket (structure des requetes/reponses)
|
||||
- Gestion du PID file pour le daemon
|
||||
- Gestion des erreurs de connexion i3 IPC (fallback gracieux si i3 non disponible)
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
None
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| DISC-04 | vmux permet d'associer un label humain a une session | Labels stockes en memoire + persistes dans `~/.vmux/labels.json`. Protocole socket : requete `label` avec session_id + texte. |
|
||||
| I3-01 | vmux mappe chaque session a son workspace i3 | Algorithme : i3 GetTree -> Node.Window -> ewmh.WmPidGet -> terminal PID -> PPID chain matching. Verifie sur machine : 6 Claude PIDs correctement mappes a leurs workspaces. |
|
||||
| I3-02 | vmux permet de switcher vers le workspace i3 d'une session en une action | `i3.RunCommand("workspace number N")` via go.i3wm.org/i3/v4. Fuzzy match sur label/branche/cwd pour trouver la session. |
|
||||
| STATE-04 | vmux affiche le temps ecoule depuis que la session attend | Daemon track les transitions d'etat avec timestamp. Quand state passe a NeedsInput, enregistre `WaitingSince`. Affichage "depuis X min" calcule par le CLI. |
|
||||
</phase_requirements>
|
||||
|
||||
## Project Constraints (from CLAUDE.md)
|
||||
|
||||
- NixOS : dependances via `nix-shell`
|
||||
- Code simple, expressif, bien concu
|
||||
- Commentaires uniquement pour le "Pourquoi"
|
||||
- TDD Chicago School
|
||||
- Ne jamais modifier les .env
|
||||
- Francais avec accents dans les communications
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| Go | 1.25.7 | Langage | Deja utilise en Phase 1. Disponible via `nix-shell -p go`. |
|
||||
| go.i3wm.org/i3/v4 | v4.24.0 | i3 IPC (GetTree, GetWorkspaces, RunCommand) | Lib officielle Go pour i3. Couvre 100% du protocole IPC. Retry transparent sur restart i3. |
|
||||
| BurntSushi/xgbutil/ewmh | v0.0.0-20190907113008 | Lecture _NET_WM_PID des fenetres X11 | Dependance transitive de go.i3wm.org/i3/v4. Pas d'ajout explicite. `ewmh.WmPidGet(xu, win)` retourne le PID. |
|
||||
| Go stdlib (net, encoding/json, os, sync) | - | Unix socket, JSON protocol, /proc, concurrence | Daemon et IPC ne necessitent aucune lib externe. |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| BurntSushi/xgb | v0.0.0-20210121224620 | Connexion X11 bas niveau | Dependance transitive. Necessaire pour initialiser xgbutil. |
|
||||
|
||||
### Alternatives Considered
|
||||
| Instead of | Could Use | Tradeoff |
|
||||
|------------|-----------|----------|
|
||||
| BurntSushi/xgbutil pour _NET_WM_PID | exec xdotool | 37ms pour 14 fenetres, acceptable mais shell-out inutile puisque xgbutil est deja en dep transitive |
|
||||
| stdlib net (Unix socket) | gRPC / protobuf | Over-engineered pour un daemon local avec 4 requetes |
|
||||
| stdlib flag avec sous-commandes manuelles | cobra | Over-engineered, 4 sous-commandes max |
|
||||
| PID file maison | github.com/nightlyone/lockfile | Trop simple pour justifier une dep (un os.WriteFile + flock) |
|
||||
|
||||
**Installation:**
|
||||
```bash
|
||||
nix-shell -p go --run "go get go.i3wm.org/i3/v4@v4.24.0"
|
||||
```
|
||||
|
||||
**Version verification:** `go.i3wm.org/i3/v4 v4.24.0` correspond a i3 4.24 installe sur la machine. `BurntSushi/xgb` et `xgbutil` sont des dependances transitives, pas d'ajout explicite dans go.mod.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
```
|
||||
vmux/
|
||||
├── main.go # Point d'entree CLI, dispatch sous-commandes, autostart daemon
|
||||
├── daemon.go # vmuxd : boucle de poll, registre en memoire, Unix socket server
|
||||
├── daemon_test.go
|
||||
├── client.go # Client Unix socket (CLI -> daemon)
|
||||
├── client_test.go
|
||||
├── protocol.go # Types requete/reponse JSON du socket
|
||||
├── protocol_test.go
|
||||
├── i3bridge.go # Integration i3 : GetTree, PID resolution, RunCommand
|
||||
├── i3bridge_test.go
|
||||
├── workspace.go # Mapping PID -> workspace via PPID chain + _NET_WM_PID
|
||||
├── workspace_test.go
|
||||
├── proc.go # (Phase 1, inchange)
|
||||
├── proc_test.go
|
||||
├── session.go # (Phase 1, inchange)
|
||||
├── session_test.go
|
||||
├── state.go # (Phase 1, inchange)
|
||||
├── state_test.go
|
||||
├── display.go # Etendu : workspace, label, temps d'attente
|
||||
├── display_test.go
|
||||
├── types.go # Enrichi : Workspace, Label, WaitingSince dans Session
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
└── shell.nix
|
||||
```
|
||||
|
||||
### Pattern 1: Daemon avec Unix Socket
|
||||
**What:** vmuxd ecoute sur `~/.vmux/vmux.sock`, recoit des requetes JSON, repond avec le registre.
|
||||
**When to use:** Toutes les commandes CLI (`list`, `switch`, `label`, `stop`).
|
||||
|
||||
```go
|
||||
// Protocole JSON simple : une requete = un objet JSON, une reponse = un objet JSON
|
||||
// Delimiter : chaque message termine par \n (JSON Lines sur le socket)
|
||||
|
||||
type Request struct {
|
||||
Action string `json:"action"` // "list", "switch", "label", "stop"
|
||||
Args json.RawMessage `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Sessions []SessionInfo `json:"sessions,omitempty"`
|
||||
}
|
||||
|
||||
type SessionInfo struct {
|
||||
PID int `json:"pid"`
|
||||
SessionID string `json:"session_id"`
|
||||
Cwd string `json:"cwd"`
|
||||
GitBranch string `json:"git_branch"`
|
||||
State string `json:"state"`
|
||||
Preview string `json:"preview"`
|
||||
Workspace string `json:"workspace"`
|
||||
Label string `json:"label,omitempty"`
|
||||
WaitingSince *time.Time `json:"waiting_since,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Autostart Daemon
|
||||
**What:** Le CLI tente de se connecter au socket. Si echec, il lance vmuxd en arriere-plan puis re-essaie.
|
||||
**When to use:** A chaque commande CLI.
|
||||
|
||||
```go
|
||||
func ensureDaemon(sockPath string) error {
|
||||
conn, err := net.DialTimeout("unix", sockPath, 500*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return nil // daemon deja actif
|
||||
}
|
||||
// Lancer le daemon en background
|
||||
exe, _ := os.Executable()
|
||||
cmd := exec.Command(exe, "daemon")
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} // detacher du terminal
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("cannot start daemon: %w", err)
|
||||
}
|
||||
// Attendre que le socket soit pret (retry avec backoff)
|
||||
for i := 0; i < 20; i++ {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
conn, err := net.DialTimeout("unix", sockPath, 200*time.Millisecond)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("daemon did not start in time")
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: PID -> Workspace Resolution (CORRIGE vs D-04)
|
||||
**What:** Mapper un PID Claude Code a son workspace i3.
|
||||
**Algorithme verifie sur la machine :**
|
||||
|
||||
```
|
||||
1. i3.GetTree() -> arbre de nodes
|
||||
2. Pour chaque node avec Window > 0 :
|
||||
a. ewmh.WmPidGet(xu, xproto.Window(node.Window)) -> terminal PID
|
||||
b. Stocker dans map[terminalPID]workspaceName
|
||||
3. Pour chaque Claude PID :
|
||||
a. Lire /proc/PID/status -> PPid
|
||||
b. Remonter la chaine PPID jusqu'a trouver un terminalPID dans la map
|
||||
c. Le workspace correspondant est le workspace de la session
|
||||
```
|
||||
|
||||
**Verification reelle sur la machine :**
|
||||
| Claude PID | Terminal PID (ancestor) | Workspace |
|
||||
|------------|------------------------|-----------|
|
||||
| 10466 | 10138 | 1 |
|
||||
| 13736 | 11864 | 10 |
|
||||
| 148021 | 147724 | 2 |
|
||||
| 16030 | 15281 | 10 |
|
||||
| 179544 | 178345 | 3 |
|
||||
| 87142 | 86863 | 3 |
|
||||
|
||||
Chaine typique : `claude -> zsh -> .sakura-wrapped -> i3`
|
||||
|
||||
```go
|
||||
func resolveWorkspace(claudePID int, terminalWorkspaces map[int]string) string {
|
||||
pid := claudePID
|
||||
for i := 0; i < 20; i++ { // max 20 ancestors (securite)
|
||||
if ws, ok := terminalWorkspaces[pid]; ok {
|
||||
return ws
|
||||
}
|
||||
ppid, err := readPPID(pid)
|
||||
if err != nil || ppid <= 1 {
|
||||
return "" // pas de workspace trouve
|
||||
}
|
||||
pid = ppid
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func readPPID(pid int) (int, error) {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
|
||||
// Parse "PPid:\t<number>" line
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Workspace Switch via i3 IPC
|
||||
**What:** Basculer vers le workspace d'une session.
|
||||
|
||||
```go
|
||||
func switchToWorkspace(wsName string) error {
|
||||
results, err := i3.RunCommand(fmt.Sprintf("workspace number %s", wsName))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(results) > 0 && !results[0].Success {
|
||||
return fmt.Errorf("i3: %s", results[0].Error)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 5: Fuzzy Match pour Switch
|
||||
**What:** `vmux switch auth` matche la premiere session contenant "auth" dans label, branche, ou cwd.
|
||||
**Ordre de priorite :** label > branche git > dernier segment du cwd.
|
||||
|
||||
```go
|
||||
func fuzzyMatch(query string, sessions []SessionInfo) *SessionInfo {
|
||||
query = strings.ToLower(query)
|
||||
// Priorite 1: label contient query
|
||||
for _, s := range sessions {
|
||||
if strings.Contains(strings.ToLower(s.Label), query) {
|
||||
return &s
|
||||
}
|
||||
}
|
||||
// Priorite 2: branche git contient query
|
||||
for _, s := range sessions {
|
||||
if strings.Contains(strings.ToLower(s.GitBranch), query) {
|
||||
return &s
|
||||
}
|
||||
}
|
||||
// Priorite 3: cwd contient query
|
||||
for _, s := range sessions {
|
||||
if strings.Contains(strings.ToLower(s.Cwd), query) {
|
||||
return &s
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 6: Labels Persistence
|
||||
**What:** Labels persistes dans `~/.vmux/labels.json`.
|
||||
|
||||
```go
|
||||
// Format: map[sessionID]string
|
||||
type LabelStore struct {
|
||||
mu sync.RWMutex
|
||||
labels map[string]string
|
||||
path string
|
||||
}
|
||||
|
||||
func (ls *LabelStore) Set(sessionID, label string) error {
|
||||
ls.mu.Lock()
|
||||
defer ls.mu.Unlock()
|
||||
ls.labels[sessionID] = label
|
||||
return ls.save()
|
||||
}
|
||||
|
||||
func (ls *LabelStore) save() error {
|
||||
data, _ := json.MarshalIndent(ls.labels, "", " ")
|
||||
return os.WriteFile(ls.path, data, 0o644)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 7: Tracking des Transitions d'Etat
|
||||
**What:** Le daemon enregistre le timestamp quand une session passe a NeedsInput.
|
||||
|
||||
```go
|
||||
type SessionRegistry struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[string]*TrackedSession // key = sessionID
|
||||
}
|
||||
|
||||
type TrackedSession struct {
|
||||
SessionInfo
|
||||
PrevState string
|
||||
WaitingSince time.Time // set quand state passe a NeedsInput
|
||||
}
|
||||
|
||||
func (r *SessionRegistry) Update(info SessionInfo) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
existing, ok := r.sessions[info.SessionID]
|
||||
if !ok {
|
||||
r.sessions[info.SessionID] = &TrackedSession{SessionInfo: info}
|
||||
return
|
||||
}
|
||||
if info.State == "Needs Input" && existing.PrevState != "Needs Input" {
|
||||
existing.WaitingSince = time.Now()
|
||||
}
|
||||
existing.PrevState = existing.State
|
||||
existing.SessionInfo = info
|
||||
if info.State == "Needs Input" {
|
||||
info.WaitingSince = &existing.WaitingSince
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **i3 GetTree() sans _NET_WM_PID :** Le Node i3 n'a PAS de champ PID. Il faut passer par X11 pour resoudre Window -> PID.
|
||||
- **Relancer le daemon a chaque commande :** Utiliser autostart (connect, si echec start, re-connect).
|
||||
- **Socket HTTP/REST :** Over-engineered. JSON Lines sur Unix socket suffit (une ligne = une requete, une ligne = une reponse).
|
||||
- **Polling i3 GetTree a chaque scan :** Coupler le scan /proc avec le scan i3 dans la meme boucle de 5s pour avoir une vue coherente.
|
||||
- **Stocker les labels par PID :** Les PID changent a chaque redemarrage de Claude Code. Utiliser le sessionID (UUID stable).
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| i3 IPC protocol | Parser binaire i3 IPC | `go.i3wm.org/i3/v4` | Protocole binaire complexe avec magic bytes, types, longueurs. Retry transparent integre. |
|
||||
| X11 window properties | Shell-out vers xprop/xdotool | `BurntSushi/xgbutil/ewmh.WmPidGet()` | Deja en dep transitive. Pure Go, pas de fork/exec. |
|
||||
| Process tree walking | Lib procfs | `os.ReadFile("/proc/PID/status")` | Une seule ligne a parser (PPid). Trivial. |
|
||||
| JSON encoding/decoding | Lib serialisation | `encoding/json` stdlib | JSON Lines est trivial. |
|
||||
| CLI sub-commands | Framework CLI (cobra) | `os.Args` manual dispatch | 5 sous-commandes max (`list`, `switch`, `label`, `stop`, `daemon`). Un switch/case suffit. |
|
||||
|
||||
**Insight :** La seule vraie complexite externe est i3 IPC (protocole binaire) et X11 (protocole bas niveau). Les deux sont couverts par les dependances de `go.i3wm.org/i3/v4`.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: i3 Node n'a pas de PID
|
||||
**What goes wrong:** Le code suppose que `Node.PID` existe (comme dit dans D-04) et panic/compile error.
|
||||
**Why it happens:** Confusion courante. i3 ne stocke pas le PID dans l'arbre. Le PID est une propriete X11 du client window.
|
||||
**How to avoid:** Utiliser `Node.Window` (X11 window ID) + `ewmh.WmPidGet()` pour obtenir le terminal PID.
|
||||
**Warning signs:** Champ inexistant dans la struct Node du Go package.
|
||||
|
||||
### Pitfall 2: Socket file residuel apres crash
|
||||
**What goes wrong:** vmuxd ne demarre pas car `~/.vmux/vmux.sock` existe deja (bind: address already in use).
|
||||
**Why it happens:** Crash ou kill -9 sans cleanup.
|
||||
**How to avoid:** Avant `net.Listen("unix", path)`, tenter un `net.Dial("unix", path)`. Si echec -> supprimer le fichier stale. Si succes -> daemon deja actif.
|
||||
**Warning signs:** "address already in use" au demarrage.
|
||||
|
||||
### Pitfall 3: Race condition daemon/CLI
|
||||
**What goes wrong:** Le CLI envoie une requete avant que le daemon ait fini son premier scan.
|
||||
**Why it happens:** Autostart : le daemon vient de demarrer, le registre est vide.
|
||||
**How to avoid:** Le daemon fait un scan synchrone AVANT d'ecouter sur le socket. Le CLI attend que le socket soit pret (retry 50ms x 20).
|
||||
**Warning signs:** `vmux list` retourne 0 sessions juste apres le premier lancement.
|
||||
|
||||
### Pitfall 4: i3 non disponible (SSH, TTY, Wayland)
|
||||
**What goes wrong:** `i3.GetTree()` echoue car i3 IPC n'est pas accessible.
|
||||
**Why it happens:** vmux est execute dans un environnement sans i3 (SSH, console, Sway/Wayland).
|
||||
**How to avoid:** Attraper l'erreur de `GetTree()`, mettre workspace="" pour toutes les sessions. `vmux list` fonctionne toujours, sans colonne workspace. `vmux switch` retourne une erreur claire.
|
||||
**Warning signs:** Erreur au premier scan.
|
||||
|
||||
### Pitfall 5: xgbutil necessita DISPLAY
|
||||
**What goes wrong:** `xgbutil.NewConn()` echoue si `$DISPLAY` n'est pas set.
|
||||
**Why it happens:** Meme situation que pitfall 4 : pas de X11.
|
||||
**How to avoid:** Verifier `$DISPLAY` avant d'initialiser xgbutil. Si absent, desactiver le mapping workspace (fallback gracieux).
|
||||
**Warning signs:** Erreur "cannot open display" au demarrage du daemon.
|
||||
|
||||
### Pitfall 6: Labels orphelins dans labels.json
|
||||
**What goes wrong:** labels.json grossit indefiniment avec des sessionIDs de sessions terminees.
|
||||
**Why it happens:** Les sessions Claude Code sont ephemeres, les sessionIDs changent.
|
||||
**How to avoid:** Au chargement de labels.json, ne garder que les labels dont le sessionID correspond a une session active. Ou purger periodiquement (ex: garder les 100 derniers).
|
||||
**Warning signs:** labels.json de plusieurs Mo apres des semaines d'utilisation.
|
||||
|
||||
### Pitfall 7: PPID chain walk infinie
|
||||
**What goes wrong:** La boucle de walk PPID ne termine pas (process circulaire theorique).
|
||||
**Why it happens:** Bug kernel theorique ou processus zombie.
|
||||
**How to avoid:** Limiter a 20 iterations. PID 1 (init) est toujours le point d'arret.
|
||||
**Warning signs:** CPU spike pendant le scan.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Initialisation X11 pour _NET_WM_PID
|
||||
```go
|
||||
// Source: BurntSushi/xgbutil/ewmh package (dep transitive de go.i3wm.org/i3/v4)
|
||||
import (
|
||||
"github.com/BurntSushi/xgb/xproto"
|
||||
"github.com/BurntSushi/xgbutil"
|
||||
"github.com/BurntSushi/xgbutil/ewmh"
|
||||
"go.i3wm.org/i3/v4"
|
||||
)
|
||||
|
||||
func buildTerminalWorkspaceMap() (map[int]string, error) {
|
||||
xu, err := xgbutil.NewConn()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("X11 connection failed: %w", err)
|
||||
}
|
||||
defer xu.Conn().Close()
|
||||
|
||||
tree, err := i3.GetTree()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("i3 GetTree failed: %w", err)
|
||||
}
|
||||
|
||||
result := make(map[int]string)
|
||||
var walk func(node *i3.Node, wsName string)
|
||||
walk = func(node *i3.Node, wsName string) {
|
||||
if node.Type == i3.WorkspaceNode {
|
||||
wsName = node.Name
|
||||
}
|
||||
if node.Window > 0 {
|
||||
pid, err := ewmh.WmPidGet(xu, xproto.Window(node.Window))
|
||||
if err == nil && pid > 0 {
|
||||
result[int(pid)] = wsName
|
||||
}
|
||||
}
|
||||
for _, child := range node.Nodes {
|
||||
walk(child, wsName)
|
||||
}
|
||||
for _, child := range node.FloatingNodes {
|
||||
walk(child, wsName)
|
||||
}
|
||||
}
|
||||
walk(tree.Root, "")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Lecture du PPID depuis /proc
|
||||
```go
|
||||
// Source: verification directe sur /proc/<PID>/status
|
||||
func readPPID(pid int) (int, error) {
|
||||
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if strings.HasPrefix(line, "PPid:") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) >= 2 {
|
||||
return strconv.Atoi(fields[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("PPid not found for PID %d", pid)
|
||||
}
|
||||
```
|
||||
|
||||
### Unix Socket Server (daemon)
|
||||
```go
|
||||
// Source: Go stdlib net package
|
||||
func startDaemonSocket(sockPath string) (net.Listener, error) {
|
||||
// Cleanup stale socket
|
||||
if _, err := net.DialTimeout("unix", sockPath, 200*time.Millisecond); err != nil {
|
||||
os.Remove(sockPath) // stale, supprimer
|
||||
}
|
||||
|
||||
os.MkdirAll(filepath.Dir(sockPath), 0o700)
|
||||
return net.Listen("unix", sockPath)
|
||||
}
|
||||
|
||||
func serveDaemon(listener net.Listener, registry *SessionRegistry) {
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
return // listener ferme
|
||||
}
|
||||
go handleConnection(conn, registry)
|
||||
}
|
||||
}
|
||||
|
||||
func handleConnection(conn net.Conn, registry *SessionRegistry) {
|
||||
defer conn.Close()
|
||||
decoder := json.NewDecoder(conn)
|
||||
encoder := json.NewEncoder(conn)
|
||||
|
||||
var req Request
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
switch req.Action {
|
||||
case "list":
|
||||
sessions := registry.List()
|
||||
encoder.Encode(Response{OK: true, Sessions: sessions})
|
||||
case "switch":
|
||||
// ... fuzzy match + i3.RunCommand
|
||||
case "label":
|
||||
// ... set label
|
||||
case "stop":
|
||||
encoder.Encode(Response{OK: true})
|
||||
// signal shutdown
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Switch workspace via i3
|
||||
```go
|
||||
// Source: go.i3wm.org/i3/v4 documentation (pkg.go.dev)
|
||||
func switchToWorkspace(wsName string) error {
|
||||
cmd := fmt.Sprintf("workspace number %s", wsName)
|
||||
results, err := i3.RunCommand(cmd)
|
||||
if err != nil {
|
||||
if i3.IsUnsuccessful(err) {
|
||||
return fmt.Errorf("i3 switch failed: %v", results[0].Error)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| BurntSushi/xgb (original) | jezek/xgb (maintained fork) | 2021 | jezek/xgb est le fork actif. Mais go.i3wm.org/i3/v4 utilise encore BurntSushi/xgb. |
|
||||
| i3 IPC v4.0 (basique) | i3 IPC v4.24 (complet) | 2024 | Memes APIs core. GetTree, GetWorkspaces, RunCommand stables depuis des annees. |
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go testing (stdlib) |
|
||||
| Config file | Aucun (convention Go par defaut) |
|
||||
| Quick run command | `go test ./...` |
|
||||
| Full suite command | `go test -v -race ./...` |
|
||||
|
||||
### Phase Requirements -> Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| DISC-04 | Label humain sur une session | unit | `go test -run TestLabelStore -v` | Wave 0 |
|
||||
| I3-01 | Mapping session -> workspace i3 | unit | `go test -run TestResolveWorkspace -v` | Wave 0 |
|
||||
| I3-02 | Switch vers workspace i3 d'une session | unit+integration | `go test -run TestFuzzyMatch -v` | Wave 0 |
|
||||
| STATE-04 | Temps ecoule depuis attente | unit | `go test -run TestWaitingSince -v` | Wave 0 |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `go test ./...`
|
||||
- **Per wave merge:** `go test -v -race ./...`
|
||||
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `daemon_test.go` -- poll loop, registre, autostart logic (testable avec mock socket)
|
||||
- [ ] `client_test.go` -- requetes/reponses sur socket (testable avec mock server)
|
||||
- [ ] `protocol_test.go` -- serialisation JSON des requetes/reponses
|
||||
- [ ] `workspace_test.go` -- couvre I3-01 : PPID chain walk (testable avec fake /proc + mock i3 tree)
|
||||
- [ ] `i3bridge_test.go` -- couvre I3-02 : fuzzy match (pure logique, pas de mock)
|
||||
|
||||
### Strategy TDD
|
||||
Le daemon est testable en isolant les couches :
|
||||
- **Registre** : pure logique, pas de I/O. Tester les transitions d'etat, le tracking WaitingSince.
|
||||
- **Protocol** : serialisation/deserialisation JSON. Tester les request/response structs.
|
||||
- **Workspace resolution** : `readPPID` mockable via un fake /proc. Le mapping i3 mockable via une interface.
|
||||
- **Client/Server** : Tester avec un vrai Unix socket dans un tmpdir.
|
||||
- **i3 bridge** : Abstraire derriere une interface (`type I3Client interface { GetTree() ... }`). Mocker en test.
|
||||
|
||||
L'integration i3 reelle (GetTree, RunCommand) sera testee manuellement sur la machine. Les tests unitaires couvrent toute la logique sans dependre de i3.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | Build & run | oui | 1.25.7 (via nix-shell) | - |
|
||||
| i3 | Workspace mapping + switch | oui | 4.24 | Fallback gracieux : vmux fonctionne sans workspace si i3 absent |
|
||||
| X11 (DISPLAY) | _NET_WM_PID resolution | oui | X11 actif (i3 tourne) | Meme fallback : pas de workspace sans X11 |
|
||||
| /proc filesystem | Process detection + PPID chain | oui | procfs Linux 6.19 | - |
|
||||
| xdotool | Non utilise (pure Go via xgbutil) | oui | 3.20211022.1 | - |
|
||||
|
||||
**Missing dependencies with no fallback:** Aucune.
|
||||
|
||||
**Missing dependencies with fallback:**
|
||||
- i3 + X11 : si absents, vmux fonctionne sans les colonnes workspace et sans `vmux switch`. Toutes les autres fonctionnalites (list, label, temps d'attente) restent operationnelles.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Concurrence X11 + i3 IPC dans le daemon**
|
||||
- Ce qu'on sait : `go.i3wm.org/i3/v4` est concurrent-safe. `xgbutil` n'est PAS concurrent-safe explicitement.
|
||||
- Ce qui est flou : Le daemon poll dans une goroutine et repond aux requetes dans d'autres. La connexion X11 doit-elle etre re-ouverte a chaque scan ou partagee ?
|
||||
- Recommandation : Ouvrir la connexion X11 une fois au demarrage du daemon. N'utiliser `ewmh.WmPidGet` que dans la goroutine de scan (jamais depuis les handlers de requetes). Le scan produit une snapshot `map[int]string` atomiquement remplacee via `sync.RWMutex`.
|
||||
|
||||
2. **Sous-commande `daemon` visible ou cachee ?**
|
||||
- Ce qu'on sait : D-03 dit autostart implicite.
|
||||
- Ce qui est flou : L'utilisateur peut-il lancer `vmux daemon` manuellement ?
|
||||
- Recommandation : Sous-commande cachee (non affichee dans l'aide). Utile pour le debug (`vmux daemon --foreground`).
|
||||
|
||||
3. **Nettoyage des sessions disparues**
|
||||
- Ce qu'on sait : Le daemon poll toutes les 5s. Si un processus Claude disparait, il ne sera plus dans /proc.
|
||||
- Ce qui est flou : Faut-il garder l'entree quelques secondes pour eviter le flicker ?
|
||||
- Recommandation : Supprimer immediatement du registre. Si le PID n'est plus dans /proc, la session est terminee.
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- [go.i3wm.org/i3/v4 pkg.go.dev](https://pkg.go.dev/go.i3wm.org/i3/v4) - API complete : Node struct (pas de PID), GetTree, GetWorkspaces, RunCommand, Subscribe
|
||||
- [BurntSushi/xgbutil/ewmh source](https://github.com/BurntSushi/xgbutil/blob/master/ewmh/ewmh.go) - `WmPidGet(xu, win)` pour _NET_WM_PID
|
||||
- Inspection directe machine : 6 Claude PIDs mappes a leurs workspaces via PPID chain
|
||||
- i3-msg -t get_tree : confirme absence du champ `pid` dans les nodes (14 windows verifiees)
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- [i3 IPC documentation](https://i3wm.org/docs/ipc.html) - Protocole IPC officiel
|
||||
- [Go Unix Domain Sockets (Eli Bendersky)](https://eli.thegreenplace.net/2019/unix-domain-sockets-in-go/) - Pattern daemon Go
|
||||
- [go-i3 GitHub](https://github.com/i3/go-i3) - Repo officiel de la lib Go i3
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- Comportement de `xgbutil` en multi-thread : documentation limitee, recommandation basee sur les bonnes pratiques Go (single writer)
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH - go.i3wm.org/i3/v4 v4.24.0 verifie, BurntSushi/xgbutil confirme en dep transitive
|
||||
- Architecture: HIGH - Algorithme PID->workspace verifie sur 6 sessions reelles
|
||||
- Pitfalls: HIGH - Absence de PID dans Node i3 decouverte par inspection directe, pas par supposition
|
||||
- Protocol: MEDIUM - Design du protocole socket base sur des patterns Go standard, pas de source specifique
|
||||
|
||||
**Research date:** 2026-03-23
|
||||
**Valid until:** 2026-04-23 (i3 IPC stable depuis des annees, /proc stable)
|
||||
Reference in New Issue
Block a user