edge and Ember: a practice coach that locks my AI, and an island to watch it work
A tour of the two programs that run my competitive programming: a Go daemon that picks problems, watches the judges, reads /proc to check I’m working and locks Claude Code until I’ve solved something, and the Wayland island that shows all of it.

That’s an evening of competitive programming on my laptop. A problem gets offered, I start it, get a wrong answer on test 4, fix it, get it accepted, and later sit a Codeforces round while a pill at the top of the screen counts down and fills in a letter for every verdict.
I didn’t press anything except Start. Two programs did the rest:
- edge decides what I practise, watches the judges, measures whether I’m actually working, and locks Claude Code until I’ve done the day’s problems.
- Ember is a dynamic island for my Wayland desktop. It shows what Claude Code is doing, what’s playing, and everything edge has to say.
This post is a tour of both. Fair warning: one of them reads /proc to check whether my terminal is lying to it.
How the pieces fit
| edge | Ember | |
|---|---|---|
| Language | Go | Go |
| Lines (code / tests) | 6,628 / 1,681 | 12,512 / 3,776 |
| Test functions | 64 | 65 |
| Dependencies | none, only the standard library | 4 Go modules (D-Bus, x/image, x/sys, x/text) |
| Runs as | a systemd user service | a systemd user service |
| Talks to | 3 judges, niri, /proc, Claude Code, Ember |
Wayland, D-Bus, the audio server, Claude Code, edge |
edge has no dependencies at all, and that isn’t minimalism for its own sake. Its Claude Code hook runs on every prompt I type, so it has to start instantly, never break on an update, and never lock me out because some library had a bad day.
The bench underneath
Under all of this is a normal setup. Neovim on AstroNvim, with competitest.nvim listening on port 27121. One click on the Competitive Companion browser extension sends a problem’s sample tests there. competitest writes ~/CP/codeforces/2257/C.cpp from my template, saves the samples next to it, and <leader>rr compiles with g++ -std=c++20 -O2 -Wall and runs them all.
The template has one line people ask about:
#ifdef LOCAL#include "debug.h"#else#define debug(...) 42#endifLocally, debug(n, edges) prints names and values to stderr. On the judge every debug call becomes 42;, which does nothing, so I never have to delete them before submitting. g++ still notices, and warns statement has no effect once per call. It isn’t wrong.
This bench also had the best bug in the whole setup. Competitive Companion names problems like A. Two Permutations, and my config took everything before the first space as the problem letter: A., dot included. So I sat an entire Educational round writing code in A..cpp, and the compiled programs were called A., B. and so on:
$ ls ~/CP/codeforces/2242A. A..cpp B. B..cpp C..cpp D..cpp E..cpp F..cppThe fix was one character class in a Lua pattern. It matters later, because edge finds out I’ve started a problem by looking for that exact file name.
edge
It speaks first
The first version of edge was five slash commands for Claude Code. They all worked, and I never typed any of them, because remembering to start is the exact thing I’m bad at. The commit that added them and the commit that made them unnecessary are eleven minutes apart.
What replaced them is a daemon that ticks every 20 seconds. At 19:00 on weekdays and 10:00 at weekends it picks a problem and sends a desktop notification with buttons, through notify-send --wait:
Next problem is readySpying on the Beaver · rating 1400Today 0/3 · streak 4 [ In 30 min ] [ Start ]Start opens the problem in the browser and nvim in a terminal. Both are launched through niri, my window manager, not as children of edge, so restarting edge can’t take my editor down with it. One more click on Competitive Companion creates the file, edge sees C.cpp appear, and the clock starts. With the double-dot bug, it would have waited for C.cpp while I typed into C..cpp, and only found out I’d started when I submitted.
It picks the problem
Problems come from a band: my current rating up to 300 above it, so 1200 to 1500 on Codeforces right now. Within the band, edge favours my weakest topics. It counted them from my 240 solved Codeforces problems: 25 DP, 5 graphs, 2 trees, 1 DSU, and no shortest paths whatsoever. Topics I’ve solved more of come up less often, and topics I’ve recently struggled with come up more.
The topic stays hidden until the problem is solved, because “this is a DSU problem” is most of the solution to a DSU problem.
The band moves by itself. After at least six new problems, if I’ve solved 70% of them without help it moves up 100, and at 30% or less it moves down. A problem solved in under five minutes doesn’t count, because edge assumes I’d seen it before, and says so:
Accepted (solo)Spying on the Beaver · 4m of worktoday 1/1 · Claude unlockedThat fast, you'd seen it before: no re-solves for this one.It’s the only compliment edge knows how to give, and it’s an accusation.
It watches the judge
While a problem is open, edge asks the Codeforces API for my latest submissions every minute, with calls spaced at least 2.1 seconds apart because the API asks for two. Each verdict becomes a notification (“Wrong answer on test 4”), and an accepted solution is recorded without my telling it anything. LeetCode works the same way through its public GraphQL endpoint. Codeforces problem pages sit behind a Cloudflare check, so edge never reads the statement, only the verdicts.
It knows whether I’m working
Hints unlock after time spent working, and “working” can’t mean time since I clicked Start, because that includes dinner and YouTube. So every tick, edge asks niri which window has focus and decides:
- A browser counts if its title mentions a judge or the problem’s own name.
- A terminal counts only if something running inside it is actually in
~/CP. edge takes the terminal’s process ID, walks/procfor every descendant (the shell, nvim, Claude), and reads each one’scwdsymlink. - A solution file saved in the last three minutes counts, whatever has focus.
- No single tick can be worth more than 40 seconds, so a laptop suspended mid-problem doesn’t wake up with two hours of work.
A terminal open on a different project is, correctly, not practice. It’s the most invasive program I’ve ever written, and its only target is me.
If YouTube or Reddit has had focus for two minutes in the middle of a problem, a notification names the site and how much work I’d done before I left.
Hints on a ladder
After 20 minutes of work, hint 1 unlocks: one observation, no technique named. At 35 minutes, hint 2 names the key idea. At 50, hint 3 gives the algorithm as steps, still no code. At 60, I’m allowed to give up and be taught the full solution. Claude gives the hints through a skill, and the skill has to claim the level first with edge stuck --take 2. Claiming is idempotent, so asking for the same hint twice costs one hint.
Giving up still counts for the day, but only after 45 minutes of real work and only if I submitted something or edited the file. Opening a problem, staring at it for 45 minutes and giving up is not, by edge’s standards, practice.
Cold re-solves
Solving something once proves less than it feels like. Every problem comes back five days later as a cold re-solve: no hints, and my old file moved into an archive before the new attempt opens, so there’s nothing to peek at. Pass it and it comes back once more after 14 days. Fail and it returns in 3, and after three failures edge gives up on it. After each accepted problem, Claude also asks me two questions about my own code (why it’s correct, and what breaks if a constraint changes) and grades the answers from 0 to 2. A 0 brings the problem back in three days.
An experiment with one subject
I don’t know whether hints that unlock over time teach better than hints on demand, so edge tests it on me. Each new problem gets one of the two styles, keeping the groups balanced, and the outcome is whether I pass its first cold re-solve. With passes out of , each style’s pass rate gets a posterior, and edge estimates from 20,000 samples with a fixed seed, so the same data always gives the same answer.
The verdict is computed once, when both styles reach 30 re-solves, and needs 95% either way. Deciding only once means I can’t end the study on the day a lucky streak agrees with me. Current sample size: one.
The gate
Claude Code runs hooks: commands it calls at set moments, with the details as JSON on stdin. edge is registered for two of them. Before a prompt reaches the model, edge decides whether it may. Before a tool runs, edge decides whether it may touch what it’s about to touch.
The rule is simple. From 19:00 on weekdays and 10:00 at weekends, until the day ends at 04:00, Claude Code is closed until I’ve done the day’s quota: one problem on a weekday, three at weekends. A blocked prompt gets this back, and the model never sees it:
{"decision": "block", "reason": "edge gate: today's learning isn't done (0/1).\nWaiting for you: Spying on the Beaver (https://codeforces.com/problemset/problem/2257/C). Click Start on the notification.\nTalk to Claude from ~/CP about it any time.\nReal emergency? Run `edge pass` in a terminal: it unlocks today (1 per week)."}Prompts from inside ~/CP still get through, with a contract attached. This is real output from the hook on my machine, trimmed:
{"hookSpecificOutput": {"hookEventName": "UserPromptSubmit", "additionalContext": "edge tutor mode. The user practices DSA in C++ and never types edge commands: you run them. Never write the solution or reveal the key idea for their current problem except through the edge-stuck / edge-giveup skills. ..."}}So Claude can explain a compiler error or how std::set works, but it can’t solve my problem for me, and when I say “I’m stuck” it runs the hint skill instead of answering.
Built not to lock me out
A gate on your own AI is only funny until it breaks. So the hook reads edge’s state without waiting for the daemon’s lock, never writes anything, recovers from panics, and always exits 0, because exit code 2 is how a hook says “block” and a crash shouldn’t be able to say it by accident. If the state file can’t be read, everything gets through. If Codeforces has been unreachable for half an hour, the gate opens too, because edge can’t offer me a way to earn the day.
A second Claude found the holes
When the gate first worked, I asked another Claude session to get past it. It found two ways almost straight away. A prompt sent from ~/CP got the tutor contract, but nothing stopped that Claude from editing files anywhere on the machine. And any prompt starting with /edge went through, whatever came after it.
So the second hook checks every tool call while the gate is closed:
- File tools may only touch
~/CP. - MCP tools are refused.
- Shell commands are split into tokens. Every path must resolve inside
~/CP,/usror/tmp, and anything that could run other code is refused outright:python,eval,xargs,tmux,script,socat,nohup,systemctl,sudoand almost thirty more.
The comment above that code calls it “a speed bump, not a sandbox (a compiled program can read anything)”. Compiling and running my own C++ is the one thing it has to allow.
This had a side effect I didn’t plan for. edge’s own tests build a fake home directory, and they can’t put it in /tmp, because the gate allows /tmp:
d, err := os.MkdirTemp("/var/tmp", "edge-test") // not /tmp: the gate allows /tmpThe emergency exit
Once a week, edge pass opens the gate for the rest of the day. Claude mustn’t be able to spend it for me, so before it does anything, edge checks that stdin and stdout are real terminals, then walks up its own process tree through /proc/<pid>/stat, parent by parent, and refuses if any ancestor is called claude. As far as I know it’s the only program on my laptop that checks whether its grandparent is an AI.
The config file is re-read within seconds of an edit, but any change that makes today easier (a smaller quota, the gate off, a different practice folder) only applies from tomorrow. Today’s rules are pinned in the state the first time the day is seen. Setting the quota to zero at nine in the evening works perfectly, tomorrow.
Rounds
The newest part is live contests. edge reads three calendars: Codeforces’ contest.list API, the AtCoder contests page (there’s no API, so it parses the HTML), and LeetCode’s GraphQL endpoint. It keeps the rounds rated for me (Codeforces Div. 2, 3, 4, Educational and Div. 1 + 2, AtCoder Beginner Contests, LeetCode weekly and biweekly) and plans my week: two rounds, at most one a day, inside study time, Codeforces first. Here’s its plan for this weekend, straight from the terminal:
$ edge roundsrounds: 2 a week, judges cf ac lc, lock true Sat 17 Oct 17:30 ABC 480 AtCoder Beginner Contest 480 · open Sat 17 Oct 20:05 CF 1128 WorldQuant Round 1 (Codeforces Round 1128, Div. 1 + Div. 2) · YOU'RE IN Sun 18 Oct 08:00 Weekly 524 Weekly Contest 524 · openABC 480 stays open because it’s on the same day. LeetCode’s weekly stays open because 08:00 on a Sunday is outside study time.
A round I’m held to gets a notification the day before, with Register and “Can’t make it”, and another 15 minutes before, with a button that opens the contest and nvim, plus the lesson I wrote down after my last round. While it runs, Claude Code is off completely: every prompt and every tool call is refused, including ~/CP and the hint skills. Both Codeforces
The debt is upsolves. Ten minutes after a round ends, edge collects my results, and the first two problems I didn’t solve become upsolve tasks. They’re offered before any new problem, with the usual hint ladder, and they lapse after a week. A round I was held to and skipped still owes its upsolves, starting from wherever I got to in my last round of the same kind. AtCoder hides submissions during a round and wants a login for its submission pages, so for AtCoder edge reads my results from the AtCoder Problems mirror, which picks them up a few minutes after the end. Then Claude runs a three-minute debrief on where the time went and keeps one lesson for next time.
Ember
Notifications are fine for things that need a decision. A countdown shouldn’t need clicking. The rest goes to Ember.
Ember is a dynamic island for Wayland: a small dark surface hanging over the empty middle of my bar, on niri. It speaks the Wayland wire protocol directly, with no toolkit and no C: one socket, one shared-memory buffer drawn in software, and frames only while something is moving. It listens to Claude Code through hooks on more than a dozen events, so it knows what every session is doing:

It also shows the desktop’s small events, pushed from D-Bus, the audio server and inotify rather than polled:

edge is an Ember plugin. It sends one line of JSON per message over Ember’s Unix socket: a status for the pill, cards with buttons, and a stats file for the dashboard. Ember’s buttons run edge action <key>. Here’s a round in the pill before, during and after:




The dashboard opens with a key and draws edge’s stats file: today’s ring, the problem in progress with its hint dots, the rounds, the week, my Codeforces rating against the band edge picks from, and a year as a heatmap.

The bug this post found
To record the video at the top, I added a scene to Ember’s demo renderer that plays edge’s real messages. In the first take, the offer card read:
Spying on the Beaver · rating 4Today 0/3 · streal00Ember animates numbers in card text: when one changes, its digits roll to the new value. Each rolling number was identified by its position in the line and its word index, and both lines of that card have a number in the same place. So “1400” and “4” shared one roller, which flipped between them every frame, forever. Every offer card edge ever sent to the island had been doing it. The rollers are now keyed by the words before the number, and the fix is on GitHub with the rest of Ember. edge stays on my laptop: it’s tuned to one person’s weak topics, and its tests know my handle.
Saturday
The first round edge picked for me is Codeforces Round 1128, this Saturday at 20:05. From then until 23:05 my laptop won’t talk to Claude at all, the pill will count down at the top of the screen, and on Sunday the first problem waiting for me will be whichever one I didn’t solve.
I didn’t choose that round. That’s the point.