A (Mostly True) Story About Windows
You had fourteen windows open. They were perfect. Firefox on workspace 2 — not because it belongs there, but because that's where you put it three months ago and you have never questioned it since. Three Ghostty terminals in a lovely little row on workspace 1, each one doing something vaguely important. Zed occupying exactly 49.3% of the screen, because you dragged that column divider to precisely the right place and it felt, in that moment, like art.
Then you restarted Hyprland.
The windows are gone. Not destroyed — windows don't really die, they simply stop being open. But they are gone all the same. Gone like old friends who moved to another city without leaving a forwarding address. Gone like socks. Gone like the particular energy you had at 2pm last Tuesday.
"A workspace without its windows is just a number."
— someone, probably
This is where hypr-recall comes in. hypr-recall is a small Rust program that remembers your windows so you don't have to. It is not magic. It is approximately 1,700 lines of fairly earnest code and a logo that was generated by an AI and then had its background removed by a different AI.1 But it works.
02First, you must save. This is the important part. You cannot restore what was never saved. This is true of sessions, of relationships, and of documents you were editing when the power went out.
▸ hypr-recall save
That's it. One command. hypr-recall will ask Hyprland — very politely,
via IPC — what windows you have open, where they are, and how wide each
column is relative to your monitor. It will write all of this to a small
JSON file at ~/.local/share/hypr-recall/session.json and
then it will stop running, because it is a CLI tool and not a daemon
and it has boundaries.
The resulting file looks something like this:
{
"version": 1,
"active_workspace": 2,
"workspaces": [
{
"workspace": 1,
"windows": [
{ "class": "com.mitchellh.ghostty", "exe": "/usr/bin/ghostty", "col_width": 0.493 },
{ "class": "dev.zed.Zed", "exe": "/usr/bin/zed", "col_width": 0.493 },
{ "class": "com.mitchellh.ghostty", "exe": "/usr/bin/ghostty", "col_width": 0.493 }
]
},
{
"workspace": 2,
"windows": [
{ "class": "firefox", "exe": "/usr/lib/firefox/firefox", "col_width": 0.989 }
]
}
]
}
You can look at this file. You can edit it. It is just JSON.
It will not judge you for opening it in Vim even though you have
Zed installed. Use hypr-recall edit to open it
in $EDITOR if you'd like the program to judge you
on your behalf.
Now the fun part. You've restarted. Your screen is empty and a little bleak. You type:
▸ hypr-recall restore
hypr-recall reads the session file. Then, with the quiet determination of someone who has a list and intends to follow it, it begins.
For each workspace: it focuses that workspace, then launches
each app in the saved left-to-right order. It doesn't just fire
them all off and hope for the best — it waits.
Specifically, it subscribes to Hyprland's IPC event socket and
listens for openwindow events.2
When your window appears, hypr-recall knows. It is, in this sense,
watching.
Once all the windows for a workspace are open, hypr-recall sorts
them into the correct column order using a series of
swapcol l dispatches — essentially sliding columns
leftward, one by one, until everything lines up.3
Then it resizes each column to its saved width ratio.
Finally, it focuses whichever workspace you were on when you saved. Then it stops. It has done its job.
Sometimes the binary you saved isn't there anymore — an update moved it,
a versioned install path shifted underneath it, whatever. hypr-recall
doesn't take this personally. It looks for a .desktop entry
matching the window's class and launches that instead. If nothing
resolves either, it prints a warning and skips just that one app,
rather than throwing up its hands and abandoning the entire restore
over a single missing Discord.
overlay = false in the config file if you find it unsettling.
If you'd like to know what would happen without committing
to the bit, there is --dry-run:
▸ hypr-recall restore --dry-run
workspace 1 (3 windows)
com.mitchellh.ghostty → launch 2
dev.zed.Zed → launch 1
discord → SKIP (binary missing, no .desktop match)
workspace 2 (1 window) ← active
firefox → skip (1 already open)
Note that Firefox was already open, so hypr-recall would skip it.
hypr-recall is not in the business of opening duplicate windows.
There are enough of those already. Discord, meanwhile, gets the
capital-letter SKIP — its recorded binary is gone and no
.desktop entry could stand in for it, so the dry run tells
you now instead of letting you find out mid-restore.
Sometimes you do not want the whole world back. You closed one
workspace by accident — the rest of your desktop is perfectly happy
where it is, thank you. For this there is --workspace
(or -w, for the hurried):
▸ hypr-recall restore work --workspace 2
Only workspace 2 from the work session is rebuilt.
Everything else is left untouched — even the part where hypr-recall
chases down stray windows only ever herds them toward the one
workspace you asked about. It minds its own business, mostly.
Saving works the same way, and for the same reason: maybe you only reshuffled workspace 2 and would like the rest of the saved session to remain exactly as it was, thank you very much.
▸ hypr-recall save work --workspace 2
Only workspace 2 is re-captured and merged into the work
session file — the other saved workspaces are left precisely as they
were. Close every window on workspace 2 first and it is quietly removed
from the file instead, on the theory that an empty workspace is not a
workspace worth remembering.
By default a restore narrates only the broad strokes: which workspace
it is on, and when it has finished. But should something go sideways
and you wish to see every launch, every column nudged left, every
stray window herded home, add --verbose (or -v):
▸ hypr-recall restore -v
The diagnostics are murmured to standard error rather than standard
out, so hypr-recall restore -v 2>trace.log keeps your
terminal serene while quietly writing the whole confession to a file.
Or go the other way. A restore launched from hyprland.lua
on login has no terminal to narrate to anyway — its broad-strokes
progress lines just vanish into Hyprland's own log, unread and
unloved. Tell it to stop bothering with --quiet (or
-q):
▸ hypr-recall restore -q
Warnings and errors still get through — quiet mode only silences the happy path. A restore that goes well says nothing at all, which is, upon reflection, exactly what you asked for.
04Some applications — Firefox, Zed, Chromium — do not open a blank window when launched. They look at their saved state, sigh contentedly, and restore their own previous session. This is called being a session-restore app, and it is both a blessing and a source of subtle confusion.
The confusion: if you launch Firefox twice, you don't get two Firefoxes. You get one Firefox, mildly bewildered, possibly with a "Firefox is already running" dialog. Firefox is a singleton. It has opinions about this.
hypr-recall knows about this. For session-restore apps, it launches once and then waits for however many windows the app decides to open on its own — up to 20 seconds.4 If your app closes immediately after launching (which is what happens when a second instance hands off to the first), hypr-recall notices and extends the wait. This is called single-instance handoff detection and it works most of the time, which is as much as you can ask of anything.
If you have an app that behaves this way but isn't on hypr-recall's built-in list, you can tell it:
▸ hypr-recall restore --session-restore-app my.special.App
05
The most useful version of hypr-recall is one you never
have to think about. Put this in your hyprland.lua:
-- restore on login
hl.exec("hypr-recall restore")
-- save on shutdown, so you never lose more than the current session
hl.on("hyprland.shutdown", function()
os.execute("hypr-recall save")
end)
-- optional: save every 10 minutes, for the paranoid (affectionately)
hl.timer(function()
hl.exec("hypr-recall save")
end, { timeout = 600000, type = "repeat" })
With this configuration, hypr-recall runs quietly in the background of your life, watching over your workspaces with the calm vigilance of a library cat. You will forget it exists. And then one day Hyprland will crash — perhaps because you were compiling something ambitious, perhaps for reasons unknown to science — and when you log back in, everything will be where you left it.
You will feel, briefly, that the universe is on your side.
06
Some apps need a little extra coaxing. A flag here, a profile path there.
You can add a launch_args array to any window entry
in the session file:
{
"class": "firefox",
"exe": "/usr/lib/firefox/firefox",
"launch_args": ["--profile", "~/.mozilla/work"],
"col_width": 0.989
}
Edit the file once with hypr-recall edit.
Every subsequent restore will pass those arguments.
hypr-recall does not question why you need four Firefox profiles.
It simply obeys.
Alternatively — and this is the cleaner approach if you're sure the args will always apply — you can set them in the config file instead, and they'll survive even if you delete and re-save the session:
~/.config/hypr-recall/config.toml is optional.
If it doesn't exist, everything uses sensible defaults. If it does,
it looks something like this:
# ~/.config/hypr-recall/config.toml
overlay = true # overlay with workspace progress during restore
settle_delay_secs = 4 # wait for late-opening windows before stray sweep
# apps to treat as session-restore apps beyond the built-in list
session_restore_apps = ["my.electron.App"]
# per-app settings
[apps.firefox]
launch_args = ["--profile", "~/.mozilla/work"]
[apps.my.electron.App]
session_restore = true
Config-file launch args take precedence over anything in the session JSON. Unknown keys are rejected outright — a typo in the config file is an error, not a mystery.
At any moment you can ask hypr-recall what it knows:
▸ hypr-recall status
session saved 3 hours ago
3 workspaces · 8 windows
workspace 1 (3 windows)
ghostty 49% 49% 49%
workspace 2 (4 windows) ← active
firefox 99%
zed 49% 60%
workspace 3 (1 window)
ghostty 99%
The percentages are column widths. 99% means "basically fullscreen, but Hyprland insists on keeping a small gap because it has principles."
07
By default, hypr-recall saves to a file called session.json.
This is fine. One session, one name, the same file every time.
It has the advantage of simplicity and the disadvantage of not being
able to save your "work" layout separately from your "I am watching
a video while things compile" layout.
If you'd like more than one, you can give your sessions names:
▸ hypr-recall save work
hypr-recall: saved 'work' — 9 windows across 3 workspaces
▸ hypr-recall save gaming
hypr-recall: saved 'gaming' — 2 windows across 1 workspace
▸ hypr-recall restore work
hypr-recall: restoring 'work'
Each named session is stored as its own JSON file:
~/.local/share/hypr-recall/work.json,
~/.local/share/hypr-recall/gaming.json, and so on.
The default (no name given) remains session.json,
exactly as before.
To see everything you've saved:
▸ hypr-recall list
hypr-recall: saved sessions
work 2 min ago 3 workspaces, 9 windows
session 3 hr ago 2 workspaces, 5 windows
gaming 2 days ago 1 workspace, 2 windows
Sorted newest first. If you've never saved anything, it will tell you that too, gently.
And that, more or less, is everything.
Go save your session. Right now. Before you forget.
1. The background was removed by remove.bg, which is a website that exists for exactly this purpose and does it remarkably well. We tried ImageMagick first. The edges were not good.
2. Window detection is event-driven: hypr-recall subscribes
to Hyprland's IPC event stream instead of busy-waiting for windows to appear.
(It does still read back hyprctl clients between launches to count
windows, and it is not above a short settling delay while the compositor
catches up.)
3. This is insertion sort, technically. But "insertion sort" doesn't sound nearly as interesting as "sliding columns leftward, one by one."
4. Twenty seconds is a long time. If your app hasn't appeared in twenty seconds, something has gone wrong that is probably not hypr-recall's fault, but hypr-recall will tell you about it anyway.