ScuttleBot

feat(#41): ephemeral channel lifecycle — TTL reaper and DROP endpoint - Track provisioned channels in Manager.channels map (name → provisionedAt) - StartReaper(ctx) starts a 5-minute background ticker that drops channels whose TTL has elapsed (policy.IsEphemeral + policy.TTLFor) - DropChannel(channel) sends ChanServ DROP and removes the channel record - DELETE /v1/topology/channels/{channel} exposes manual drop via REST API - Wire StartReaper in cmd/scuttlebot/main.go after initial provisioning - Add TestReaperExpiry: verifies only TTL-expired ephemeral channels are reaped

lmata 2026-04-02 13:27 trunk
Commit f0853f53f00357d39865ceb46fa59b2cd71c253d27be8166881f7381c774be73
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -189,10 +189,11 @@
189189
})
190190
}
191191
if err := topoMgr.Provision(staticChannels); err != nil {
192192
log.Error("topology provision failed", "err", err)
193193
}
194
+ topoMgr.StartReaper(ctx)
194195
go func() {
195196
<-ctx.Done()
196197
topoMgr.Close()
197198
}()
198199
}
199200
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -189,10 +189,11 @@
189 })
190 }
191 if err := topoMgr.Provision(staticChannels); err != nil {
192 log.Error("topology provision failed", "err", err)
193 }
 
194 go func() {
195 <-ctx.Done()
196 topoMgr.Close()
197 }()
198 }
199
--- cmd/scuttlebot/main.go
+++ cmd/scuttlebot/main.go
@@ -189,10 +189,11 @@
189 })
190 }
191 if err := topoMgr.Provision(staticChannels); err != nil {
192 log.Error("topology provision failed", "err", err)
193 }
194 topoMgr.StartReaper(ctx)
195 go func() {
196 <-ctx.Done()
197 topoMgr.Close()
198 }()
199 }
200
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -9,10 +9,11 @@
99
1010
// topologyManager is the interface the API server uses to provision channels
1111
// and query the channel policy. Satisfied by *topology.Manager.
1212
type topologyManager interface {
1313
ProvisionChannel(ch topology.ChannelConfig) error
14
+ DropChannel(channel string)
1415
Policy() *topology.Policy
1516
}
1617
1718
type provisionChannelRequest struct {
1819
Name string `json:"name"`
@@ -86,10 +87,22 @@
8687
8788
type topologyResponse struct {
8889
StaticChannels []string `json:"static_channels"`
8990
Types []channelTypeInfo `json:"types"`
9091
}
92
+
93
+// handleDropChannel handles DELETE /v1/topology/channels/{channel}.
94
+// Drops the ChanServ registration of an ephemeral channel.
95
+func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
96
+ channel := "#" + r.PathValue("channel")
97
+ if err := topology.ValidateName(channel); err != nil {
98
+ writeError(w, http.StatusBadRequest, err.Error())
99
+ return
100
+ }
101
+ s.topoMgr.DropChannel(channel)
102
+ w.WriteHeader(http.StatusNoContent)
103
+}
91104
92105
// handleGetTopology handles GET /v1/topology.
93106
// Returns the channel type rules and static channel names declared in config.
94107
func (s *Server) handleGetTopology(w http.ResponseWriter, r *http.Request) {
95108
policy := s.topoMgr.Policy()
96109
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -9,10 +9,11 @@
9
10 // topologyManager is the interface the API server uses to provision channels
11 // and query the channel policy. Satisfied by *topology.Manager.
12 type topologyManager interface {
13 ProvisionChannel(ch topology.ChannelConfig) error
 
14 Policy() *topology.Policy
15 }
16
17 type provisionChannelRequest struct {
18 Name string `json:"name"`
@@ -86,10 +87,22 @@
86
87 type topologyResponse struct {
88 StaticChannels []string `json:"static_channels"`
89 Types []channelTypeInfo `json:"types"`
90 }
 
 
 
 
 
 
 
 
 
 
 
 
91
92 // handleGetTopology handles GET /v1/topology.
93 // Returns the channel type rules and static channel names declared in config.
94 func (s *Server) handleGetTopology(w http.ResponseWriter, r *http.Request) {
95 policy := s.topoMgr.Policy()
96
--- internal/api/channels_topology.go
+++ internal/api/channels_topology.go
@@ -9,10 +9,11 @@
9
10 // topologyManager is the interface the API server uses to provision channels
11 // and query the channel policy. Satisfied by *topology.Manager.
12 type topologyManager interface {
13 ProvisionChannel(ch topology.ChannelConfig) error
14 DropChannel(channel string)
15 Policy() *topology.Policy
16 }
17
18 type provisionChannelRequest struct {
19 Name string `json:"name"`
@@ -86,10 +87,22 @@
87
88 type topologyResponse struct {
89 StaticChannels []string `json:"static_channels"`
90 Types []channelTypeInfo `json:"types"`
91 }
92
93 // handleDropChannel handles DELETE /v1/topology/channels/{channel}.
94 // Drops the ChanServ registration of an ephemeral channel.
95 func (s *Server) handleDropChannel(w http.ResponseWriter, r *http.Request) {
96 channel := "#" + r.PathValue("channel")
97 if err := topology.ValidateName(channel); err != nil {
98 writeError(w, http.StatusBadRequest, err.Error())
99 return
100 }
101 s.topoMgr.DropChannel(channel)
102 w.WriteHeader(http.StatusNoContent)
103 }
104
105 // handleGetTopology handles GET /v1/topology.
106 // Returns the channel type rules and static channel names declared in config.
107 func (s *Server) handleGetTopology(w http.ResponseWriter, r *http.Request) {
108 policy := s.topoMgr.Policy()
109
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -25,10 +25,12 @@
2525
2626
func (s *stubTopologyManager) ProvisionChannel(ch topology.ChannelConfig) error {
2727
s.last = ch
2828
return s.provErr
2929
}
30
+
31
+func (s *stubTopologyManager) DropChannel(_ string) {}
3032
3133
func (s *stubTopologyManager) Policy() *topology.Policy { return s.policy }
3234
3335
func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
3436
t.Helper()
3537
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -25,10 +25,12 @@
25
26 func (s *stubTopologyManager) ProvisionChannel(ch topology.ChannelConfig) error {
27 s.last = ch
28 return s.provErr
29 }
 
 
30
31 func (s *stubTopologyManager) Policy() *topology.Policy { return s.policy }
32
33 func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
34 t.Helper()
35
--- internal/api/channels_topology_test.go
+++ internal/api/channels_topology_test.go
@@ -25,10 +25,12 @@
25
26 func (s *stubTopologyManager) ProvisionChannel(ch topology.ChannelConfig) error {
27 s.last = ch
28 return s.provErr
29 }
30
31 func (s *stubTopologyManager) DropChannel(_ string) {}
32
33 func (s *stubTopologyManager) Policy() *topology.Policy { return s.policy }
34
35 func newTopoTestServer(t *testing.T, topo *stubTopologyManager) (*httptest.Server, string) {
36 t.Helper()
37
--- internal/api/server.go
+++ internal/api/server.go
@@ -78,10 +78,11 @@
7878
apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
7979
apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
8080
}
8181
if s.topoMgr != nil {
8282
apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
83
+ apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel)
8384
apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
8485
}
8586
8687
if s.admins != nil {
8788
apiMux.HandleFunc("GET /v1/admins", s.handleAdminList)
8889
8990
ADDED internal/topology/reaper_test.go
--- internal/api/server.go
+++ internal/api/server.go
@@ -78,10 +78,11 @@
78 apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
79 apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
80 }
81 if s.topoMgr != nil {
82 apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
 
83 apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
84 }
85
86 if s.admins != nil {
87 apiMux.HandleFunc("GET /v1/admins", s.handleAdminList)
88
89 DDED internal/topology/reaper_test.go
--- internal/api/server.go
+++ internal/api/server.go
@@ -78,10 +78,11 @@
78 apiMux.HandleFunc("POST /v1/channels/{channel}/presence", s.handleChannelPresence)
79 apiMux.HandleFunc("GET /v1/channels/{channel}/users", s.handleChannelUsers)
80 }
81 if s.topoMgr != nil {
82 apiMux.HandleFunc("POST /v1/channels", s.handleProvisionChannel)
83 apiMux.HandleFunc("DELETE /v1/topology/channels/{channel}", s.handleDropChannel)
84 apiMux.HandleFunc("GET /v1/topology", s.handleGetTopology)
85 }
86
87 if s.admins != nil {
88 apiMux.HandleFunc("GET /v1/admins", s.handleAdminList)
89
90 DDED internal/topology/reaper_test.go
--- a/internal/topology/reaper_test.go
+++ b/internal/topology/reaper_test.go
@@ -0,0 +1,59 @@
1
+package topology
2
+
3
+import (
4
+ "io"
5
+ "log/slog"
6
+ "testing"
7
+ "time"
8
+
9
+ "github.com/conflicthq/scuttlebot/internal/config"
10
+)
11
+
12
+// reapDry runs the reaper's expiry check without calling ChanServ.
13
+// It returns the names of channels that would be reaped.
14
+func reapDry(m *Manager, now time.Time) []string {
15
+ if m.policy == nil {
16
+ return nil
17
+ }
18
+ m.mu.Lock()
19
+ defer m.mu.Unlock()
20
+ var out []string
21
+ for _, rec := range m.channels {
22
+ ttl := m.policy.TTLFor(rec.name)
23
+ if ttl > 0 && m.policy.IsEphemeral(rec.name) && now.Sub(rec.provisionedAt) > ttl {
24
+ out = append(out, rec.name)
25
+ }
26
+ }
27
+ return out
28
+}
29
+
30
+func TestReaperExpiry(t *testing.T) {
31
+ pol := NewPolicy(config.TopologyConfig{
32
+ Types: []config.ChannelTypeConfig{
33
+ {
34
+ Name: "task",
35
+ Prefix: "task.",
36
+ Ephemeral: true,
37
+ TTL: config.Duration{Duration: 72 * time.Hour},
38
+ },
39
+ {
40
+ Name: "sprint",
41
+ Prefix: "sprint.",
42
+ },
43
+ },
44
+ })
45
+ log := slog.New(slog.NewTextHandler(io.Discard, nil))
46
+ m := NewManager("localhost:pol, log)
47
+
48
+ // Simulate that channels were provisioned at different times.
49
+ m.mu.Lock()
50
+ m.channels["#task.old"] = channelRecord{name: "#task.old", provisionedAt: time.Now().Add(-80 * time.Hour)}
51
+ m.channels["#task.fresh"] = channelRecord{name: "#task.fresh", provisionedAt: time.Now().Add(-10 * time.Hour)}
52
+ m.channels["#sprint.2026-q2"] = channelRecord{name: "#sprint.2026-q2", provisionedAt: time.Now().Add(-200 * time.Hour)}
53
+ m.mu.Unlock()
54
+
55
+ expired := reapDry(m, time.Now())
56
+ if len(expired) != 1 || expired[0] != "#task.old" {
57
+ t.Errorf("expected [#task.old] to be expired, got %v", expired)
58
+ }
59
+}
--- a/internal/topology/reaper_test.go
+++ b/internal/topology/reaper_test.go
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/internal/topology/reaper_test.go
+++ b/internal/topology/reaper_test.go
@@ -0,0 +1,59 @@
1 package topology
2
3 import (
4 "io"
5 "log/slog"
6 "testing"
7 "time"
8
9 "github.com/conflicthq/scuttlebot/internal/config"
10 )
11
12 // reapDry runs the reaper's expiry check without calling ChanServ.
13 // It returns the names of channels that would be reaped.
14 func reapDry(m *Manager, now time.Time) []string {
15 if m.policy == nil {
16 return nil
17 }
18 m.mu.Lock()
19 defer m.mu.Unlock()
20 var out []string
21 for _, rec := range m.channels {
22 ttl := m.policy.TTLFor(rec.name)
23 if ttl > 0 && m.policy.IsEphemeral(rec.name) && now.Sub(rec.provisionedAt) > ttl {
24 out = append(out, rec.name)
25 }
26 }
27 return out
28 }
29
30 func TestReaperExpiry(t *testing.T) {
31 pol := NewPolicy(config.TopologyConfig{
32 Types: []config.ChannelTypeConfig{
33 {
34 Name: "task",
35 Prefix: "task.",
36 Ephemeral: true,
37 TTL: config.Duration{Duration: 72 * time.Hour},
38 },
39 {
40 Name: "sprint",
41 Prefix: "sprint.",
42 },
43 },
44 })
45 log := slog.New(slog.NewTextHandler(io.Discard, nil))
46 m := NewManager("localhost:pol, log)
47
48 // Simulate that channels were provisioned at different times.
49 m.mu.Lock()
50 m.channels["#task.old"] = channelRecord{name: "#task.old", provisionedAt: time.Now().Add(-80 * time.Hour)}
51 m.channels["#task.fresh"] = channelRecord{name: "#task.fresh", provisionedAt: time.Now().Add(-10 * time.Hour)}
52 m.channels["#sprint.2026-q2"] = channelRecord{name: "#sprint.2026-q2", provisionedAt: time.Now().Add(-200 * time.Hour)}
53 m.mu.Unlock()
54
55 expired := reapDry(m, time.Now())
56 if len(expired) != 1 || expired[0] != "#task.old" {
57 t.Errorf("expected [#task.old] to be expired, got %v", expired)
58 }
59 }
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -9,10 +9,11 @@
99
import (
1010
"context"
1111
"fmt"
1212
"log/slog"
1313
"strings"
14
+ "sync"
1415
"time"
1516
1617
"github.com/lrstanley/girc"
1718
)
1819
@@ -32,19 +33,28 @@
3233
Voice []string
3334
3435
// Autojoin is a list of bot nicks to invite after provisioning.
3536
Autojoin []string
3637
}
38
+
39
+// channelRecord tracks a provisioned channel for TTL-based reaping.
40
+type channelRecord struct {
41
+ name string
42
+ provisionedAt time.Time
43
+}
3744
3845
// Manager provisions and maintains IRC channel topology.
3946
type Manager struct {
4047
ircAddr string
4148
nick string
4249
password string
4350
log *slog.Logger
4451
policy *Policy
4552
client *girc.Client
53
+
54
+ mu sync.Mutex
55
+ channels map[string]channelRecord // channel name → record
4656
}
4757
4858
// NewManager creates a topology Manager. nick and password are the Ergo
4959
// credentials of the scuttlebot oper account used to manage channels.
5060
// policy may be nil if the caller only uses the manager for ad-hoc provisioning.
@@ -53,10 +63,11 @@
5363
ircAddr: ircAddr,
5464
nick: nick,
5565
password: password,
5666
policy: policy,
5767
log: log,
68
+ channels: make(map[string]channelRecord),
5869
}
5970
}
6071
6172
// Policy returns the policy attached to this manager, or nil.
6273
func (m *Manager) Policy() *Policy { return m.policy }
@@ -207,13 +218,65 @@
207218
208219
if len(ch.Autojoin) > 0 {
209220
m.Invite(ch.Name, ch.Autojoin)
210221
}
211222
223
+ m.mu.Lock()
224
+ m.channels[ch.Name] = channelRecord{name: ch.Name, provisionedAt: time.Now()}
225
+ m.mu.Unlock()
226
+
212227
m.log.Info("provisioned channel", "channel", ch.Name)
213228
return nil
214229
}
230
+
231
+// DropChannel drops an IRC channel via ChanServ DROP and removes it from the
232
+// channel registry. Use for ephemeral channels that have expired or been closed.
233
+func (m *Manager) DropChannel(channel string) {
234
+ m.chanserv("DROP %s", channel)
235
+ m.mu.Lock()
236
+ delete(m.channels, channel)
237
+ m.mu.Unlock()
238
+ m.log.Info("dropped channel", "channel", channel)
239
+}
240
+
241
+// StartReaper starts a background goroutine that drops ephemeral channels once
242
+// their TTL has elapsed. The reaper runs until ctx is cancelled.
243
+// Policy must be set on the Manager for TTL rules to be evaluated.
244
+func (m *Manager) StartReaper(ctx context.Context) {
245
+ if m.policy == nil {
246
+ return
247
+ }
248
+ go func() {
249
+ ticker := time.NewTicker(5 * time.Minute)
250
+ defer ticker.Stop()
251
+ for {
252
+ select {
253
+ case <-ctx.Done():
254
+ return
255
+ case <-ticker.C:
256
+ m.reap()
257
+ }
258
+ }
259
+ }()
260
+}
261
+
262
+func (m *Manager) reap() {
263
+ now := time.Now()
264
+ m.mu.Lock()
265
+ expired := make([]channelRecord, 0)
266
+ for _, rec := range m.channels {
267
+ ttl := m.policy.TTLFor(rec.name)
268
+ if ttl > 0 && m.policy.IsEphemeral(rec.name) && now.Sub(rec.provisionedAt) > ttl {
269
+ expired = append(expired, rec)
270
+ }
271
+ }
272
+ m.mu.Unlock()
273
+ for _, rec := range expired {
274
+ m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
275
+ m.DropChannel(rec.name)
276
+ }
277
+}
215278
216279
func (m *Manager) chanserv(format string, args ...any) {
217280
msg := fmt.Sprintf(format, args...)
218281
m.client.Cmd.Message("ChanServ", msg)
219282
}
220283
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -9,10 +9,11 @@
9 import (
10 "context"
11 "fmt"
12 "log/slog"
13 "strings"
 
14 "time"
15
16 "github.com/lrstanley/girc"
17 )
18
@@ -32,19 +33,28 @@
32 Voice []string
33
34 // Autojoin is a list of bot nicks to invite after provisioning.
35 Autojoin []string
36 }
 
 
 
 
 
 
37
38 // Manager provisions and maintains IRC channel topology.
39 type Manager struct {
40 ircAddr string
41 nick string
42 password string
43 log *slog.Logger
44 policy *Policy
45 client *girc.Client
 
 
 
46 }
47
48 // NewManager creates a topology Manager. nick and password are the Ergo
49 // credentials of the scuttlebot oper account used to manage channels.
50 // policy may be nil if the caller only uses the manager for ad-hoc provisioning.
@@ -53,10 +63,11 @@
53 ircAddr: ircAddr,
54 nick: nick,
55 password: password,
56 policy: policy,
57 log: log,
 
58 }
59 }
60
61 // Policy returns the policy attached to this manager, or nil.
62 func (m *Manager) Policy() *Policy { return m.policy }
@@ -207,13 +218,65 @@
207
208 if len(ch.Autojoin) > 0 {
209 m.Invite(ch.Name, ch.Autojoin)
210 }
211
 
 
 
 
212 m.log.Info("provisioned channel", "channel", ch.Name)
213 return nil
214 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
216 func (m *Manager) chanserv(format string, args ...any) {
217 msg := fmt.Sprintf(format, args...)
218 m.client.Cmd.Message("ChanServ", msg)
219 }
220
--- internal/topology/topology.go
+++ internal/topology/topology.go
@@ -9,10 +9,11 @@
9 import (
10 "context"
11 "fmt"
12 "log/slog"
13 "strings"
14 "sync"
15 "time"
16
17 "github.com/lrstanley/girc"
18 )
19
@@ -32,19 +33,28 @@
33 Voice []string
34
35 // Autojoin is a list of bot nicks to invite after provisioning.
36 Autojoin []string
37 }
38
39 // channelRecord tracks a provisioned channel for TTL-based reaping.
40 type channelRecord struct {
41 name string
42 provisionedAt time.Time
43 }
44
45 // Manager provisions and maintains IRC channel topology.
46 type Manager struct {
47 ircAddr string
48 nick string
49 password string
50 log *slog.Logger
51 policy *Policy
52 client *girc.Client
53
54 mu sync.Mutex
55 channels map[string]channelRecord // channel name → record
56 }
57
58 // NewManager creates a topology Manager. nick and password are the Ergo
59 // credentials of the scuttlebot oper account used to manage channels.
60 // policy may be nil if the caller only uses the manager for ad-hoc provisioning.
@@ -53,10 +63,11 @@
63 ircAddr: ircAddr,
64 nick: nick,
65 password: password,
66 policy: policy,
67 log: log,
68 channels: make(map[string]channelRecord),
69 }
70 }
71
72 // Policy returns the policy attached to this manager, or nil.
73 func (m *Manager) Policy() *Policy { return m.policy }
@@ -207,13 +218,65 @@
218
219 if len(ch.Autojoin) > 0 {
220 m.Invite(ch.Name, ch.Autojoin)
221 }
222
223 m.mu.Lock()
224 m.channels[ch.Name] = channelRecord{name: ch.Name, provisionedAt: time.Now()}
225 m.mu.Unlock()
226
227 m.log.Info("provisioned channel", "channel", ch.Name)
228 return nil
229 }
230
231 // DropChannel drops an IRC channel via ChanServ DROP and removes it from the
232 // channel registry. Use for ephemeral channels that have expired or been closed.
233 func (m *Manager) DropChannel(channel string) {
234 m.chanserv("DROP %s", channel)
235 m.mu.Lock()
236 delete(m.channels, channel)
237 m.mu.Unlock()
238 m.log.Info("dropped channel", "channel", channel)
239 }
240
241 // StartReaper starts a background goroutine that drops ephemeral channels once
242 // their TTL has elapsed. The reaper runs until ctx is cancelled.
243 // Policy must be set on the Manager for TTL rules to be evaluated.
244 func (m *Manager) StartReaper(ctx context.Context) {
245 if m.policy == nil {
246 return
247 }
248 go func() {
249 ticker := time.NewTicker(5 * time.Minute)
250 defer ticker.Stop()
251 for {
252 select {
253 case <-ctx.Done():
254 return
255 case <-ticker.C:
256 m.reap()
257 }
258 }
259 }()
260 }
261
262 func (m *Manager) reap() {
263 now := time.Now()
264 m.mu.Lock()
265 expired := make([]channelRecord, 0)
266 for _, rec := range m.channels {
267 ttl := m.policy.TTLFor(rec.name)
268 if ttl > 0 && m.policy.IsEphemeral(rec.name) && now.Sub(rec.provisionedAt) > ttl {
269 expired = append(expired, rec)
270 }
271 }
272 m.mu.Unlock()
273 for _, rec := range expired {
274 m.log.Info("reaping expired ephemeral channel", "channel", rec.name, "age", now.Sub(rec.provisionedAt).Round(time.Minute))
275 m.DropChannel(rec.name)
276 }
277 }
278
279 func (m *Manager) chanserv(format string, args ...any) {
280 msg := fmt.Sprintf(format, args...)
281 m.client.Cmd.Message("ChanServ", msg)
282 }
283

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button