// Package activity holds the app's coordination state: the single active project // (the repo/task currently in focus) and a bounded feed of what happened — user // AND Claude actions. Both are in-memory (mirrored to the logs, no datastore — // AGENT.md §1.3) and queryable so Claude can sync on any turn boundary; new // events also fan out to subscribers for the browser SSE stream (§8.2). This is // the foundation the graceful project handoff (§8.3) builds on. package activity import ( "log/slog" "sync" "time" ) // Actor is who caused an event. type Actor string const ( ActorUser Actor = "user" ActorClaude Actor = "claude" ActorSystem Actor = "system" ) // Event is one entry in the activity feed. type Event struct { ID int64 `json:"id"` Time time.Time `json:"time"` Actor Actor `json:"actor"` Kind string `json:"kind"` // e.g. "active-project-changed" Repo string `json:"repo,omitempty"` // repo path, when relevant Detail string `json:"detail,omitempty"` // human-readable extra context } // Feed is the concurrency-safe active-project + activity store with fan-out. type Feed struct { mu sync.RWMutex active string // active project path ("" = none) events []Event maxEvents int nextID int64 subs map[chan Event]struct{} log *slog.Logger } // New builds a Feed keeping at most maxEvents recent events. func New(log *slog.Logger, maxEvents int) *Feed { if maxEvents <= 0 { maxEvents = 200 } return &Feed{ maxEvents: maxEvents, nextID: 1, subs: make(map[chan Event]struct{}), log: log, } } // ActiveProject returns the current active project path ("" if none). func (f *Feed) ActiveProject() string { f.mu.RLock() defer f.mu.RUnlock() return f.active } // SetActiveProject sets the active project and records an event. It is a no-op // (changed=false, zero Event) when path already matches, so repeated sets don't // spam the feed. func (f *Feed) SetActiveProject(actor Actor, path string) (Event, bool) { f.mu.Lock() if f.active == path { f.mu.Unlock() return Event{}, false } f.active = path ev, subs := f.appendLocked(actor, "active-project-changed", path, "") f.mu.Unlock() publish(subs, ev) return ev, true } // Record adds an arbitrary event to the feed. func (f *Feed) Record(actor Actor, kind, repo, detail string) Event { f.mu.Lock() ev, subs := f.appendLocked(actor, kind, repo, detail) f.mu.Unlock() publish(subs, ev) return ev } // Events returns up to limit of the most recent events, oldest first. limit<=0 // returns all retained events. func (f *Feed) Events(limit int) []Event { f.mu.RLock() defer f.mu.RUnlock() if limit <= 0 || limit > len(f.events) { limit = len(f.events) } out := make([]Event, limit) copy(out, f.events[len(f.events)-limit:]) return out } // Subscribe returns a channel of future events and an unsubscribe func the // caller MUST invoke when done (e.g. via defer) to avoid leaking the channel. func (f *Feed) Subscribe() (<-chan Event, func()) { ch := make(chan Event, 16) f.mu.Lock() f.subs[ch] = struct{}{} f.mu.Unlock() var once sync.Once unsub := func() { once.Do(func() { f.mu.Lock() delete(f.subs, ch) f.mu.Unlock() close(ch) }) } return ch, unsub } // appendLocked assigns id/time, appends (trimming to maxEvents), logs, and // returns the event plus a snapshot of subscriber channels to publish to after // the lock is released. Caller must hold f.mu. func (f *Feed) appendLocked(actor Actor, kind, repo, detail string) (Event, []chan Event) { ev := Event{ID: f.nextID, Time: time.Now(), Actor: actor, Kind: kind, Repo: repo, Detail: detail} f.nextID++ f.events = append(f.events, ev) if len(f.events) > f.maxEvents { f.events = f.events[len(f.events)-f.maxEvents:] } if f.log != nil { f.log.Info("activity", "actor", actor, "kind", kind, "repo", repo, "detail", detail) } subs := make([]chan Event, 0, len(f.subs)) for ch := range f.subs { subs = append(subs, ch) } return ev, subs } // publish does a non-blocking send to each subscriber; a full channel (slow // consumer) drops the event rather than stalling the producer. func publish(subs []chan Event, ev Event) { for _, ch := range subs { select { case ch <- ev: default: } } }