diff --git a/daemon.go b/daemon.go new file mode 100644 index 0000000..1fffeaa --- /dev/null +++ b/daemon.go @@ -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) +} diff --git a/protocol.go b/protocol.go new file mode 100644 index 0000000..1595577 --- /dev/null +++ b/protocol.go @@ -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"` +} diff --git a/types.go b/types.go index 8212789..2ef3070 100644 --- a/types.go +++ b/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 }