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.

· 16 min read
The top of a Linux desktop. A dark island hangs from the bar. A card drops out of it: “Next problem is ready, Spying on the Beaver, rating 1400”, and the pointer clicks Start. The island shrinks to a pill with a timer. A red card says “Wrong answer on test 4”. A green card bounces in: “Accepted (solo), 41m of work”. Later the pill reads “CF 1128 in 2m”, then shows letter chips A to F filling in green and red beside a countdown and a padlock, and finally a card says “CF 1128, solved A B, upsolve next: C D”
An evening of practice as my laptop sees it, recorded with Ember's own renderer.

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

CodeforcesAPILeetCodeGraphQLAtCoderHTML + kenkooooedge daemona Go binary, systemd user servicepicks · polls · counts work · plans roundssubmissions, contests, ratingsniri + /procwhat's on screen,where my shells standnotify-sendnotificationswith buttonsstate.jsonflock, fsync, hourly .bakClaude Codehook: the gate on prompts and toolsskills: stuck · done · giveup · roundreads, never waitsEmberpill · cards · dashboardember.sock + stats.jsonstatus, cards, statsbuttons → edge actionJudgesCodeforces · LeetCodeAtCoderniri + /procwhat's on screenedge daemonGo, systemd user servicepicks · polls · counts work · plans roundsnotify-sendwith buttonsstate.jsonflock · fsync · .bakClaude Codehook: the gateskills: stuck · done · giveupreadsEmberpill · cards · dashboardember.sock + stats.jsonstatus
The whole system. edge’s daemon is the only thing that talks to the judges and the only thing that writes the state; the hook only reads, and Ember only draws.
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
#endif

Locally, 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/2242
A. A..cpp B. B..cpp C..cpp D..cpp E..cpp F..cpp

The 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 ready
Spying on the Beaver · rating 1400
Today 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 work
today 1/1 · Claude unlocked
That 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 /proc for every descendant (the shell, nvim, Claude), and reads each one’s cwd symlink.
  • 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 s passes out of n, each style’s pass rate gets a Beta(1+s, 1+n−s) posterior, and edge estimates P(pladder>pinstant) 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, /usr or /tmp, and anything that could run other code is refused outright: python, eval, xargs, tmux, script, socat, nohup, systemctl, sudo and 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 /tmp

The 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 rounds
rounds: 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 · open

ABC 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 CodeforcesMike Mirzayanov, Rule Restricting the Use of AI, Codeforces, September 2024. It covers rated rounds: translation and plain code completion are allowed; getting a solution or a fix from a model is not. and AtCoderAtCoder’s rules on generative AI, covering ABC, ARC and AGC while they run. Translating the statement is the main exception. already ban using AI to solve problems in rated rounds; edge enforces it one step earlier, on my side. The only way out is “Can’t make it”, which frees the evening but keeps the debt.

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:

Ember following a Claude Code session: the pill names the session and a timer runs. The card opens on hover with a timeline of the session’s tool calls: reading, searching, editing, running tests. A permission request drops in with Deny, Always and Allow, and the pointer clicks Allow. The session finishes and Ember shows a one-line summary of what it did.
A Claude Code session on the island: its steps as they happen, a permission request answered without leaving the editor, and the recap when it's done.

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

Short moments in the pill, one after another: Charging with a battery meter, headphones connected with their battery level, Screenshot saved with a thumbnail, the brightness meter dropping, then a session that needs permission
Charging, headphones, a screenshot, brightness, and a session asking for permission.

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 pill: a small flame, “CF 1128”, “in 1h 42m”
An hour and 42 minutes out.
The pill during the round: “CF 1128”, chips A and B filled green, C ringed red, D, E and F empty, “1” left and a padlock
During it: A and B accepted, C rejected at least once, D to F not opened, and the padlock means Claude is off.
The pill after the round: “CF 1128”, “upsolve C”, chips A and B green, C and D ringed amber, E and F empty
After it: the amber rings are the upsolves edge now expects.
Ember’s open card at night, ending in a row for CF 1128 that reads “solved A B · upsolve C D” beside a single Upsolve button
Hover the island after a round and the card offers exactly one next step.

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.

edge’s dashboard on Ember: a ring for today’s tasks and a streak flame, the current problem with hint dots, a Rounds section with “You’re in CF 1128” and three past rounds as coloured letters with rating changes, bars for the week, a Codeforces rating line over a shaded band, a year-long heatmap, and four numbers along the bottom: solo rate, retention, focus time and weakest topic
The dashboard, drawn here with sample data.

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 4
Today 0/3 · streal00

Ember 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.

Worth passing on?

Reading

Text size
Line length
Line spacing
Typeface