@@ -21,9 +21,11 @@ import (
2121
2222 "github.com/RandomCodeSpace/ctm/internal/config"
2323 "github.com/RandomCodeSpace/ctm/internal/serve/api"
24+ "github.com/RandomCodeSpace/ctm/internal/serve/attention"
2425 "github.com/RandomCodeSpace/ctm/internal/serve/auth"
2526 "github.com/RandomCodeSpace/ctm/internal/serve/events"
2627 "github.com/RandomCodeSpace/ctm/internal/serve/ingest"
28+ "github.com/RandomCodeSpace/ctm/internal/serve/webhook"
2729 "github.com/RandomCodeSpace/ctm/internal/tmux"
2830)
2931
@@ -72,9 +74,18 @@ type Options struct {
7274 // means /tmp/ctm-statusline (per design spec §4 default).
7375 StatuslineDumpDir string
7476
75- // HasWebhook is surfaced in /api/bootstrap response. Wired up in a
76- // later step; defaults false.
77- HasWebhook bool
77+ // WebhookURL enables the webhook dispatcher. Empty → disabled.
78+ // HasWebhook in /api/bootstrap is derived from this.
79+ WebhookURL string
80+
81+ // WebhookAuth, if non-empty, is sent verbatim in the Authorization
82+ // header on each POST (e.g. "Bearer abc123").
83+ WebhookAuth string
84+
85+ // AttentionThresholds overrides the built-in defaults for the
86+ // attention engine's seven triggers. A zero-valued Thresholds falls
87+ // back to attention.Defaults().
88+ AttentionThresholds attention.Thresholds
7889}
7990
8091// Server is the ctm serve HTTP daemon.
@@ -91,12 +102,15 @@ type Server struct {
91102 requestCtx context.Context
92103 requestCancel context.CancelFunc
93104
94- token string
95- hub * events.Hub
96- proj * ingest.Projection
97- tailers * ingest.TailerManager
98- quota * ingest.QuotaIngester
99- logDir string
105+ token string
106+ hub * events.Hub
107+ proj * ingest.Projection
108+ tailers * ingest.TailerManager
109+ quota * ingest.QuotaIngester
110+ cpCache * api.CheckpointsCache
111+ attention * attention.Engine
112+ webhook * webhook.Dispatcher
113+ logDir string
100114}
101115
102116// New binds the listener, loads the bearer token, constructs the hub
@@ -149,6 +163,40 @@ func New(opts Options) (*Server, error) {
149163
150164 hub := events .NewHub (0 )
151165 proj := ingest .New (sessionsPath , tmux .NewClient (tmuxConf ))
166+ quota := ingest .NewQuotaIngester (dumpDir , proj , hub )
167+ cpCache := api .NewCheckpointsCache ()
168+
169+ // Attention engine: subscribes to hub, evaluates the seven triggers,
170+ // re-publishes attention_raised/_cleared on transitions. Wired into
171+ // quotaEnricher.Attention so /api/sessions surfaces the current
172+ // alert. Runs for the full daemon lifetime.
173+ thr := opts .AttentionThresholds
174+ if thr == (attention.Thresholds {}) {
175+ thr = attention .Defaults ()
176+ }
177+ attEngine := attention .NewEngine (
178+ hub ,
179+ quota ,
180+ sessionSourceAdapter {proj : proj , cpCache : cpCache },
181+ thr ,
182+ nil ,
183+ )
184+
185+ // Webhook dispatcher: POSTs attention_raised events to a user-
186+ // configured URL with 3× exponential retry and 60 s debounce per
187+ // (session, alert). URL empty → Run returns immediately without
188+ // subscribing (dispatcher disabled).
189+ disp := webhook .NewDispatcher (
190+ hub ,
191+ sessionResolverAdapter {proj : proj },
192+ webhook.Config {
193+ URL : opts .WebhookURL ,
194+ AuthHeader : opts .WebhookAuth ,
195+ UIBaseURL : fmt .Sprintf ("http://127.0.0.1:%d" , opts .Port ),
196+ },
197+ nil ,
198+ )
199+
152200 reqCtx , reqCancel := context .WithCancel (context .Background ())
153201 s := & Server {
154202 opts : opts ,
@@ -160,7 +208,10 @@ func New(opts Options) (*Server, error) {
160208 hub : hub ,
161209 proj : proj ,
162210 tailers : ingest .NewTailerManager (logDir , hub ),
163- quota : ingest .NewQuotaIngester (dumpDir , proj , hub ),
211+ quota : quota ,
212+ cpCache : cpCache ,
213+ attention : attEngine ,
214+ webhook : disp ,
164215 logDir : logDir ,
165216 }
166217
@@ -201,6 +252,16 @@ func (s *Server) Run(ctx context.Context) error {
201252 quotaDone := make (chan error , 1 )
202253 go func () { quotaDone <- s .quota .Run (quotaCtx ) }()
203254
255+ attCtx , attCancel := context .WithCancel (ctx )
256+ defer attCancel ()
257+ attDone := make (chan error , 1 )
258+ go func () { attDone <- s .attention .Run (attCtx ) }()
259+
260+ whCtx , whCancel := context .WithCancel (ctx )
261+ defer whCancel ()
262+ whDone := make (chan error , 1 )
263+ go func () { whDone <- s .webhook .Run (whCtx ) }()
264+
204265 // Tailer adoption: scan the JSONL log directory and spawn a tailer
205266 // for every UUID we find a log file for. The log files are the
206267 // ground truth (claude writes them via the log-tool-use hook
@@ -285,6 +346,10 @@ func (s *Server) Run(ctx context.Context) error {
285346 <- projDone
286347 quotaCancel ()
287348 <- quotaDone
349+ attCancel ()
350+ <- attDone
351+ whCancel ()
352+ <- whDone
288353 s .tailers .StopAll ()
289354 if errors .Is (err , http .ErrServerClosed ) {
290355 return nil
@@ -304,6 +369,10 @@ func (s *Server) Run(ctx context.Context) error {
304369 <- projDone
305370 quotaCancel ()
306371 <- quotaDone
372+ attCancel ()
373+ <- attDone
374+ whCancel ()
375+ <- whDone
307376 s .tailers .StopAll ()
308377 err := <- serveDone
309378 if shutErr != nil {
@@ -329,9 +398,9 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
329398
330399 // Authenticated JSON endpoints.
331400 mux .Handle ("GET /health" , authHF (api .Health (s .opts .Version , ServeVersionHeader , s .startedAt , hubStatsAdapter {s .hub })))
332- mux .Handle ("GET /api/bootstrap" , authHF (api .Bootstrap (s .opts .Version , s .opts .Port , s .opts .HasWebhook )))
401+ mux .Handle ("GET /api/bootstrap" , authHF (api .Bootstrap (s .opts .Version , s .opts .Port , s .opts .WebhookURL != "" )))
333402
334- enricher := quotaEnricher {quota : s .quota }
403+ enricher := quotaEnricher {quota : s .quota , attention : s . attention }
335404 mux .Handle ("GET /api/sessions" , authHF (api .List (s .proj , enricher )))
336405 mux .Handle ("GET /api/sessions/{name}" , authHF (api .Get (s .proj , enricher )))
337406 // Quota REST fallback so global rate-limit bars render on first
@@ -349,18 +418,18 @@ func (s *Server) registerRoutes(mux *http.ServeMux) {
349418 // Shared 5 s checkpoint cache: the /checkpoints handler and the
350419 // revert SHA-allowlist check both read through the same cache,
351420 // preventing a rapid-revert client from spinning up unbounded
352- // `git log` subprocesses.
353- cpCache := api . NewCheckpointsCache ()
421+ // `git log` subprocesses. Also consumed by the attention engine
422+ // (trigger G yolo_unchecked).
354423 allowedSHA := func (name , sha string ) bool {
355424 wd , ok := resolveWorkdir (name )
356425 if ! ok {
357426 return false
358427 }
359428 // Full SHA equality only; abbreviated SHAs are intentionally
360429 // rejected (see api.CheckpointsCache.IsCheckpoint comment).
361- return cpCache .IsCheckpoint (wd , name , sha )
430+ return s . cpCache .IsCheckpoint (wd , name , sha )
362431 }
363- mux .Handle ("GET /api/sessions/{name}/checkpoints" , authHF (api .Checkpoints (resolveWorkdir , cpCache )))
432+ mux .Handle ("GET /api/sessions/{name}/checkpoints" , authHF (api .Checkpoints (resolveWorkdir , s . cpCache )))
364433 mux .Handle ("POST /api/sessions/{name}/revert" , authHF (api .Revert (resolveWorkdir , allowedSHA )))
365434 // Feed REST seed — parallel to /api/quota. Global ring and per-
366435 // session variant; both emit tool_call payloads only. See
@@ -399,11 +468,13 @@ type hubStatsAdapter struct{ hub *events.Hub }
399468func (a hubStatsAdapter ) Stats () any { return a .hub .Stats () }
400469
401470// quotaEnricher adapts the QuotaIngester (in `internal/serve/ingest`)
402- // to the api.SessionEnricher interface so per-session `context_pct`
403- // shows up on /api/sessions responses without coupling the ingest
404- // package to the api package.
471+ // and the attention engine to the api.SessionEnricher interface so
472+ // per-session `context_pct`, tokens, and current attention alert show
473+ // up on /api/sessions responses without coupling the api package to
474+ // ingest or attention internals.
405475type quotaEnricher struct {
406- quota * ingest.QuotaIngester
476+ quota * ingest.QuotaIngester
477+ attention * attention.Engine
407478}
408479
409480func (e quotaEnricher ) ContextPct (name string ) (int , bool ) {
@@ -418,7 +489,18 @@ func (e quotaEnricher) LastToolCallAt(name string) (time.Time, bool) {
418489}
419490
420491func (e quotaEnricher ) Attention (name string ) (api.Attention , bool ) {
421- return api.Attention {}, false
492+ if e .attention == nil {
493+ return api.Attention {}, false
494+ }
495+ snap , ok := e .attention .Snapshot (name )
496+ if ! ok {
497+ return api.Attention {}, false
498+ }
499+ return api.Attention {
500+ State : snap .State ,
501+ Since : snap .Since ,
502+ Details : snap .Details ,
503+ }, true
422504}
423505
424506func (e quotaEnricher ) Tokens (name string ) (api.TokenUsage , bool ) {
@@ -436,6 +518,65 @@ func (e quotaEnricher) Tokens(name string) (api.TokenUsage, bool) {
436518 }, true
437519}
438520
521+ // sessionSourceAdapter satisfies attention.SessionSource by reading
522+ // through the live projection and the checkpoint cache. Projection
523+ // already owns its own tmux client for TmuxAlive; checkpoint freshness
524+ // (for trigger G) comes from a bounded-limit cache Get that avoids
525+ // unbounded `git log` calls per tick.
526+ type sessionSourceAdapter struct {
527+ proj * ingest.Projection
528+ cpCache * api.CheckpointsCache
529+ }
530+
531+ func (a sessionSourceAdapter ) Names () []string {
532+ all := a .proj .All ()
533+ out := make ([]string , 0 , len (all ))
534+ for _ , s := range all {
535+ out = append (out , s .Name )
536+ }
537+ return out
538+ }
539+
540+ func (a sessionSourceAdapter ) Mode (name string ) string {
541+ s , ok := a .proj .Get (name )
542+ if ! ok {
543+ return ""
544+ }
545+ return s .Mode
546+ }
547+
548+ func (a sessionSourceAdapter ) TmuxAlive (name string ) bool {
549+ return a .proj .TmuxAlive (name )
550+ }
551+
552+ func (a sessionSourceAdapter ) LastCheckpointAt (name string ) (time.Time , bool ) {
553+ s , ok := a .proj .Get (name )
554+ if ! ok || s .Workdir == "" {
555+ return time.Time {}, false
556+ }
557+ cps , err := a .cpCache .Get (s .Workdir , name , 1 )
558+ if err != nil || len (cps ) == 0 {
559+ return time.Time {}, false
560+ }
561+ t , perr := time .Parse (time .RFC3339 , cps [0 ].TS )
562+ if perr != nil {
563+ return time.Time {}, false
564+ }
565+ return t , true
566+ }
567+
568+ // sessionResolverAdapter satisfies webhook.SessionResolver so webhook
569+ // payloads carry session_uuid / workdir / mode alongside the alert.
570+ type sessionResolverAdapter struct { proj * ingest.Projection }
571+
572+ func (a sessionResolverAdapter ) Resolve (name string ) (uuid , workdir , mode string , ok bool ) {
573+ s , found := a .proj .Get (name )
574+ if ! found {
575+ return "" , "" , "" , false
576+ }
577+ return s .UUID , s .Workdir , s .Mode , true
578+ }
579+
439580// quotaSourceAdapter wraps the ingester's GlobalSnapshot return into
440581// the api-package's private quotaSnapshot, keeping the ingest →
441582// api dependency one-way (api stays ignorant of ingest internals).
0 commit comments