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:
141
daemon.go
Normal file
141
daemon.go
Normal 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
43
protocol.go
Normal 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"`
|
||||
}
|
||||
19
types.go
19
types.go
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user