feat(02-01): implement protocol types, SessionRegistry, and LabelStore

- Enrich Session with Workspace, Label, WaitingSince fields
- Protocol types: Request, Response, SessionInfo, SwitchArgs, LabelArgs
- SessionRegistry with WaitingSince transition tracking
- LabelStore with JSON file persistence
This commit is contained in:
Pierre Martin
2026-03-23 17:43:12 +01:00
parent c9a28df3dc
commit 5315e88494
3 changed files with 196 additions and 7 deletions

141
daemon.go Normal file
View File

@@ -0,0 +1,141 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
// TrackedSession holds a session's info alongside state-transition tracking.
type TrackedSession struct {
Info SessionInfo
PrevState string
}
// SessionRegistry maintains an in-memory index of active sessions.
// It tracks WaitingSince transitions: when a session moves to "Needs Input",
// the timestamp is recorded; when it leaves that state, it is cleared.
type SessionRegistry struct {
mu sync.RWMutex
sessions map[string]*TrackedSession
}
func NewRegistry() *SessionRegistry {
return &SessionRegistry{
sessions: make(map[string]*TrackedSession),
}
}
// Update adds or refreshes a session in the registry.
// WaitingSince is set when transitioning to "Needs Input" and cleared otherwise.
func (r *SessionRegistry) Update(info SessionInfo) {
r.mu.Lock()
defer r.mu.Unlock()
existing, ok := r.sessions[info.SessionID]
if !ok {
existing = &TrackedSession{}
r.sessions[info.SessionID] = existing
}
isWaiting := info.State == "Needs Input"
wasWaiting := existing.PrevState == "Needs Input"
if isWaiting && !wasWaiting {
now := time.Now()
info.WaitingSince = &now
} else if isWaiting && wasWaiting {
// Keep existing timestamp
info.WaitingSince = existing.Info.WaitingSince
}
// If not waiting, WaitingSince stays nil (default)
existing.Info = info
existing.PrevState = info.State
}
// List returns a snapshot of all tracked sessions.
func (r *SessionRegistry) List() []SessionInfo {
r.mu.RLock()
defer r.mu.RUnlock()
result := make([]SessionInfo, 0, len(r.sessions))
for _, ts := range r.sessions {
result = append(result, ts.Info)
}
return result
}
// RemoveStale removes sessions whose SessionID is not in activeIDs.
func (r *SessionRegistry) RemoveStale(activeIDs map[string]bool) {
r.mu.Lock()
defer r.mu.Unlock()
for id := range r.sessions {
if !activeIDs[id] {
delete(r.sessions, id)
}
}
}
// LabelStore persists user-assigned labels in a JSON file.
type LabelStore struct {
mu sync.RWMutex
labels map[string]string
path string
}
// NewLabelStore loads labels from path. Returns an empty store if the file does not exist.
func NewLabelStore(path string) (*LabelStore, error) {
ls := &LabelStore{
labels: make(map[string]string),
path: path,
}
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return ls, nil
}
return nil, err
}
if err := json.Unmarshal(data, &ls.labels); err != nil {
return nil, err
}
return ls, nil
}
// Set assigns a label to a session and persists to disk.
func (ls *LabelStore) Set(sessionID, label string) error {
ls.mu.Lock()
defer ls.mu.Unlock()
ls.labels[sessionID] = label
return ls.save()
}
// Get returns the label for a session, or empty string if not found.
func (ls *LabelStore) Get(sessionID string) string {
ls.mu.RLock()
defer ls.mu.RUnlock()
return ls.labels[sessionID]
}
func (ls *LabelStore) save() error {
data, err := json.MarshalIndent(ls.labels, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(ls.path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
return os.WriteFile(ls.path, data, 0o644)
}

43
protocol.go Normal file
View File

@@ -0,0 +1,43 @@
package main
import (
"encoding/json"
"time"
)
// Request is the JSON message sent by a client to the daemon over the Unix socket.
type Request struct {
Action string `json:"action"` // "list", "switch", "label", "stop"
Args json.RawMessage `json:"args,omitempty"`
}
// Response is the JSON message sent back by the daemon.
type Response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Sessions []SessionInfo `json:"sessions,omitempty"`
}
// SessionInfo is the wire format for a session in IPC responses.
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"`
}
// SwitchArgs carries the query for workspace switching.
type SwitchArgs struct {
Query string `json:"query"`
}
// LabelArgs carries the session ID and label for the label action.
type LabelArgs struct {
SessionID string `json:"session_id"`
Label string `json:"label"`
}

View File

@@ -1,5 +1,7 @@
package main
import "time"
// SessionState represents the current activity state of a Claude Code session.
type SessionState int
@@ -32,11 +34,14 @@ type Process struct {
// Session represents a Claude Code session enriched with JSONL metadata.
type Session struct {
Process Process
SessionID string
GitBranch string
State SessionState
Preview string // last lines of output
CwdPath string // process cwd
Worktree string // git worktree (may differ from cwd)
Process Process
SessionID string
GitBranch string
State SessionState
Preview string // last lines of output
CwdPath string // process cwd
Worktree string // git worktree (may differ from cwd)
Workspace string // i3 workspace number
Label string // user-assigned label
WaitingSince *time.Time // when session started waiting for input
}