Compare commits

...
Author SHA1 Message Date
ZacharyZhang-NY a9b39710a2 Add Simplified Chinese as a system language
The installer asks for the language before the keyboard, and
Setup > Language (omarchy language en|zh) switches it later. Under
zh_CN the menu reads its labels from omarchy-menu.zh-cn.jsonc, the
shell from I18nZh.js through I18n.tr, and notifications are worded
by their sender through omarchy-text, which reads the same table.
User text is never translated.
2026-10-12 02:56:40 -04:00
Ryan Hughes defbf90e89 Fully revert USB and Thunderbolt device approval (#15091)
* Revert "Merge pull request #11874 from acrogenesis/feature/usb-device-authorization"

This reverts commit 5cb31317b4, reversing
changes made to 9d48fc402e.

* Undo USB and Thunderbolt device approval on machines that ran it

* Verify boot rebuilds, recover Thunderbolt journals and prove USBGuard ownership in the removal migration

* Read the boot partition from ESP_PATH and stop on failed Bolt reads in the removal migration

* Recover Bolt's original mode from the setup journal in the removal migration
2026-10-12 01:20:01 -04:00
bjarneo 9480d1b6a9 Merge pull request #15065 from bjarneo/fix/bar-icon-consistency
Normalize bar icon size and alignment
2026-10-11 22:07:57 +02:00
Bjarne Oeverli 6575f06d53 Correct custom bar icon bounds 2026-10-11 22:02:37 +02:00
Bjarne Oeverli 201de1c0d6 Normalize bar icon size and alignment 2026-10-11 21:39:17 +02:00
David Heinemeier HanssonandClaude Opus 5.5 e71a816e90 Cache workspace layouts instead of running a script on every switch (#15028)
* Cache workspace layouts instead of running a script on every switch

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the workspace layout cache by running its logic, not matching its source

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the fake layout read running while its exit handler runs

Addresses Codex review: clearing running first let an undeferred, broken retry pass the cache tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:03:30 +09:00
David Heinemeier HanssonandClaude Opus 5.5 1b1cc5ecb5 Track the power profile through Quickshell instead of polling busctl (#15027)
* Track the power profile through Quickshell instead of polling busctl

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Recover the power profile when the daemon was down at shell start

Addresses Codex review: Quickshell never attaches its PowerProfiles listener if the daemon failed at startup. One-off reads on startup, power source changes and an IPC refresh from omarchy-powerprofiles-set cover that, still with no timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Don't let a stale power profile read win over a change signal or drop a refresh

Addresses Codex re-review: a busctl read in flight could overwrite a newer PowerProfiles signal, and refreshes requested while a read ran were dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test power profile transitions and stale reads as behavior, not source text

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:01:49 +09:00
David Heinemeier HanssonandClaude Opus 5.5 3c1f7b780f Refresh the passwordless sudo indicator on change instead of polling (#15026)
* Refresh the passwordless sudo indicator on change instead of polling

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refresh the sudo indicator after failed changes and on a wall-clock deadline

Addresses Codex review: a rolled-back renewal skipped the refresh, the monotonic user timer paused through suspend, and default timer accuracy allowed a minute's delay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the sudo indicator refreshes by running the grant flows

Replace source-string checks with runs of timed, renewed, failed, permanent and disable flows against the fixture, asserting the recorded omarchy-shell refresh and wall-clock systemd-run --user timer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:01:45 +09:00
David Heinemeier HanssonandClaude Opus 5.5 8bd1b7c045 Tell agents to react to events instead of polling (#15029)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:42:26 +09:00
David Heinemeier Hansson c4803d7d74 Update comment 2026-10-11 22:33:14 +09:00
David Heinemeier HanssonandClaude Opus 5.5 5f25ef1da8 Let Neovim's current line and status line show the terminal background on existing installs (#15024)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:30:32 +09:00
David Heinemeier HanssonandClaude Opus 5.5 88de9ecc6c Let herdr's tab bar show the terminal background, so it turns glassy too (#15022)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 22:25:16 +09:00
David Heinemeier HanssonandClaude Opus 5.5 1ac31e55ae Never leave the desktop without a wallpaper after a theme intro (#15000)
A theme switch that plays an intro gave the shell the new still only when
the intro failed. The shell kept the old theme's path, which the theme swap
deletes, and loaded it again when OWE handed the desktop back, leaving it
empty. The new still is now handed over either way.

The shell also re-reads the background link whenever OWE hands the desktop
back or a wallpaper fails to load, falling back to the theme's first
background when the link itself is broken.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:42:00 +09:00
David Heinemeier HanssonandClaude Opus 5.5 754d2d04b3 Only recover a session lock no other locker may hold (#15020)
Hyprland lets any client take over a held lock, so a second shell started
beside the desktop judged the real shell's live lock stranded, took it, and
then exited, leaving the session behind the failsafe while the real shell
still reported itself secure. Recovery now waits while any other Quickshell
or hyprlock runs, and gives up if one never exits.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:26:49 +09:00
David Heinemeier HanssonandClaude Opus 5.5 a98e55eb91 Pick window transparency from Appearance > Window Transparency, and a Glassy bar background (#14991)
* Pick window transparency from Appearance > Window Transparency

Transparent, the default, keeps today's windows. Opaque turns the windows
Omarchy makes transparent solid. Glassy is the frosted glass Starship
(#14726): its blur behind every see-through window and shell surface, and a
see-through, blurred background in the theme's terminal files, since foot
turns compositor blur down and a Hyprland opacity rule fades text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave window transparency to Appearance when animations are off

Turning animations off no longer makes windows opaque. VM installs start
with opaque windows instead, and a migration keeps them opaque for anyone
who already has animations off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add a Glassy bar background, under Menu Bar > Bar Background

Menu Bar > Bar Background picks Opaque Bar, Glassy Bar or Clear Bar, and
Indicators becomes Bar Indicators to match. Glassy keeps the theme
background faintly and Hyprland blurs what is behind it, with the same blur
as Glassy windows.

omarchy bar background opaque|glassy|none|cycle sets it and prints it when
given nothing. omarchy bar transparent true|false|toggle stays for
compatibility, as none and opaque. Double-clicking the bar cycles through
the three backgrounds the same way, from clear to glassy to opaque.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop when a saved corner choice can't be removed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Make btop follow glassy windows

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 21:21:00 +09:00
David Heinemeier HanssonandClaude Opus 5.5 be7c7bf378 Replace Xournal++ with Signed for signing PDFs (#14999)
Signed, Omacom's PDF signing app, takes Xournal++'s place in the base
install and the preinstall set, so Remove and Install Preinstalls cover
it too. A migration installs it on existing machines, unless their owner
has removed the preinstalls; Xournal++ stays on machines that already
have it. The Filling out PDFs chapter now walks through Signed, the GUIs
chapter introduces it, the setup tip points to it, and the unused
Xournal++ config is gone.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 10:57:16 +02:00
David Heinemeier HanssonandClaude Opus 5.5 4df545905d Pick window corners from Appearance > Window Corners (#14990)
* Expect Appearance before Toggle under Trigger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read the default workspace layout once for all the menu rows that ask

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pick window corners from Appearance > Window Corners

Theme Corners leaves them to each theme. Sharp and Rounded override every
theme through a Hyprland toggle, with Rounded using Solitude's corners.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the corner choice when saving a new one fails, and test the load order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 17:06:54 +09:00
David Heinemeier Hansson 3882c27cfd Better ordering 2026-10-11 16:11:18 +09:00
David Heinemeier HanssonandClaude Opus 5.5 5ec8bd300b Pick the bar clock format from Appearance > Menu Bar > Indicators > Clock
Every preset the clock cycles through gets its own row, labeled with how
it reads, and the current one is checked. omarchy-bar gains get <id> <key>
and a bare position to read the settings the rows compare against.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:10:33 +09:00
David Heinemeier HanssonandClaude Opus 5.5 eb1a34ecd0 Move Battery Percentage under Appearance > Menu Bar > Indicators
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:03:50 +09:00
David Heinemeier HanssonandClaude Opus 5.5 b6ceb8f4d6 Use the menu's animations icon in the toggle notifications
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 16:00:47 +09:00
David Heinemeier HanssonandClaude Opus 5.5 bef30c1c50 Move the Menu Bar from Style to Appearance, with Hide and Show (#14982)
* Match the menu test to the rearranged Trigger menu

Trigger now leads with Toggle and Appearance, and Appearance's entries,
now including Herdr Theme Sync, are in their new order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move the Menu Bar from Style to Appearance, with Hide and Show

Trigger > Appearance > Menu Bar now holds the bar's Position and
Transparency from Style > Menu Bar, and offers Hide while the bar shows
and Show while it is hidden, instead of a bare toggle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name the Menu Bar entries after the bar, and split transparency

Hide Bar, Show Bar, Position Bar, Top Bar and the rest read on their own
when searched. Transparency becomes Transparent Bar while the bar is
opaque and Opaque Bar while it is transparent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:59:25 +09:00
David Heinemeier HanssonandClaude Opus 5.5 18e059d9a4 Show the remote session indicator only during a remote session (#14981)
Hovering the indicators revealed it dimmed alongside the ones that can be
switched on, though there is nothing to do with it there. An indicator can
now opt out of that reveal, and remote session does.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:58:19 +09:00
David Heinemeier Hansson cf473b5c5d Rearrange menu items a bit 2026-10-11 15:50:04 +09:00
David Heinemeier HanssonandClaude Opus 5.5 d1a614ce66 Decide lock requests from the current lock state (#14978)
The locked binding once read true after an unlock, though the request,
the session lock, and its secure state were all false. A lock before
suspend then took the session for locked and did nothing, and the
machine slept unlocked. Lock requests, the status reply, and stranded
lock recovery now read those three values directly.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:37:45 +02:00
1498718969 Add a floating mode with titlebars to the workspace layout cycle (#14482)
* Let the workspace layout test's Lua assertions fail the suite

Lua 5.5's standalone interpreter exits 0 when the script that raised the error came in on stdin, and exits 1 for the same error in a script file. The block here was piped in through a heredoc, so every assertion in it reported as passing however wrong the value was: changing the expected layout to one the toggle never writes still left the file green.

* Add a floating mode to the workspace layout cycle

Super + L cycled a workspace between dwindle and scrolling. It now carries on into floating, where every window on the workspace floats.

Floating cannot be a third `layout` on the workspace rule. Hyprland has no layout by that name and accepts one anyway: `hl.workspace_rule({ layout = "floating" })` returns ok and silently leaves the workspace on dwindle, exactly as a misspelt name does. So the mode floats the windows themselves, through the same `float` dispatcher SUPER + T uses.

Ending the mode has to tile the windows the mode floated and only those, so each one is tagged with `omarchy-mode-floated`. A window already floating when the mode reaches it -- an app with a float rule of its own, a dialog, one popped out with SUPER + T -- is left unclaimed and is still floating when the mode ends. The tag lives on the window rather than in a table here: it dies with the window, follows it between workspaces, and survives the config reload that plugging in a monitor performs, none of which a table on this side would do. A pinned window is left alone entirely, because tiling one unpins it as well.

Windows arrive on a workspace three ways and a window rule only covers one of them, so the mode watches events: window.open for what opens there, window.move_to_workspace for what is carried in, and the same event for what is carried back out, which gives up the floating the mode gave it rather than staying floating on a workspace that tiles. Every tiled window is claimed before any of them is floated, because floating one member of a group floats the rest, and claiming as it went would find those already floating and leave them stranded.

The mode is read back from the saved file rather than from the compositor. Hyprland reports a workspace's tiled layout and floating is not one, so a floating workspace still answers dwindle or scrolling; trusting that answer would skip a step of the cycle. A file saved before floating existed names a layout rather than a mode and falls through to what Hyprland reports, so the cycle keeps working and the next press rewrites it.

Two faults in the old script had to go first, because saving the mode makes both persistent. Named workspaces report a negative id, which passed the `^-?[0-9]+$` guard, and Hyprland reads a leading "-" as a selector relative to the current workspace: pressing Super + L on a named workspace reconfigured workspace 1 instead, and would now save a file that did it again at every login. And the saved state was written under $HOME/.local/state while the Hyprland side reads XDG_STATE_HOME, which also had to reach package.path: the loader lists that directory but loads the files by module name, so a state home anywhere else listed the saved layouts and then loaded same-named ones from the home directory.

Saving is checked too. The mode reaching the compositor and the mode reaching disk are separate things that can fail separately, and a save that failed silently would leave the next press reading a file that never got written, cycling between two modes and never reaching the third.

The guard against named workspaces stops new files being written, but an earlier version already wrote them, and they go on being loaded at every login. A migration removes the saved layouts named for a negative id.

The keyword fallback is gone. `hyprctl keyword` refuses a Lua configuration outright -- "keyword can't work with non-legacy parsers. Use eval." -- and exits 0 while doing so, so as a fallback it neither worked nor reported that it had not. A failed apply now says so and saves nothing, rather than announcing a layout the screen does not show and restoring it at the next login.

Co-Authored-By: Codex XHigh <noreply@openai.com>

* Add titlebars, edge snapping, and a window Shelf to floating workspaces

Floating workspaces become a traditional desktop that works with every
theme. Windows get themed titlebars from the omarchy-hyprland-titlebars
plugin, which is loaded only while something floats. Dragging a titlebar
to the left or right edge snaps the window to that half, and dragging it
to the top or double-clicking maximizes it. The window floating mode
places is sized, centered, and cascaded, and floating workspaces focus
on click.

Super + M, or the minus button, sets a window aside on the Shelf, which
works from any workspace and restores the window onto the current one
in that workspace's layout. Float All Workspaces makes floating the
default while Super + L still sets a single workspace's layout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Bring resize on border to tiling too

* Fix floating mode cleanup, fullscreen windows, and failed Shelf notes

Turning Float All Workspaces off with no workspace layouts left behind
started nothing to give the floated windows back. A window fullscreen
when its workspace began floating now floats once it leaves fullscreen.
A window whose Shelf note cannot be saved stays where it is, and the
titlebars read the theme from where omarchy-theme-set writes it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Place the Shelf on the bar once, instead of on every minimize

Putting it back on each minimize kept a Shelf the user moved where they
put it, but re-added one they had removed. A migration places it once for
existing bars; Super + Alt + M opens the Shelf either way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Raise titlebars off the window background

A titlebar in the theme's background color ran into the window under it.
It takes the theme's lighter background, the raised surface every theme
defines, and the selection color on themes that make the two the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop minimizing and edge snapping from floating workspaces

A floating workspace is now titlebars with maximize and close, and nothing
else. The Shelf (its bar widget, panel, commands, notes, Super + M and
Super + Alt + M) goes with the minimize button, and the titlebar plugin's
edge snapping is left off, along with the shell's snap preview.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stop describing the titlebar package as snapping

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Name web app windows in their launchers

Chromium names a web app window after its URL, and titles it with the page
title or just the domain. StartupWMClass ties the window to its launcher,
so the titlebar plugin can show the launcher's Name instead: WhatsApp
rather than web.whatsapp.com. New web apps, the shipped ones, and existing
launchers (through a migration) all carry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the titlebar package comment from the base list

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show the workspace layout in the bar and cycle it on click

The focused workspace's mark is a square for dwindle, a triangle for
scrolling, and a circle for floating, and clicking it cycles the layout
as Super + L does. Both layout toggles ask the bar to read the new mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show scrolling as a circle and floating as a triangle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move the look of the desktop to Trigger > Appearance

Menu Bar, Battery Percentage, Workspace Layout, Window Gaps, Animations,
and 1-Window Ratio change how the desktop looks rather than switching a
behavior, so they leave Trigger > Toggle for their own menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Choose a default workspace layout instead of floating everything

Trigger > Appearance > Workspace Layout sets the layout of every workspace
without one of its own: Tiling, Scrolling, or Floating. A tiled default
is Hyprland's own layout, and a floating one is the floating mode. It
replaces Float All Workspaces, which also overrode and set aside the
layouts workspaces had been given with Super + L.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Title the workspace layout menu Default Workspace Layout

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use the animation icon for Appearance > Animations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Open clipboard history from Trigger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave edge resizing to looknfeel on floating workspaces

looknfeel now resizes from borders everywhere, so floating workspaces
only need to switch pointer focus to click.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Convert saved workspace layout rules to modes

Released versions saved a workspace's layout as a plain workspace rule,
which names no mode, so a floating default would override a workspace
set to scrolling, and the bar and Super + L would misread it. A migration
rewrites those files as modes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cycle workspace layouts backwards with Super + Alt + L

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the Trigger > Appearance split and Clipboard History placement

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Decode escaped web app URLs before naming their windows

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Describe the workspace widget's layout mark and click

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let a workspace cycled onto the default follow it again

Once Super + L gave a workspace a layout of its own, it kept it for good,
so a later change of the default never reached it. Cycling onto the
default layout now forgets the workspace's own instead of saving it.
Without a saved default, the default is Hyprland's own layout, which
the user's looknfeel may have set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 08:20:45 +02:00
David Heinemeier HanssonandClaude Opus 5.5 c09ddcff90 Move the look of the desktop to Trigger > Appearance (#14973)
* Move the look of the desktop to Trigger > Appearance

Menu Bar, Battery Percentage, Workspace Layout, Window Gaps, Animations,
and 1-Window Ratio change how the desktop looks rather than switching a
behavior, so they leave Trigger > Toggle for their own menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use the animation icon for Appearance > Animations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Open clipboard history from Trigger

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Test the Trigger > Appearance split and Clipboard History placement

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:17:33 +02:00
David Heinemeier HanssonandClaude Opus 5.5 dc50bbe574 Switch out of an input method and back with a lone Shift (#14975)
* Switch out of an input method and back with a lone Shift

Fcitx's alternate trigger only acts while an engine like Pinyin is or was
active, so Shift + letter and plain Latin typing are unaffected. It needs
Shift releases intact, which the both-Shifts Caps Lock option prevented.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Follow XDG_CONFIG_HOME in the Shift migration and test it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 15:15:03 +09:00
35a55fd236 Remove redundant agent guidance in user customization skill (#5925)
* Remove skill body trigger conditions; bolster frontmatter content.

* Keep window behavior and screen recording in the skill's triggers

The removed "When This Skill MUST Be Used" section listed both, and the description that replaces it had dropped them, so a request to record the screen or change how windows behave no longer matched anything the agent reads before choosing the skill.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex Medium <noreply@openai.com>

---------

Co-authored-by: omarchybot <omarchybot@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Codex Medium <noreply@openai.com>
Co-authored-by: Omarchybot <317366263+omarchybot@users.noreply.github.com>
2026-10-11 07:45:22 +02:00
David Heinemeier HanssonandClaude Opus 5.5 10f1cbf7cf Install clang with the Rust dev environment instead of for everyone (#14967)
Nothing in the base install builds with clang: Qt apps and Ruby's native
gems compile with gcc, and Ruby comes prebuilt from mise. Rust crates that
use bindgen, including rb-sys gems, need libclang, so the Rust dev
environment installs it. llvm was unused even there.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 13:37:23 +09:00
d74d6ea249 Build the calendar month grid in UTC (#10476)
* Build the calendar month grid in UTC

The grid walked a cursor at local midnight and stepped it with setDate(getDate() + 1). Chile starts DST at 24:00 on the first Saturday of September, so local midnight of 6 September 2026 does not exist, and Qt's V4 engine resolves a missing local time backward -- onto 5 September 23:00, the same calendar date, which the next iteration reads again. September 2026 draws 30 31 1 2 3 4 5 and then 5 6 7 8 9 10 11, and every later date sits one weekday early. Cuba, Lebanon, the Azores and Egypt change over at 00:00 too and break the same way in their own transition month; where the gap lands on a leading padding cell instead, the whole six-week grid shifts by a day with no duplicate in it.

Node resolves the identical gap forward, so the suite has never seen this: running the shipped monthGrid under Node across all 598 tzdata zones for 2020-2035 reports nothing, while the same code under the QML engine breaks 402 grids. A sequence of civil dates never needed a wall clock, so it walks UTC, where every day is 24 hours long, and the test pins that by building the grid with a Date whose local constructor and accessors throw.

stepMonth reads a month back off the same kind of date. No zone puts a midnight gap on a first of the month, so it is not broken today, but it moves with the grid rather than waiting for a tzdata release to make it a second bug.

Co-Authored-By: Codex XHigh <noreply@openai.com>

* Name the calendar heading's month from local noon

The heading built a local midnight on the first of the month, and that midnight does not always exist: Cuba started DST at 00:00 on 1 April 2012, so in Havana the QML engine resolves it back onto 31 March and the heading reads March 2012 above an April grid. Noon exists on every first of the month there has been, and keeping it local is what makes Qt.formatDate print the right month in every zone.

The Apia case comes from #13186: Samoa skipped 30 December 2011 entirely, so unlike a one-hour midnight gap, Node's V8 cannot resolve its way past it, and the test fails on the old grid under Node instead of only in the shell.

Co-Authored-By: Codex Medium <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Codex XHigh <noreply@openai.com>
Co-authored-by: Omarchybot <317366263+omarchybot@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 06:14:23 +02:00
David Heinemeier HanssonandClaude Opus 5.5 3609968a42 Stop shipping OBS Studio and Kdenlive to new installs (#14962)
Monologue records and Omacut trims, which covers what most people used
them for. Existing installs keep both; there is no migration, and the
preinstall removal no longer treats them as Omarchy's to uninstall.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 12:57:28 +09:00
David Heinemeier Hansson c1096aa36f Refresh all default theme previews (#14944) 2026-10-11 12:50:06 +09:00
ccf263611d Correct omarchy-pkg-add AUR claim in AGENTS.md (#13895)
* Correct omarchy-pkg-add AUR claim in AGENTS.md

* Say omarchy-pkg-add installs from the pacman repos, not the official ones

It runs plain pacman -S, which also reaches the [omarchy] repo every shipped pacman.conf enables, so "official repos" would send an agent to the AUR helper for Omarchy's own packages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex Medium <noreply@openai.com>

---------

Co-authored-by: Omarchybot <317366263+omarchybot@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Codex Medium <noreply@openai.com>
2026-10-11 05:33:32 +02:00
stephenlentonandClaude Sonnet 5 4d3a85969a Clarify that omarchy-toggle-bar's on/off tracks the bar-off flag (#11803)
on hides the bar and off shows it, matching the literal flag name
rather than an inferred "bar visibility on" reading. The behavior is
correct and depended on by omarchy-toggle-fullscreen-desktop; this
just makes the --help summary say so.


Claude-Session: https://claude.ai/code/session_01YQK8wd7WU8qEVnXVnNmcSW

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-11 05:32:58 +02:00
a3d4bea9d0 docs(agents): add plugin UI safety baseline (#7792)
* docs(agents): add plugin UI safety baseline

Document safe rendering and validation of dynamic plugin data after a marketplace review surfaced a QML AutoText boundary.

Assisted-by: Codex/GPT-5

* docs(agents): allow consented background refreshes

Clarify that plugin networking may refresh periodically when disclosed and user-controlled.

Assisted-by: Codex/GPT-5

* docs(agents): keep plugin guide label concise

Leave the safety baseline discoverable within the plugin guide without expanding its topic index label.

Assisted-by: Codex/GPT-5

* docs(agents): point plugin authors at their own markup helper

Omarchy ships no markup-neutralizing helper, and this skill is for installed systems, where the parent SKILL.md forbids editing the packaged shell components the old line suggested extending.

Co-Authored-By: Codex Medium <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Omarchybot <317366263+omarchybot@users.noreply.github.com>
Co-authored-by: Codex Medium <noreply@openai.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 05:17:02 +02:00
kevin koontz 9d47dd1497 Point the plugin manual at plugins.omarchy.org (#10499)
The marketplace moved to the official host on 30 Aug 2026. omarchyplugins.com redirects, but the published manual should name the current URL.
2026-10-11 05:00:53 +02:00
Vtorrealba 77b13fd026 Fix typo on 04_navigation.md (#7463) 2026-10-11 04:59:07 +02:00
Ed Schofield 9476e47a10 docs: add 2020 iMac models to the T2 Mac list (#10783) 2026-10-11 04:57:41 +02:00
Spencer Bull 9830142834 Merge pull request #14935 from omacom/startup-cursor-recovery
Recover the startup cursor across Hyprland reloads
2026-10-10 20:59:21 -05:00
Spencer BullandCodex XHigh 0bcb6a2559 Keep the default cursor alias on ordinary boots
Omarchy never sets XCURSOR_THEME, so asking GSettings on every cold boot replaced the "default" alias with whatever GSettings held, which is the schema default Adwaita when nothing was ever set. That overrode a theme chosen through ~/.icons/default and exported the substitute to every application. The saved theme and GSettings are now consulted only when recovering from the blank startup theme or an interrupted reveal.

Co-Authored-By: Codex XHigh <codex@openai.com>
2026-10-10 20:55:29 -05:00
David Heinemeier Hansson 04b374a989 Add Starship theme preview (#14942) 2026-10-11 09:15:47 +09:00
David Heinemeier HanssonandClaude Opus 5.5 1b5222be74 Return Caps Lock to normal and make Shift + Caps Lock the compose key (#14939)
* Return Caps Lock to normal and make Shift + Caps Lock the compose key

Caps Lock as compose, with both Shifts as Caps Lock, confused too many
people. Stock XKB has no option for compose on the shifted Caps key, so
Omarchy defines one in /etc/xkb, which libxkbcommon reads before the
system rules. The explicit actions keep the Shift level from toggling
Caps Lock. Lone Shift is no longer part of Caps Lock, which leaves it
free for input methods.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Require xkbcli so the keymap check cannot silently skip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 09:06:59 +09:00
David Heinemeier HanssonandClaude Opus 5.5 0b9dace018 Unify input methods with installer keyboard selection (#14433)
* Unify input methods with the installer keyboard selection

* Preserve inherited input keys and later keyboard changes

* Exercise input engines across desktop toolkits

* Show input modes in the keyboard indicator

* Keep Pinyin setup prompts visible

* Remember input selections before an app gains focus

* Remove the Japanese input welcome notification

* Show explicit choices on notification cards

* Suppress the Cloudpinyin first-use prompt

* Show the input switching shortcut in keyboard hints

* Verify input setup and composition stay quiet

* Use Super+I for input switching independently of Fcitx keys

* Restore linked input settings and attribute test notifications to Fcitx

* Correct the input setup shortcut hint

* Keep input tooltips to the name and switching shortcut

* Place the input language selector with the right bar widgets

* Show input switching only for multiple layouts or methods

* Unify input method and keyboard layout switching

* Select keyboard layouts and input methods in the Typing menu

* Apply typing selections immediately from the menu

* Use installer keyboard choices and keep typing selections accurate

* Queue typing changes and wait for saved selections

* Keep queued typing selections when reopening the menu

* Cycle keyboard layouts alongside composition engines

* Avoid shadowing Python typing and retain pending mixed input selections

* Keep input clicks working through controller and compositor failures

* Move the keyboard layout widget to the right side of existing bars

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Map keyboard choices to the layouts the installer sets

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the switching order when changing input methods

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Set input method fonts only after the selection saves

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restore confirmed picker checks when an immediate change fails

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Route Super+I through the shell's input reader so unfocused choices stick

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restore the input group when the font preference cannot be written

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the azerty conversion now that the installer no longer offers it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Apply the owner's keyboard layout once before persisting the input choice

A quattro merge kept a second apply_keyboard after the retry loop, which
re-ran loadkeys and firstboot, ignored their failure, and overwrote the
XKB layout setup supplies for keymaps kbd-model-map lacks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Give the Lao keyboard choice a Lao desktop layout

The row used la-latin1, kbd's Latin American Spanish keymap, so Lao
installs typed Spanish. There is no Lao console keymap; like Korean, the
console stays US and the desktop gets la, which Omarchy keeps behind US
as a non-Latin layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Load engines installed after Fcitx started when they are selected

The Typing menu refused an engine whose package an update had installed,
since Fcitx discovers engines only at startup. Restart input once and wait
for the engine; name the package when it really is not installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cycle input methods back with Super+Shift+I

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop notification action buttons now that the Pinyin prompt is suppressed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Cycle input only through the shell's reader

Without the shell there is no bar or menu either, so the one-shot cycling
path only duplicated the reader's logic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add engines from the command line through the Typing menu's path

omarchy setup input <engine> had its own restart, append, select and
live-rollback code in configure.py, parallel to the menu's. It now
installs the packages and appends through typing_setup, leaving
configure.py with install-time seeding and update defaults.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep Ctrl+Space out of Fcitx's toggles instead of probing for it

The terminal prefix bind read the Fcitx config and profile on every press
to avoid fighting a Ctrl+Space toggle. configure --defaults now removes
that key from every trigger list, so the bind only checks for a terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run the input-method scripts with the system Python

A bare python resolves through PATH, so a mise, pyenv or venv interpreter
could run them without the system's PyGObject.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 08:16:30 +09:00
Spencer BullandGPT-6.1-Sol XHigh f5170dd070 Recover the startup cursor across Hyprland reloads
Hyprland discards Lua globals and timers during reload, so the pending startup reveal must survive outside Lua state. Preserve the normal cursor theme for this compositor session, recover orphaned blank settings, and repair activation environments before future applications inherit them.

🤖 Generated by GPT-6.1-Sol High in Codex through T3 Code. Reviewed by GPT-6.1-Sol XHigh in Codex through T3 Code.

Co-Authored-By: GPT-6.1-Sol XHigh <codex@openai.com>
2026-10-10 17:17:28 -05:00
David Heinemeier HanssonandClaude Opus 5.5 f2a39c4dca Drop the Azerbaijani keyboard choice, which installed French AZERTY (#14877)
kbd's azerty keymap is French AZERTY and there is no Azerbaijani console
keymap. XKB's az moves letter keys, so pairing it with a US console would
type passwords differently at disk unlock than on the desktop.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 07:06:54 +09:00
Marcelo Alcantara 6fee7105af Re-check Bolt less often while there is no Thunderbolt controller (#14931)
Both Thunderbolt loops reconciled or rescanned every 2 s even on machines with
no controller: on an idle M2 Max that was about 1,590 process launches a
minute. While nothing is connected or boot-protected and no controller is
present, the daemon now reconciles every 10 s, inside a snapshot's 20 s
lifetime, and returns to every tick as soon as a controller, a policy change
or a boot recovery checkpoint appears. The watcher skips an unchanged, fresh,
device-free snapshot, and both loops wait without forking sleep.
2026-10-10 18:03:22 -04:00
David Heinemeier Hansson 77bfaca408 Place Elsewhen after the clock by default (#14870) 2026-10-10 15:50:15 +02:00
outfoxxed 077ac1da93 Merge pull request #14845 from omacom/fix-qt612-color
Patch all QtQuick imports to 6.11 to work around plugin Color bug
2026-10-10 03:33:00 -07:00
outfoxxed 51717f10ba Patch all QtQuick imports to 6.11 to work around plugin Color bug 2026-10-10 03:08:55 -07:00
Emir Beganović a15636b59e Merge pull request #14799 from omacom/fix/bash-startup-check-in-chroot
Find the caller of the Bash startup check the way /proc numbers it
2026-10-10 07:51:58 +02:00
Emir Beganovic 0801356155 Test the startup check from a subshell, with a decoy -p, and rejecting in a PID namespace
The rejection inside the new PID namespace ran without privileged mode, so
it never reached the caller lookup, and nothing would have caught a check
that accepted -p anywhere in the arguments.
2026-10-10 02:52:51 +02:00
Emir Beganovic dd05114e95 Find the caller of the Bash startup check the way /proc numbers it
The check read /proc/$$/cmdline. arch-chroot runs its command in a new PID
namespace beneath the outer /proc, where $$ names no process or someone
else's, so every bash -p entrypoint was rejected there with status 126.

The installer met this as soon as one was called at install time:
install/config/thunderbolt-authorization.sh runs
omarchy-thunderbolt-authorization-admin, and the edge ISO stopped in
"Configuring system" with "/proc/219/cmdline: No such file or directory".

The check runs in a child of the caller, so read the parent from that
child's /proc/self/stat, which is given in the numbering of the /proc it is
read through. Outside a PID namespace that is $$, as before.
2026-10-10 02:48:26 +02:00
Emir Beganović 7cc6a55f24 Merge pull request #14796 from omacom/fix/user-units-not-shipped
Enable first-run units one at a time, and test that units are shipped
2026-10-10 02:43:32 +02:00
Emir Beganovic 5489418c9d Require the unit copy to come before the enable, and compare first-run to a clean run
Naming a unit's source in default/ counted as installing it, so a command
that never copied the unit, or copied it after enabling it, passed. Look
for the install or cp of that source ahead of the enable instead.

The first-run test accepted any script that tried three of the units. It
now requires one enable --now per unit and, with each unit unknown in
turn, exactly the calls of a clean run minus that unit.
2026-10-10 02:27:56 +02:00
Emir Beganovic 55fc353e47 Enable first-run units one at a time, and test that units are shipped
The Thunderbolt approval watcher is enabled by its setup command, a
migration and first-run, but no package put it in a user unit folder and
nothing installed it. On edge the migration failed with "Unit
omarchy-thunderbolt-authorization.service does not exist", so every later
migration stayed pending. First-run failed too, and enabled none of its
units: given a list, systemctl enables nothing when one unit is unknown.

The units are now shipped by the settings packages (omarchy-pkgs#922).
Here, first-run enables its units one at a time, so a unit missing from a
build costs only that unit, and config-test fails when a unit under
default/systemd/user is enabled without being shipped or installed first.
2026-10-10 02:20:08 +02:00
+12 e1b0e5e9bb Converge omarchy-mac and omarchy-mx-mac into upstream Omarchy (#14431)
* Add omarchy-hw-platform to tell Apple Silicon and Qualcomm machines apart

aarch64 covers Apple Silicon Macs, Snapdragon laptops, Raspberry Pis and
VMs, so the CPU architecture can't pick platform setup. omarchy-hw-platform
prints apple-silicon, qualcomm, generic-aarch64 or generic from the vendor
prefixes in the device tree's root compatible (/proc/device-tree, then
/sys/firmware/devicetree/base) and the CPU, and fails when they contradict
each other. Its qualcomm answer uses the same evidence as the dragon
branch's omarchy-hw-qualcomm-soc, and the fixtures check the two agree.
omarchy-hw-apple-silicon is the Apple predicate on top of it.

An image built away from its hardware names its target in a root-owned
manifest, /var/lib/omarchy/image/target (format=1, platform=). While the
root is being built rather than booted, the detector answers from it and
never reads the build host's device tree; a booted system always answers
from its hardware. As root it restarts in an empty environment, so no
variable picks the platform for privileged setup.

Co-authored-by: Birk Skyum <74932975+birkskyum@users.noreply.github.com>
Co-authored-by: Jimmy Van Veen <8206925+JimmayVV@users.noreply.github.com>

* Reject platform gates keyed only on the CPU architecture

An architecture check is right for an ABI, a binary or a repository's
$arch, not for picking a platform. The lint lists each shipped check after
review and fails on a new one, so platform setup goes through
omarchy-hw-platform.

* Compose default packages from base, architecture and platform lists

omarchy-reinstall-pkgs installed the base list on every machine. A
platform's default set is now the base list, then install/omarchy-aarch64.packages
on aarch64, then the platform's own list when it has one
(install/omarchy-qualcomm.packages adds linux-firmware-qcom).
omarchy-pkg-defaults prints the set for the detected platform, or for the
one named, and reinstall installs it. x86_64 still gets exactly the base
list, and an image build gets its target's set.

* Early-load thunderbolt only where the kernel builds the module

linux-aarch64 has no thunderbolt module, and mkinitcpio fails the whole
image over a MODULES entry it cannot find. The optional form, thunderbolt?,
is not enough: a systemd initramfs copies MODULES as written into
modules-load.d, where "thunderbolt?" names no module, and a kernel without
the module would still list it there. thunderbolt_module.conf now adds plain
thunderbolt only when modinfo finds it as a module for the kernel being
built.

Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com>

* Compose mkinitcpio HOOKS from an early baseline drop-in

omarchy_hooks.conf assigned HOOKS outright, and mkinitcpio sources drop-ins in
name order, so it ran after any numbered drop-in (90-*.conf) and threw its
hooks away. HOOKS now comes from 00-omarchy-hooks.conf, which sorts first, and
later drop-ins add to it. The NVIDIA kms filter and the vconsole handling stay
in omarchy_hooks.conf. With the drop-ins Omarchy writes, machines keep the
same HOOKS, MODULES and FILES; a user drop-in that sets HOOKS and sorts before
omarchy_hooks.conf now takes effect instead of being overwritten.

Migration 1786605598 now sources the baseline first, since without it the
migration would read empty HOOKS and also rebuild hybrid systems. It skips a
machine that lacks the baseline.

* Resolve the LUKS parent from raw lsblk output

lsblk -s prints the ancestor tree with box-drawing prefixes ("└─/dev/nvme0n1p6"), so the crypto_LUKS parent came back as a path that does not exist and first-boot setup stopped with "Could not locate the LUKS device to re-key". -r prints raw names. Factory reset uses the same lookup.

* Check the factory-reset passphrase against keyslots only

cryptsetup open tries enrolled tokens before the key file, so a TPM2 or
FIDO2 token could authorize the re-key with a wrong passphrase.

* Test LUKS parent lookup during owner setup and factory reset

Exercise raw ancestry output without invoking provisioning or reset. Cover btrfs subvolume roots, absent encryption, failed probes, and explicit cryptdevice specifications.

* Cover LUKS parent lookup through an LVM volume group

* Journal the first-boot LUKS re-key so an interrupted attempt resumes

A first boot killed after the staged install key's slot was retired left the
key file behind, and every retry then stopped at "the staged LUKS key no
longer unlocks". A lost key file with the boot-time auto-unlock still
configured let setup finish with the disk unlocking unattended.

The re-key now runs from install/provisioning/luks-rekey.sh and records its
phase and slot numbers (never key material) in
/var/lib/omarchy/provisioning/luks-rekey.state. It reuses the owner's slot
instead of adding a duplicate, retires LUKS1 slots too, and destroys the
staged key only after the volume proves it opens nothing. Setup refuses to
finish while any part of the provisioning unlock remains. The journal ends at
phase=done and leaves together with pending, so a retry after the staged key
is retired must use the password the disk already holds.

Key checks exclude LUKS2 tokens: cryptsetup open tries an enrolled token
(say a TPM2 slot a previous owner left behind) before the key and reports its
slot whatever key it was given. Slot listings read only the keyslot section,
since a luks2-keyring token looks like a keyslot in luksDump.

* Change the root volume's LUKS key before the login and root passwords

Installation gives the login user, root and the disk holding / one password,
but omarchy-drive-password changed only the LUKS key, so the three drifted
apart. For the disk holding / it now checks the current password opens the
disk, changes the LUKS key, confirms the new key opens the disk and the old
one no longer does, and only then sets the login and root passwords. Any other
encrypted drive changes its LUKS key alone. Every drive now asks for the
current password with gum instead of cryptsetup's prompt.

A journal in ~/.local/state/omarchy/drive-password.state (disk UUID, key slots
and phase, never a password, written with the re-key journal helpers) lets the
next run finish an interrupted change. The rerun accepts only the old key
(removing the slot the interrupted change added) or the new one (retiring the
old slot), so another key such as a recovery passphrase never becomes the login
password. luksChangeKey is judged by whether the new key opens the disk, not
by its exit status, and key checks exclude LUKS2 tokens, which cryptsetup
open would otherwise accept in place of any password.

Runs are serialised, cryptsetup calls hold a disk lock, a / whose LUKS
partition cannot be resolved fails closed, and passwords are never traced. A
new password equal to the current one is refused, and --pbkdf argon2id is
passed only to LUKS2, so LUKS1 drives can change their password again.

* Add omarchy-lifecycle-dispatch for platform boot operations

Provisioning, factory reset and update call a fixed set of operations
through it. A platform whose boot chain the generic Limine path can't drive
registers a directory of root-owned entrypoints in the dispatcher; every
other platform gets a no-op and keeps its generic path. Apple Silicon is
registered to omarchy-mac-boot's /usr/lib/omarchy/mac-boot, with its
required operations failing clearly when the package or an entrypoint is
missing. docs/lifecycle-dispatch.md is the contract, including how a
Qualcomm boot package would plug in.

* Let the platform's boot package own first-boot setup's boot chain and key slots

Owner provisioning asks provision-prepare before the owner form, hands the
staged unlock to provision-commit and provision-verify where the platform
implements both, and records the kept LUKS slot through luks-slots before
the staged key is destroyed; the disk password change records the owner's
new slot the same way. On x86, Snapdragon and generic aarch64 every call is
a no-op and first boot is unchanged.

* Reset through the platform's boot package where it owns the boot chain

Where a platform implements the reset operations, reset-prepare and
reset-verify stage and prove the factory root's boot files, the throwaway
slot is added only after that, and reset-commit gets the key on standard
input once the factory root is active. A failure before the switch rolls the
boot state back and revokes the slot. Elsewhere the Limine UKI path is
unchanged.

* Check the platform's boot chain after an update

omarchy-update-boot runs update-verify after AUR packages, the last
sudo-capable step, so it also covers the initramfs rebuilds they trigger.
It follows third-party build code, so like AUR it authenticates through the
no-update wrapper. A failed verification finishes the update's remaining
steps, then exits non-zero without offering the reboot. Where the platform
does not implement it nothing runs and nothing asks for root.

* Hand a machine to its platform's migration through lifecycle dispatch

migrations/1790347292.sh resolves the new optional migrate operation and,
where the platform's boot package implements it, runs it as root. The
entrypoint moves the machine onto the platform's official package set,
idempotently and resumably; its failure leaves the migration pending. Once
it succeeds, a root-owned marker spares other accounts the migration and its
root prompt. Everywhere else, Macs without the boot package included, the
migration is a no-op, and an undetermined platform keeps it pending.

* Refuse packages tagged for another platform in a pacman hook

A platform package names its platform with groups=('omarchy-platform-<name>'),
<name> being what omarchy-hw-platform prints. omarchy-settings ships a
pre-transaction hook, the guard it runs and a copy of the detector, so the
guard is resident before the runtime and any platform package. The guard
reads tags from the synced databases and refuses a transaction that installs
a package tagged for another platform; a transaction without a tagged
package passes without asking which machine this is. Image builds answer
from the image's root-owned target manifest, as the detector does.

The guard ships on aarch64 only: no platform package is built for x86_64,
so x86 transactions run no hook. Hardware setup starts by proving the guard
is resident and checking the packages an installer placed without it. On
generic aarch64, where no platform packages exist, a missing guard is
logged, not fatal, and on x86 it is expected.
docs/platform-guard.md is the contract.

Co-authored-by: Birk Skyum <74932975+birkskyum@users.noreply.github.com>
Co-authored-by: Jimmy Van Veen <8206925+JimmayVV@users.noreply.github.com>

* Enable Voxtype's Vulkan backend only when it is installed

omarchy-hw-vulkan reports a Vulkan runtime, not whether this voxtype build
has a Vulkan backend. The aarch64 voxtype-bin package ships only the CPU and
ONNX binaries, so on any aarch64 machine with a Vulkan driver setup ran
`voxtype setup gpu --enable` and printed "Vulkan backend not installed" in
the middle of an otherwise working install. Require the backend binary too.

x86_64 voxtype-bin always ships /usr/lib/voxtype/voxtype-vulkan, so x86
installs still switch to the GPU backend as before.

Ported from omacom/omarchy-mac#483.

Co-authored-by: Oliver Lukschander <33756270+oliverlukschander@users.noreply.github.com>

* Link the system Widevine CDM into Brave

The widevine package registers its CDM for Chromium and Firefox only.
Brave looks for the CDM in its own install directory, finds nothing, and
DRM sites (Netflix, Apple TV+, Spotify) fail with a blank player and no
useful error. This hits machines where Widevine comes from that package,
such as aarch64 ones, since no Widevine component is published for them.

Link /opt/WidevineCdm/chromium into /opt/brave-bin and /opt/brave-origin-bin
through as_root when Brave or Brave Origin is installed, and add a migration
that repairs existing installs. Both do nothing when the widevine package is
absent or the browser already has a CDM. A standard x86 install has no
widevine package, so nothing changes there.

Ported from omacom/omarchy-mac#474, extended to Brave Origin.

Co-authored-by: Caleb Marshall <68087341+cajomar@users.noreply.github.com>

* Report failed setup steps on the console

With file logging, run_logged sends a failing leaf's output and its
"Failed:" marker to the install log only, so the caller sees setup stop
with an exit status and nothing else. A missing command (exit 127) was the
case that made this hard to diagnose.

Print the failed script, its exit status and the log path on stderr. Stdout
logging mode already shows the failure marker and is unchanged, and
successful steps stay quiet.

Ported from omacom/omarchy-mac#389 (the logging part).

Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com>

* Let a migration defer itself without stopping the update

A migration that exits 75 changed nothing and cannot finish yet: omarchy-migrate
leaves it pending with its login notification, runs the migrations after it and
exits 0, so omarchy update carries on. Any other failure still stops the queue.
The platform migration passes a deferred refusal of its entrypoint through, and
defers itself where the dispatcher can't resolve its entrypoint (the platform
can't be told, or the entrypoint isn't trusted): nothing has run then, so the
update goes on to its boot verification, which fails there, as documented.

* Keep Apple Silicon's systemd initramfs on the HOOKS baseline

00-omarchy-hooks.conf set the busybox line on every machine. On a Mac,
omarchy-mac-boot's drop-ins then add the Asahi firmware hooks, but its
encryption drop-in reads busybox encrypt as a Mac unlocked through
cryptdevice= and leaves the line alone. The image came out as
"... block encrypt asahi omarchy-vendorfw filesystems ...", with no
systemd, sd-encrypt or omarchy-mac-encrypt, so an encrypted Mac could not
unlock its disk. Reproduced against an M1 Pro's installed drop-ins.

The baseline now asks omarchy-hw-platform. Apple Silicon starts from the
systemd line its boot package builds on. A Mac whose own mkinitcpio.conf
carries the asahi hook, or busybox encrypt, keeps that line, as a legacy
install needs. x86, Snapdragon and generic aarch64 keep today's busybox
line, as does a machine with no detector. The detector is the runtime's,
or else the copy omarchy-settings ships for its platform guard, so a
settings package newer than the runtime still places a Mac. A detector
that cannot place the machine stops the build.

The HOOKS test now composes the baseline on each platform and on legacy
Mac lines. A new test composes it with omarchy-mac-boot 20260926-1.45's
drop-ins, kept as fixtures, and checks the exact HOOKS the M1 Pro builds.
The NVIDIA test pins its platform.

* Hide optional installs that have no package on this machine

Not every optional install exists on every architecture: some vendors ship no
aarch64 build, and some repositories don't carry a package at all. Each
Install row now carries a `when:` guard naming every package its install
command adds, checked with omarchy-pkg-available, so a row with nothing to
install disappears instead of failing halfway through a pacman transaction.
The rows that build from the AUR (Chrome, Edge, Brave, Brave Origin, Zen,
NordVPN) are guarded on the CPU architectures their vendors publish for.

omarchy-pkg-available asks the sync databases: one `pacman -Slq` per batch,
and `pacman -Sp` for a name that list doesn't hold, so a provided name
(libappindicator-gtk3) and a version constraint resolve the way `pacman -S`
will. The menu's guard prelude sources the same function, so the whole batch
still costs one fork. omarchy-reinstall-pkgs reports and skips a default
that no configured repository offers instead of failing the transaction.

On x86_64 every guarded row resolves against the stable and edge
repositories, so the menu is unchanged there.

Tests derive each row's packages from its install command on x86_64 and
aarch64 and fail when the guard names different ones.

Co-authored-by: Ryan Hughes <1630358+ryanrhughes@users.noreply.github.com>

* Run 1Password with software GL where there is no render GPU

Electron draws a blank window when the machine has no DRM render node: a VM,
a headless or passthrough GPU, or a GPU whose driver isn't up yet (Apple M3
and later on Linux today). omarchy-hw-render-gpu tells whether a renderD node
exists, and omarchy-cmd-electron-gl-args prints --ozone-platform=wayland
--disable-gpu only when it doesn't.

omarchy-cmd-electron-gl-wrap installs a marked PATH wrapper in /usr/local/bin
that execs the real binary with those flags, so it follows the machine rather
than the moment it was installed. It refuses to replace a launcher it didn't
write, or to wrap a binary over itself, and stages the write so a failure
leaves the old launcher in place. omarchy-cmd-desktop-exec-repair points the
user's desktop entry at the wrapper by replacing only the executable token,
keeping arguments, other actions and customizations, with a backup.

The 1Password installer uses them when the machine has no render node. With
one, which is every x86 machine with a working GPU, nothing changes.

Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com>

* Record with wf-recorder when gpu-screen-recorder can't start

gpu-screen-recorder knows NVIDIA, AMD, Intel and Broadcom GPUs and aborts
during GL init on anything else, such as Apple Silicon's, before a recording
starts. Screen recording now falls back to wf-recorder, when it is installed,
if gpu-screen-recorder is missing or exits before the file appears. The
picker, file naming, notifications and post-processing are shared. wf-recorder
records one audio device, so desktop plus microphone go through a temporary
null sink that stop unloads, and its track is pinned to 48 kHz. Portal
capture and --resolution stay gpu-screen-recorder only.

The finalize pass keeps 48 kHz too: loudnorm declares 192 kHz output, so
without -ar the AAC encoder wrote 96 kHz from a 48 kHz recording, which many
editors decode as silence, and which on Apple Silicon makes the speaker
safety daemon lock the amps. #11092 makes the same one-line change; whichever
lands second drops it.

Stop and status matched `pgrep -f ^gpu-screen-recorder`, which misses
wf-recorder and matches any command line that starts with that path. The
script now records the recorder's pid, and the new
omarchy-capture-screenrecording-process checks or signals it by its
executable, through a pidfd opened before the check so a reused pid can't be
signalled. When that pid is no longer a recorder (a recording started before
this change, or one that died), every recorder the user runs counts, as it
does for the bar indicator and the Stop row, which use the same helper.

Where gpu-screen-recorder works, which is every x86 GPU it supports today, it
records as before.

Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com>
Co-authored-by: Issac Trotts <15530+ijt@users.noreply.github.com>

* Hold logind's lid switch while a display it doesn't count is in use

systemd-logind treats a closed lid as docked only when an external display
is on a connector type it lists (HDMI, DisplayPort, DVI, VGA...). Displays on
"USB" connectors, which Apple Silicon uses for USB-C displays, aren't on that
list, and a missing comma fuses DVI-A and Composite into one entry. With such
a display in use, closing the lid suspended a laptop that was still being
used on it.

omarchy-system-lid-inhibit holds a handle-lid-switch inhibitor while one of
those connectors is connected and enabled in Hyprland, on a laptop where
HandleLidSwitchDocked is ignore, which is exactly the case logind would have
ignored had it counted the display. It releases the inhibitor when the display
goes. A compositor or logind that doesn't answer changes nothing, since
releasing then would suspend a closed lid. The inhibitor runs as a transient
user unit bound to the monitor watcher's unit, so a watcher that dies can't
leave the lid inhibited until logout.

The monitor watcher runs it alongside the clamshell sync. On a machine whose
external displays are all on connectors logind counts, it does nothing.

* Keep the focused workspace in front when a monitor goes away

When a monitor is unplugged, or the lid turns off the laptop panel, Hyprland
first warps focus to whichever monitor it lists first and only then moves the
removed monitor's workspaces over. The workspace you were on lands hidden
behind that monitor's own (Hyprland 0.56.2, CMonitor::onDisconnect).

monitor.focused fires before Hyprland records the new focus, so the monitor
still reported as active is the one being left: its workspace is remembered
until the event loop next turns, and a monitor.removed in that window focuses
it again once the removal has finished. An open special workspace already
comes along, and any focus or workspace change made before the restore runs
wins.

* Drive the keyboard backlight from the ambient light sensor

Laptops with an ambient light sensor and a keyboard backlight LED now light
the keys in the dark and dim them as the room brightens, inverse to display
auto-brightness. omarchy-brightness-keyboard-auto polls the sensor every five
seconds (preferring an IIO device that names itself ALS, and converting a raw
channel with its offset and scale) and maps lux onto the LED, with a deadband
so it doesn't chase noise. It pauses while the screen is locked or the lid is
closed.

A level set with the keyboard-brightness keys wins until the light changes
enough that it no longer fits. omarchy-brightness-keyboard records each level
it sets in the session's runtime directory (a private state directory without
one), so the loop can tell keys turned off on purpose from keys left off by
the lock screen or a restored zero, which it lights again.

The user unit runs it in the graphical session. Its ExecCondition asks
--available, so it is a no-op on machines without both a sensor and a
keyboard LED. First run enables it, and a migration enables it for existing
installs, writing the enable symlink itself when there is no live user
manager.

Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com>
Co-authored-by: Gabe Ohlson <3355909+6abe@users.noreply.github.com>

* Defer an image build's hardware setup to the machine's first boot

An image is built away from the machine it will run on, so its hardware
leaves would probe the builder. While the image manifest the detector
already reads (/var/lib/omarchy/image/target) exists, omarchy-apply-hardware
queues every leaf of install/hardware/all.sh instead of running it and arms
omarchy-provision-hardware.service. On the first boot, before owner setup
and the login screen, omarchy-provision-hardware retires the manifest and
runs the queue on the real hardware: a failed step stays queued for the
next boot (exit 75), and the initramfs is rebuilt once after the last step
when a step changed its inputs or asked for it. Bluetooth starts its
service on that boot, whose target is already up. Root ignores any
environment override of these paths. Without a manifest, as on every
ISO install, hardware setup runs exactly as before. Nothing here fetches
package databases: an image carries its sync databases and every package
its steps install.

* Give an image-built root a UTF-8 locale when LANG is still C

A root built from a distribution tarball instead of the ISO (Arch Linux
ARM's ships LANG=C) never went through the ISO's locale step, so it ran
non-UTF-8. The config phase now selects en_US.UTF-8, generating it if
needed, but only when LANG is unset, C or POSIX: any named locale is
somebody's choice and stays. The ISO writes the locale before this runs,
so an ISO install is unchanged.

Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com>

* Let a platform's packages run their own boot, system and user setup

Three dispatch operations give a platform one place for the setup it
needs beyond Omarchy's, instead of inline platform branches in the setup
leaves. The last hardware leaf runs setup-boot, then setup-system, as
root, so an image build queues them with the others and its first boot
runs them, with image-first-boot (offline-safe, asking for the one boot
rebuild instead of building). setup-boot belongs to the boot package and
is required on Apple Silicon, where hardware setup has no other way to
run the Mac's boot setup; a failed setup-boot stops the leaf before
setup-system. setup-user runs as the user being set up, refuses root,
and gets only the user's home and session variables: from the last user
leaf at finalization (skipped inside an image build, where the detector
would read the build host) and again from first run once the session is
up. On Apple Silicon setup-boot resolves in omarchy-mac-boot's directory
and the other two, both optional, in omarchy-mac's, /usr/lib/omarchy/mac;
x86, generic aarch64 and Qualcomm register none, so all three are no-ops
there.

A failed finalization now keeps first run pending, like any failed
first-run step, instead of letting the other steps mark it complete.

Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com>

* Find batteries, lids and panels that aren't named like a PC's

Laptop detection assumed PC names: a battery called BAT*, a lid in /proc/acpi, a
touchpad or trackpad in the device name, a GPU backlight. Apple Silicon has
none of them (macsmc-battery, the SMC lid switch, apple-mtp-multi-touch,
apple-panel-bl), and some Framework and Chromebook firmware calls its battery
CMB0, so those machines lost the battery panel, laptop-only services, lid
handling, the touchpad toggle and display brightness.

- Battery presence goes by type instead of name, and skips a battery scoped
  Device (a wireless mouse or keyboard), so a desktop with one isn't a laptop.
- Battery status takes the first UPower battery that powers the machine, reads
  thresholds and cycles from it (BAT* stays the fallback), shows the magnitude
  of a signed power_now, and rounds the percentage half-up for display like the
  bar, while the charge-hold check keeps the unrounded value.
- A laptop is also one with any input device that reports a lid switch
  (SW_LID), whatever its name: Apple's SMC input device has the same name on a
  Mac mini, where it reports none. Whether the lid is closed comes from ACPI
  where there is an ACPI lid, as before; otherwise logind is asked, with a
  short timeout, only when a device reports a lid switch.
- A device named touchpad or trackpad still wins; only when there is none does
  the touchpad match take a mouse udev marks as a touchpad (Apple's
  multi-touch trackpad), never a touchscreen.
- The platform package can describe its displays in
  /usr/share/omarchy-platform/displays.conf: backlights that never drive the
  panel (a Touch Bar's), the panel's own where there is no GPU backlight, and
  a display driver without DDC channels, where a probe walks unrelated I2C
  buses for about 3 s: there only a connector with a ddc node is probed, and
  a monitor without one reports no brightness instead of dimming the built-in
  panel. Omarchy ships no such file and names no device in it; without it the
  backlight choice and DDC probing are unchanged.

Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com>
Co-authored-by: Ryan Hughes <1630358+ryanrhughes@users.noreply.github.com>
Co-authored-by: Sparsh Kumar <22640187+skkumarsparsh@users.noreply.github.com>
Co-authored-by: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com>

* Keep the user's XDG config and state directories in platform user setup

setup-user runs with an allowlisted environment, which dropped
XDG_CONFIG_HOME and XDG_STATE_HOME. A platform's user setup reads the user's
configuration and keeps its setup markers there, so for a user who sets
either it looked in, and wrote to, the default directories instead. Both now
pass through with the rest of the session; XDG_DATA_HOME and everything else
still don't.

* Let a platform package add Hyprland defaults of its own

A platform's runtime package may need Hyprland defaults of its own (a Mac's
capture keys and three-finger workspace swipe, say), and Omarchy's config
should carry no branch for them. Three directories under the platform root,
/usr/share/omarchy-platform, now take them. The one installed platform package
owns that root, Omarchy reads it by literal path and ships nothing there, so
without a platform package nothing changes. Each file is loaded by its path,
so it can't shadow a module, and one that fails is logged and shown as a
notification while the rest of the config loads.

- hypr/defaults/*.lua loads before Omarchy's defaults. A
  chord bound there replaces Omarchy's default for it, and a decorator it adds
  runs before every later bind, so it can bind what has to run first. It sees
  the command a bind runs, including the command a shell shortcut such as
  { menu = "root" } stands for when it binds the shell's global shortcut.
- hypr/settings/*.lua loads after Omarchy's defaults and
  before the theme, for settings that replace Omarchy's.
- hypr/gestures/*.lua loads after the user's files, only for what must
  see them: the gesture registry, which now records on every platform, lets a
  default gesture step aside for the user's own.

The user's files load after the first two and can unbind or rebind any of it.

* Let a platform package name keys in the keybindings menu

The keybindings menu shows a key by its keysym, which on some keyboards is not
what the key says: on a MacBook, XF86MonBrightnessUp and Down are the F2 and
F1 keys, so the menu listed rows like "SHIFT + XF86MonBrightnessUp". A
platform package can now list names for such keys in /usr/share/omarchy-
platform/key-names, one "<keysym> <name>" per line, and the menu shows those
instead. The file is part of the menu cache's key, so installing or changing
it refreshes the menu. Without it the menu is as before.

Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com>
Co-authored-by: kx0101 <90087463+kx0101@users.noreply.github.com>

* Keep the top bar out of a panel's camera cutout

A laptop panel with a camera notch at its top (Apple Silicon MacBooks, for
one) covered the middle of the top bar, and a bar shorter than the notch left
a sliver of every window beside the camera. Omarchy knows no panel's cutout
itself: the platform's own package describes them in
/usr/share/omarchy-platform/display-cutouts.json, by
connector, physical mode and the rows the cutout covers. On a described panel
a top bar is never shorter than the cutout, its center section sits beside
the right one, and hiding it parks all of it; [bar] notch-height sets that
floor by hand, on that panel only. Without a description, which is every
machine without such a package, the bar is as before.

Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com>

* Keep PC and Intel Mac quirks off Apple Silicon

A few hardware steps and commands written for PCs and Intel Macs do the wrong
thing on Apple Silicon:

- The Intel Mac Wi-Fi quirk turns off the Broadcom firmware supplicant for
  every Mac whose DMI vendor is Apple and whose chip is on its list. Apple
  Silicon reports Apple Inc. through U-Boot's DMI tables, and the M1 Pro and
  Max's BCM4387 is on the list, but there the firmware supplicant is the one
  that works: turning it off makes firmware commands time out and breaks
  scanning. The install step and its migration now skip Apple Silicon.
- fnmode=2 for Apple-like keyboards is left to the Mac's own platform package
  on Apple Silicon, where the built-in keyboard's top row depends on it.
- The SPI keyboard check reads the DMI product name without failing the
  hardware pass where there is none.
- The Windows VM, an x86_64 guest under KVM, runs on x86_64 only, and direct
  boot (U-Boot finds the bootloader and makes its boot entries itself)
  refuses Apple Silicon; the menu hides both there. Hibernation setup skips
  it; its kernel has no hibernation today, and resume there is not wired.

Everywhere else each step runs as before.

Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com>

* Let a platform's runtime package adjust the apps it needs to

Some apps need a platform's own touch once they are installed. On Apple
Silicon, Chromium-family browsers must decode video in software (the hardware
decoder renders green frames in them), and Steam only runs through a
FEX launcher, which then depends on Steam and must go before Steam can be
removed. Until now the Mac runtime did this with inline branches in the
installers.

Two optional dispatch operations take their place. post-install <app> runs
as the user once an installer has installed the app and finished its own
setup of it: every browser install (after its flags file, before it says the
browser is installed) and Steam's (after the package, before the 32-bit
drivers and the first launch). pre-remove <app> runs before Steam's removal
drops its packages. Where a platform registers them they refuse root (a
platform with nothing registered is a no-op, so sudo installs still work
there) and get the same home and session variables as setup-user, and a
failure fails the install or stops the removal. On Apple Silicon they
resolve in omarchy-mac's directory; every other platform registers none, so
installs and removal run as before there.

Steam's 32-bit driver step also stops failing the install on a machine with no
Intel, AMD or NVIDIA GPU (Apple Silicon, but also Snapdragon or a VM): it has
nothing to add there and now says so.

Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com>

* Ask the platform's boot package before an update takes over its files

When an Omarchy package's upgrade stops only on files no package owns, the
update moves them aside and retries. On a Mac some of those files belong to its
boot chain or were written by its image (initramfs and pacman configuration,
say), and moving them aside can leave it unable to boot or update. The Mac
runtime refused those paths with an inline list.

update-takeover <path>... asks the platform's boot package instead, before
anything moves: read-only, exit 0 lets the takeover go ahead, anything else
keeps every file and the update fails as it did before the retry existed. The
update resolves it as the user and runs it with sudo only where it resolves,
inside the authorization the move needs anyway. On Apple Silicon it is
required: a Mac without its boot package, or with one too old to answer, keeps
its files. Every other platform registers none, so the takeover goes ahead as
before, asking no one.

* Install the Mac's package set on Apple Silicon

omarchy-pkg-defaults already composes install/omarchy-<platform>.packages
after the base and aarch64 lists, but Apple Silicon had no list, so a Mac got
the generic aarch64 set: no omarchy-mac or omarchy-mac-boot, whose entrypoints
the dispatcher requires there.

install/omarchy-apple-silicon.packages names the Mac's runtime and boot
packages, plus the defaults an owner may remove: the startup-volume tool,
hardware video decode, Widevine, and wf-recorder, since gpu-screen-recorder
can't capture on Apple Silicon and screen recording falls back to it. What a
Mac can't work without (its speaker stack, PipeWire's PulseAudio server and
realtime scheduling, the GPU's Vulkan driver) comes as omarchy-mac's
dependencies, so a new Mac driver needs no Omarchy change. Other platforms
compose exactly what they did.

Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com>

* Give existing aarch64 machines the zram swap Omarchy configures

omarchy-settings configures swap on zram and tunes memory reclaim for it.
x86_64 gets zram-generator from its installer and new aarch64 installs from
their default packages, but an aarch64 machine installed before
omarchy-aarch64.packages named it has no generator, so the configuration asks
for a device nothing creates.

Migration 1790328426 installs zram-generator where the platform's default
packages name it and it is missing, and starts the zram swap wherever it is
down, unless no device is configured (then the generator makes no swap unit)
or the administrator masked it. A swap that won't start, or whose unit can't
be read, only warns: the generator's unit starts with every boot, so a reboot
retries it, and failing the migration would stop every update. A failed
install waits (75) for the next migrate, as it does where the platform can't
be told, changing nothing. It keeps the name it has in omarchy-mac, so Macs
that already ran it skip it. It also retires an /etc copy of the drop-in
identical to the packaged one, which earlier Mac setups made and which would
hide the packaged file's updates; an edited copy stays. On x86_64 the default
packages don't name the generator and there is no copy, so it does nothing.

* Keep an ARM machine's repositories when it changes channel

omarchy-refresh-pacman and install finalization copied x86_64 templates
everywhere, so on an aarch64 machine a channel change or a finished install
wrote Arch Linux's x86 [core]/[extra]/[multilib] and dropped Arch Linux ARM
and the platform's own repositories.

Channel changes now tell the platform apart. x86_64 is unchanged. On aarch64
the machine's configuration is kept, with its repositories, mirrors and
signature policies, and only the [omarchy] server moves to the new channel;
a configuration whose Omarchy server can't be identified is refused
unchanged, and one with no Omarchy repository gets it at the end (not on
Apple Silicon, whose own repository must not come first). Only pacman.conf is
backed up and rewritten, straight from this process through sudo, never
through a file another process could change first.

Before that, a channel must be qualified for the platform: a signed upgrade
and reboot tested there, not only a published repository. x86_64 keeps
stable, rc and edge; Snapdragon and other aarch64 machines take edge, as
dragon does; Apple Silicon takes none until the Mac packages qualify on edge.
The refresh then checks, as the user, that the channel's omarchy.db
publishes the runtime pair, and on Apple Silicon omarchy-mac and
omarchy-mac-boot. omarchy-channel-set refuses an unqualified channel before
anything else, the dev confirmation included.

Install finalization writes a whole template, as before on x86_64. Other
aarch64 platforms get Arch Linux ARM (dragon's pacman-aarch64.conf and
mirrorlist-aarch64, byte for byte) with its keyring, plus Omarchy on a
qualified channel. Apple Silicon gets omarchy-mac's template on a qualified
channel and keeps its image's configuration otherwise. In an image build,
finalization leaves the keyring to the machine's first boot, so machines
flashed from one image don't share a pacman master key. On an unqualified
channel, a machine that gets no Omarchy repository is told why.

Co-authored-by: Birk Skyum <74932975+birkskyum@users.noreply.github.com>
Co-authored-by: Jim Martin <1334144+jdvmi00@users.noreply.github.com>

* Keep Arch Linux ARM's signing keys current on ARM machines

omarchy-update-keyring reinstalls archlinux-keyring before every system
upgrade, since keys change without a version bump. An aarch64 machine's Arch
Linux ARM repositories are signed with keys from archlinuxarm-keyring, which
nothing refreshed, so a rotated ALARM key failed the upgrade that followed.

On an aarch64 platform where archlinuxarm-keyring is installed, it is
reinstalled in the same transaction. On x86_64 the keyring step runs exactly
as before, even where that package happens to be installed.

* Mark the runtime's platform files as deciding at runtime

The omarchy-settings recipe leaves the mkinitcpio, Limine, memory and USB
drop-ins out of its aarch64 package, because they used to assume x86_64. That
leaves an aarch64 install without the HOOKS baseline its initramfs needs, and
without the zram and reclaim settings every other Omarchy machine gets.

Each of those files now decides at runtime whether it applies: the HOOKS
baseline asks the platform, thunderbolt is added only where the kernel has the
module, the Limine files are read only where Limine is installed, the oomd
drop-ins are inert without systemd-oomd, aarch64 installs the zram generator
the zram drop-in needs, and the USB autosuspend option is ignored by a kernel
that builds usbcore in. default/settings-runtime-profile says so, file by
file; a recipe that finds it ships the same files on every architecture
(omacom/omarchy-pkgs#638), and older sources keep today's aarch64 package. A
test checks that every file it names exists and that each runtime decision is
in place.

* Set the wireless regulatory domain from the owner's timezone at first-boot setup

A deferred install (an Apple Silicon image, an OEM ISO install) runs its
hardware steps before the owner picks a timezone, on the UTC placeholder,
which names no country, so install/hardware/set-wireless-regdom.sh leaves the
regulatory domain unset and the radios stay on the world domain. Owner setup
now runs that step again once the owner's timezone is set, and applies it to
the radios through wireless-regdb's helper, since no reboot follows setup. An
up-front install, which picks the timezone before its hardware steps, is
unchanged.

* Ask again for a keyboard layout first-boot setup could not apply

First-boot setup kept going when the chosen layout did not take: loadkeys
errors were ignored, a keymap localectl did not list kept the default, and a
failed or partial write of /etc/vconsole.conf was only logged. The owner then
typed the disk password in one layout while the boot files asked for it in
another, and could only unlock the disk by typing US-style.

The keyboard step now fails closed. On a console loadkeys must succeed, the
keymap must be in localectl's list exactly (a list localectl cannot produce
counts as a failure too, logged apart), and after systemd-firstboot or the
localectl fallback, /etc/vconsole.conf is read back in a clean shell and must
name the chosen keymap with an XKB layout. Any failure is logged, the owner is
told the layout could not be set, and the layout is asked for again, so the
password form is never reached with a layout that did not take.

* Refresh a platform's own signing keyrings before an update

omarchy-update-keyring reinstalls archlinux-keyring (and Arch Linux ARM's on
aarch64) before the system upgrade, so a rotated key never fails that
upgrade's signature checks. A platform's own repository has a keyring of its
own that nothing refreshed: on Apple Silicon, asahi-alarm-keyring for
[asahi-alarm].

A platform package now lists those keyrings in
/usr/share/omarchy-platform/keyrings, one package name per line, and
omarchy-update-keyring reinstalls each one that is installed in the same
transaction. It adds no repository the machine didn't already trust. A line
that isn't one keyring package name is skipped with a warning, so a mistake in
the list can't stop every update. Without the file, as on x86 today, the step
runs as before.

* Drive an untyped default input from the audio panel

Quickshell types only exact media classes, so a virtual source (an
Audio/Source/Virtual: EasyEffects' source, or the microphone mapping a
platform package adds) has no PwNode.audio. With one as the default input the
audio panel showed the input at 0% and its slider changed nothing, the peak
meter stayed flat, the microphone widget read it as muted, and the panel could
not list it unless it was already the default.

Such an input now goes through wpctl for its volume and mute (UntypedInput in
shell/Commons, one reader and one pactl subscribe for the whole shell), the
panel meters it with the new omarchy-audio-source-level, and the input list
takes an untyped node PulseAudio lists as a source.

A platform whose audio runs through its own processing can also name nodes
that are neither devices nor apps in /usr/share/omarchy-platform/audio.json,
which Omarchy ships none of: the panel and the microphone widget leave them
out, and a "replaced" node only while its replacement exists. Without the
file nothing is hidden.

On every machine: the input list leaves out a source whose ports are all
unavailable (an empty headset jack) unless it is the default, as the output
list does for sinks; choosing an input no longer moves a filter's own capture
(a source output with node.link-group), which would feed the filter from the
new input or from itself; the shell's own level meters no longer light the
microphone widget; and omarchy-audio-sink-availability reads pactl in the C
locale for sinks too, since "not available" is translated.

* Give first-boot setup an XKB layout for keymaps systemd cannot map

systemd-firstboot writes an XKB layout only for keymaps listed in
kbd-model-map. Nine the setup form offers are not (Colemak, Azerbaijani,
Bulgarian, Czech, Swiss German, Kyrgyz, Norwegian, Polish, Ukrainian), so it
wrote KEYMAP= alone, the readback refused it, and owner setup could never get
past the keyboard step for them.

Setup now adds the XKB settings itself when firstboot left none, matching the
console map so the shared password types the same on the desktop: kbd's cz is
QWERTY, bg-cp1251 phonetic and azerty French AZERTY, and non-Latin layouts keep
a US layout to switch to, as kbd-model-map's own rows do. A test checks every
layout the form offers ends with an XKB layout.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Leave keys a firmware key turned off dark

The ambient keyboard backlight treated any 0 the keyboard-brightness keys had
not recorded as an accident and relit it within five seconds. Firmware Fn keys
(Fn+Space on ThinkPads and Framework laptops, Fn+F10 on Dells) write the LED
directly, so on those laptops turning the keys off never stuck.

The lock screen's off now records a blank, and only that blank or a 1%
leftover is taken back; any other off is a choice that pauses auto like the
keys do. A wake clears the blank only once the restore succeeded, the command
runs one at a time so a blank racing a wake can't leave the LED and record
disagreeing, and the loop drops a blank once it has taken the keys back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Drop the optional-install availability guards

Each menu open checked every Install row against the sync databases, which
costs most of a second of pacman work on every machine to hide rows only an
ARM machine is missing. The fix for those is publishing the ARM packages, not
hiding the rows, so this removes omarchy-pkg-available, the Install rows'
availability and architecture guards, and reinstall's skipping of defaults no
repository offers.

The Windows VM row keeps its x86_64 guard, since no package fixes a guest that
needs an x86_64 host, and reinstall keeps its aarch64 channel handling.

This reverts commit aea001cbde4d0852b311ef42b5e8c380c719f833.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Put the current root back when the factory root cannot take its place

Factory reset moved the running root aside, marked the reset committed, then
renamed the staged factory root to @. If that second rename failed, cleanup
saw a committed reset: it skipped the boot package's rollback and kept the
throwaway LUKS slot, and since the swap had not happened it deleted the staged
root as well, leaving nothing at @.

The reset now commits only once the factory root is at @. A failed activation
renames the old root back first, then cleanup rolls the platform's boot state
back and revokes the throwaway slot. If that rename fails too, both roots are
kept, cleanup still rolls back and revokes, and the error names the rename
that recovers the machine. The generic Limine path has already rebuilt its
boot files from the factory root by then, as it had before this change.

* Leave other recorders alone when the saved recorder pid is stale

A saved pid that no longer named a recorder made status and stop select every
gpu-screen-recorder and wf-recorder the user ran, so stopping could interrupt,
and after five seconds kill, a recording started somewhere else.

A stale pid now means no recording of ours is running: the toggle starts a new
one, and --stop-recording finds nothing to stop. Stop acts on the recorder the
status check selected rather than selecting again. Without a saved pid, as for
a recording started before the pid was saved, every recorder the user runs
still counts.

* Name the audio a wf-recorder recording could not capture

With desktop and microphone audio both requested, wf-recorder records a null
sink fed by two loopbacks, and a loopback that failed to load was ignored: the
recording went ahead without that source, or silent when both failed. A
missing null sink or default device dropped audio just as quietly.

A source that cannot be captured is now left out with a notification naming
it, the loopbacks that did load are unloaded, and the recording carries the
source that remains, or no audio track at all.

* Show the Edge, Spotify and Dropbox installers on x86_64 only

These vendors currently publish Linux builds for x86_64 alone (the AUR's
microsoft-edge-stable-bin and spotify are arch=('x86_64'), and Dropbox lists
no Linux ARM client), so no native aarch64 build is available and choosing
one on an ARM machine only fails. Like the Windows VM row they keep a uname
check, which the menu answers once per open without asking pacman. The other
Install rows are unchanged.

* Revoke the generic reset's throwaway slot when the reset fails

The generic Limine path added its throwaway LUKS slot without recording it,
so a reset that failed before activation never revoked it. Once the factory
root's UKI was on the ESP, a failed activation that put the old root back left
a machine that booted the factory kernel and unlocked unattended with a key
readable on the ESP, while the screen said the current root was back.

The generic slot is now recorded like the platform's and revoked by cleanup
whenever the reset fails before the factory root is active. A failed
activation on that path says the boot files were already rebuilt from the
factory system and to run limine-update before rebooting.

* Clear a stale recorder pid when a recording starts

The recorder's pid is saved only once its file exists. Until then a pid left
by a recording that ended without a stop still counted as stale, so pressing
the toggle again while the new recorder started found nothing of ours and
could start a second recording. Starting a recording now clears that pid,
and during startup the toggle falls back to the user's recorders as before.

* Word the dropped-audio notice for every reason a source is left out

The notice said the dropped source "could not be captured", which was wrong
when the null sink itself failed to load: the microphone was never tried, and
desktop audio recorded alone as it always has. It now says the source could
not be added to the recording, true for a missing device, a failed loopback
and a failed null sink alike.

* Show the Heroic and Minecraft installers on x86_64 only

Heroic publishes Linux builds for x86_64 alone (its arm64 builds are for
macOS and Windows), and Mojang's Linux launcher is a single x86_64 tarball.
They join Edge, Spotify and Dropbox under the same uname check, so every row
whose vendor ships no native Linux aarch64 build is treated alike.

* Ask the recorder script whether its stop has a recording to end

The bar indicator and the menu's Stop Screenrecording row asked the process
helper whether any recorder the user runs was alive, while the toggle's stop
acts only on the recording it saved. With a stale saved pid alongside an
unrelated recorder, both offered a stop that did nothing.

omarchy-capture-screenrecording --status answers with the selection the stop
uses, and both now ask it. Status needs no recordings directory and never
notifies, so the recordings-directory check moved below argument parsing and
skips it.

* Copy every platform's pacman templates into place, as x86_64 does

On aarch64 a channel change edited the machine's pacman.conf in place: an awk
pass found the [omarchy] server and rewrote its channel, appended a section
where there was none, checked the channel's omarchy.db first, and gated it all
on a per-platform table of qualified channels. Install finalization appended
Omarchy to a template, or on a Mac copied one bundled inside omarchy-mac. What
a machine ended up with was never in the repository.

Each platform now has a pacman.conf and mirrorlist per channel, copied whole:
x86_64's stay in default/pacman, Snapdragon and other aarch64 machines use
default/pacman/aarch64, and Apple Silicon default/pacman/apple-silicon, with
Omarchy and Asahi ALARM ahead of Arch Linux ARM. Omarchy publishes aarch64
packages on edge alone so far, so every ARM channel points at edge for now and
channel changes work there; moving a channel is a template edit. A channel
without both files is refused before anything changes, including a dev
checkout's own templates. Reinstall goes back to resetting to stable, and the
update confirmation loses the missing-repository notice it no longer needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Revert "Copy every platform's pacman templates into place, as x86_64 does"

This reverts commit 9a07e138cc6c3a19b58d2f3d7eb536dd3042ccc5.

* Copy every platform's pacman templates into place, as x86_64 does

On aarch64 a channel change edited the machine's pacman.conf in place: an awk
pass found the [omarchy] server and rewrote its channel, appended a section
where there was none, checked the channel's omarchy.db first, and gated it all
on a per-platform table of qualified channels. Install finalization appended
Omarchy to a template, or on a Mac copied one bundled inside omarchy-mac. What
a machine ended up with was never in the repository.

Each platform now has a pacman.conf and mirrorlist per channel, copied whole:
x86_64's stay in default/pacman, Snapdragon and other aarch64 machines use
default/pacman/aarch64, and Apple Silicon default/pacman/apple-silicon, with
Omarchy and Asahi ALARM ahead of Arch Linux ARM. Omarchy publishes aarch64
packages on edge alone so far, so every ARM channel points at edge for now and
channel changes work there; moving a channel is a template edit. A channel
without both files is refused before anything changes, including a dev
checkout's own templates. Reinstall goes back to resetting to stable, and the
update confirmation loses the missing-repository notice it no longer needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>

* Drop the migrations no installed machine needs

Fresh installs mark every migration done, and every Apple Silicon machine is a
fresh install, so the Mac handoff to omarchy-mac-boot (and the migrate
operation it called), zram for aarch64 installs that predate the package list,
the Brave Widevine link and the Broadcom migration's Apple Silicon guard never
run anywhere. The exit-75 deferral in omarchy-migrate existed only for two of
them. Keyboard backlight auto stays on new installs through first run rather
than switching on for existing ones, and migration 1786605598 goes back to
quattro: the mkinitcpio drop-ins arrive by package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the generic changes Mac support doesn't need

The pacman platform guard (and the detector copy omarchy-settings shipped for
it, which the HOOKS baseline fell back to), the lint against uname gates, first
run staying pending after a failed finalization, run_logged echoing failures to
stderr, half-up battery rounding, hiding empty-jack inputs and leaving a
filter's capture alone when choosing an input all rode along with the Mac work
without serving it. Each goes back to quattro's behaviour and can come back on
its own merits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the platform Hyprland slots

A platform package could load its own Hyprland binds, settings and gestures:
binds that replaced Omarchy's chords and decorated every later bind, settings
ahead of the theme, and gestures after the user's files, which needed every
hl.gesture call wrapped so a default could step aside. On a Mac those carried
macOS habits (a three-finger workspace swipe, tap-to-click off, menus opening
on the built-in screen, other screenshot and keyboard-backlight chords), not
anything the hardware needs, and Omarchy should behave the same on every
machine. The one bind a Mac does need, its lid switch under the SMC's name,
joins Omarchy's own lid binds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep aarch64 machines off the release channels until a release supports them

Every aarch64 channel's template pointed at the edge repository, but a switch
to stable or rc installs the release-named omarchy and omarchy-settings. On
edge/aarch64 those are the 4.0.4 release line, which has no omarchy-hw-platform,
lifecycle dispatch or omarchy-pkg-defaults, so `omarchy-channel-set stable` on
a Mac replaced the quattro runtime it runs with one that cannot run it, and
succeeded.

aarch64 platforms now have edge templates alone, so a switch to stable or rc is
refused before anything changes; adding those templates once a release
supports aarch64 opens them. Refresh with no channel named, as reinstall runs
it, and install finalization for a channel the platform has no templates for
take the platform's default channel: stable on x86_64, edge on aarch64.

* Leave a filter's own capture alone when choosing an input again

d0b94d98c returned omarchy-audio-input-set-default to moving every source
output, as not serving Mac support. It does: asahi-audio's microphone DSP on
Apple Silicon records the raw array through a filter-chain capture stream
(audio_effect.jNNN-mic, node.link-group "filter-chain-..."). On an M1 Pro,
choosing the Mac's microphone in the audio panel moved that stream onto the
DSP's own output, and the microphone recorded digital silence (-91 dB, from
-55 dB of room noise) until PipeWire restarted. With the skip it keeps
recording (-54 dB).

This restores the skip of source outputs carrying node.link-group, and its
test, as they were before that commit.

* Say aarch64 offers edge alone where the templates are described

The aarch64 edge templates, the template helper and the update-process docs
still said every platform has a template per channel and every ARM channel
installs from edge, which the previous commit made untrue.

* Point the Mac packages at omacom/omarchy-mac-pkgs

omarchy-mac and omarchy-mac-boot now live in omacom/omarchy-mac-pkgs, with
each package at the top level. The dispatcher, its documentation and the
Apple Silicon package list name that repository, and the drop-in test's
fixtures, byte for byte the same as omarchy-mac-boot/files/etc/mkinitcpio.conf.d
at 4399105, cite it there.

* Stop naming a platform migration among the dispatcher's callers

The migrate operation went with the Mac migrations, and no caller hands a
machine to a platform migration any more, so the header lists only the
flows that still dispatch. The optional-operations comment is rewrapped.

* Keep the Intel Mac Broadcom migration off Apple Silicon again

A fresh install marks every migration done only for the user it creates, so
an account added later runs them all on its first update. On an Apple
Silicon Mac this one matches: an M1 Pro reports sys_vendor "Apple Inc." and
a BCM4387 (14e4:4433), so it would append feature_disable=0x82000, which
stops that chip scanning once brcmfmac reloads. Restore the guard and its
test case.

* Say when Steam's post-install hook runs now that the drivers go first

Steam's installer now adds the 32-bit drivers before Steam itself, so the hook runs after both and before the first launch.

* Release fallback audio after the recorder exits

When wf-recorder ends outside the normal Stop path, status rejects the saved PID before unloading the combined-audio loopbacks. Release those owned modules when the saved recorder is gone, so the next status or stop call ends microphone capture without signaling another recorder. Leave a mix without a saved PID alone: its recorder can still be starting.

Co-Authored-By: GPT-6 in Codex <noreply@openai.com>
Co-Authored-By: Codex Medium <noreply@openai.com>

* Serialize recording state changes and stale audio cleanup

Status can finish checking an exited recorder after another command has replaced its audio modules, and stop finalization can outlive the recorder while a new recording starts. Share a lock across start, stop and cleanup so each command finishes updating its resources before another changes them. Status uses a read-only check when the lock is busy, keeping the bar responsive during the picker. Close the lock descriptor in persistent children so recording and preview cleanup cannot retain it.

Co-Authored-By: GPT-6 in Codex <noreply@openai.com>
Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com>

* Keep automatic and manual keyboard brightness updates together

A brightness key can update the LED and its chosen-level record after the automatic loop checks for a manual change but before it applies a new target. The automatic write then overwrites the user's choice and deletes the record. Take the manual command's lock for the whole tick, skipping a busy tick and releasing the descriptor on every return. Create the private fallback directory before opening the lock outside a session runtime directory.

The normal lock screen waits five seconds before blanking, so an ordinary tick should already see the session locked. The regression exercises a brightness key arriving within a tick instead of relying on an implausibly delayed lock-state check.

Co-Authored-By: GPT-6 in Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 High via Pi <noreply@anthropic.com>

* Read the split hook baseline in the NVIDIA migration

The NVIDIA firmware migration decides whether to rebuild by sourcing the hardware and filter drop-ins. With HOOKS now defined in the earlier baseline, that partial read treats every hybrid machine as if kms had been removed. Source the new baseline first when it exists, keeping the legacy two-file layout working and skipping rebuilds when the effective hooks still include kms.

Co-Authored-By: GPT-6 in Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 High via Pi <noreply@anthropic.com>

* Use a hardware helper for x86-only menu entries

Keep the x86_64 availability checks for Windows and vendor-specific installers in a hardware predicate, so menu conditions use the same command style as other hardware checks.

Co-Authored-By: GPT-6 in Codex <noreply@openai.com>

* Keep restored keyboard levels from pausing automatic brightness

Idle wake, quick unlock and hibernate can restore a saved level older than the ambient loop current level without the lock blanking first. Treat that restoration as a one-tick event, preserving existing manual pauses and the normal blank-and-restore path. Consume unmatched and unchanged restore records too, so a later firmware key cannot look like the earlier restore.

Co-Authored-By: GPT-6 in Codex <noreply@openai.com>

Co-Authored-By: Claude Opus 5.5 High via Pi <noreply@anthropic.com>

* Preserve restored keyboard levels across a later lock blank

The ambient loop skips a locked session, so a restored level may still be unconsumed when the next blank saves it. Carry that origin through blanking and restore failures so fingerprint unlock cannot make the old saved level look like a manual choice. A brightness key replaces the marker with its chosen level and retains the ordinary blank behavior.

Co-Authored-By: GPT-6 in Codex <noreply@openai.com>

Co-Authored-By: Claude Opus 5.5 High via Pi <noreply@anthropic.com>

* Preserve firmware brightness choices before a lock blank

Firmware keys change the LED without updating the record. Carry a pending restoration through the next blank only if the observed LED still matches that restoration, or is still zero from its previous blank. A firmware change otherwise follows the ordinary blank-and-restore path so automatic lighting pauses on the chosen level.

Co-Authored-By: GPT-6 in Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 High via Pi <noreply@anthropic.com>

* Change only the disk password in omarchy-drive-password

Changing the system disk's password also set the login and root passwords
to match. That changes account-password policy on every platform, x86
included, and overwrites passwords someone chose to keep different, so it
moves to its own change with an explicit confirm.

The disk change itself stays as it was: the new key must open the disk and
the old one must not before the change counts, the journal finishes an
interrupted change, and where the platform's boot checks prove the key
slots, the owner's new slot is recorded before the run finishes. The
journal's confirmed phase is now "record" and no longer needs a login user.

* Cancel a pending recording start instead of queueing behind it

Starting a recording holds the recorder lock through the picker and until the
recorder creates its file. A second toggle or a stop then waited on that lock,
with no end while a portal pick or a stuck recorder never finished, and once
the start gave up the queued toggle began another recording.

While a start is pending, a toggle or stop that cannot take the lock within a
second now leaves a cancel marker and returns. The start checks it after the
picker, after the webcam overlay and while waiting on its recorder, then stops
that recorder and releases its audio. A recorder other than the portal's that
has not created its file after 15 seconds is stopped and the next backend
tried.

* Move automatic keyboard lighting to its own change

Ambient-light keyboard backlight is a new feature on every laptop with the
sensor, not something Apple hardware needs to run Omarchy, so it leaves this
change for a separate review. The keyboard-brightness command returns to
upstream's version, and first run no longer enables the user service.

* Move workspace refocus on monitor removal to its own change

Keeping the viewed workspace in front after a monitor is unplugged changes
desktop behaviour on every machine, not just Apple hardware, so it leaves
this change for a separate review.

* Move prebuilt-image hardware deferral to its own change

Building an image away from its machine needs a manifest the detector reads,
a queue of hardware steps for the first boot, a first-boot service and a
per-machine pacman keyring. An install on the machine itself needs none of
it, so it leaves this change for a separate review with an installer and
package boundary of its own. Hardware and user setup now run where they are
installed, and the platform setup operations take no image argument.

* Move the Qualcomm platform to its own change

Snapdragon support is separable from Apple compatibility, so the detector no
longer names a qualcomm platform: a "qcom," device tree is generic aarch64,
which Snapdragon already behaves as everywhere else here, and a tree naming
both Apple and Qualcomm is still refused. linux-firmware-qcom's platform list
leaves with it.

* Keep a cancel that arrives while a recording start finishes

A stop could land after the start's last cancel check, while it saved its
recording as started, and be dropped with the marker. The start now removes
its starting marker before a final check, a request reads that marker only
after leaving its own, and one that finds the start gone waits for the lock
and acts unless the start already used its request. A recorder ended by a
cancel or a readiness timeout gets two seconds after TERM before KILL, so a
recorder ignoring TERM cannot hold the start and its lock.

* Defer an image build's hardware setup to the machine's first boot

Brings back prebuilt-image support split out of #14431 so it gets its own review. An image built away from its machine names its target in a root-owned manifest the detector reads; hardware setup is queued for the machine's first boot, run there by omarchy-provision-hardware, which also makes the machine's own pacman keyring; and the platform setup operations learn they run on an image's first boot. Apple Silicon images built by the omarchy-mac installer need this; an install on the machine itself does not.

* Rebuild the initramfs when deferred setup changes module options

mkinitcpio's modconf hook copies /etc/modprobe.d into the initramfs, but the
deferred first-boot setup only compared mkinitcpio's configuration and the
kernel command line when deciding whether to rebuild. A step that only wrote
module options, as nvidia.sh does, left the old ones in the boot image.

* Put the Omarchy repository first on aarch64, as on x86_64

quattro now searches [omarchy] before the Arch repositories (34016cfb2), and
its migration 1791403252 moves the section first in /etc/pacman.conf on every
machine. The aarch64 edge template still listed it last, so refreshing pacman
on a generic aarch64 machine would undo that migration. Apple Silicon's
template already lists it first.

* Refuse disk password changes that recovery could not settle

An interrupted change to the system disk is finished from the one key slot
the password opened. If the old password also sat in another slot, a LUKS2
change made in place and stopped before the old key was retired let the rerun
accept the new password as the old one and finish while the other copy still
opened the disk. A system disk whose current password opens more than one slot
is now refused before anything changes, naming the extra slot to remove and,
where the platform records key slots, how to record the ones kept.

The journal now notes that check (checked=1). A change journaled without it,
by an earlier version, on a disk that has other key slots may still hold a
copy of the old password, and no password entered now can show which slot is
whose, so it is left for the owner to settle by hand, in every phase, with the
commands to do it. On a disk with one slot it settles as before.

A new password that already opens a key slot is refused on every drive too:
the new key's slot is how the change is followed, and an existing copy made
the run take the wrong slot as the owner's. A settled interrupted change now
says the disk opens with the password just entered, which is true whether it
finished the change or rolled it back.

* Ignore the start marker of a recording start that was killed

A start killed before it removed its marker left it behind, and a later toggle that had to wait for the lock took that marker for a start still in progress and exited without doing anything. The marker now holds the starting process's pid and counts only while that process runs.

* Say why generic aarch64 searches Omarchy's repository first

Putting [omarchy] ahead of Arch Linux ARM follows quattro's x86_64 order and its migration 1791403252, which already reorders existing aarch64 machines. It means a package Omarchy also publishes now comes from Omarchy, and a refresh can move it there, downgrading it if Omarchy's build is older. The update-process notes and the template now say so.

* Keep a working key in the manual settlement steps

When an earlier version's interrupted change has to be settled by hand, the steps now start by finding and keeping the slot of the password to keep, test the other slots with passphrases only, never remove the kept slot, and record a Mac's slots before the journal goes. Before, removing every slot the old password opened could remove the only key when the change had not landed.

* Keep Superwhisper's x86_64-only default off aarch64

quattro now ships Superwhisper as the default dictation backend and puts
superwhisper-bin in the base list, but it has no aarch64 build. On an aarch64
machine omarchy-reinstall-pkgs and omarchy-upgrade-to-quattro asked for a
package no repository carries, Setup > Defaults offered an installer that
cannot work, new users were preset to that backend, and the dictation keys, F9
and Right Alt (AltGr on many layouts) included, were bound to it.

install/omarchy-x86_64-only.packages names the base packages only x86_64
builds. omarchy-pkg-defaults leaves them out of every aarch64 platform's set,
and omarchy-upgrade-to-quattro out of the base list it installs there. The
Superwhisper default row shows on x86_64 only, like the other x86_64-only
rows. User setup on aarch64 removes the shipped Superwhisper preset, so a new
user starts with no backend and picks one; any other selection stays.
The dictation keys are bound only once the selected backend's own program is
installed, which changes nothing where it is.

* Install the stub dictation backend where binding tests expect its keys

The dictation keys are now bound only once the selected backend's program is installed. The binding-conflict check selected Voxtype without providing it, so it no longer saw the dictation keys and failed its F9 check; the default-config check now also covers a selected backend that is not installed.

* Make the stale start marker test's dead pid reliably dead

The test killed a backgrounded sleep to get a pid that no longer runs. When the signal landed before sleep started, wait never returned and the suite timed out, about one run in three. A process that exits on its own gives the same dead pid without the race.

* Drop the virtual input level meter the panel ran through a helper

Volume and mute for an untyped input still go through wpctl; typed inputs
keep Quickshell's peak monitor. Quickshell's monitor takes typed nodes only,
so the panel hides the level bar for a virtual input rather than showing an
empty bar that reads as a silent mic.

* Find recording processes with pgrep instead of a Python /proc scan

pgrep matches the kernel's 15-character process name, so gpu-screen-recorder
is matched as gpu-screen-reco. That prefix also lets any gpu-screen-reco*
executable, or a process that renames itself, pass for a recorder, so each
match is confirmed against its /proc exe link, ignoring the " (deleted)"
suffix an upgrade leaves.

--signal re-checks that link just before signalling each recorder, which
narrows the pid-reuse window the old pidfd-based scan closed, and exits 1 when
no signal was delivered. --pid rejects values above the Linux pid limit
instead of letting bash arithmetic wrap them.

* Leave 1Password's software GL wrapper to its own change

This reverts f200105c2; the render-GPU check, Electron GL wrapper and desktop entry repair return in a separate branch.

* Keep Gliff's x86_64-only package off aarch64

quattro added gliff to the base list and a migration that installs it, but its recipe builds for x86_64 alone. It joins Superwhisper in install/omarchy-x86_64-only.packages, so aarch64 package sets leave it out, and the migration installs it on x86_64 only.

* Stub accessory enrollment in the first-boot re-key test

Owner setup now enrolls the owner's USB devices and Thunderbolt accessories after the staged LUKS key is gone, and the re-key test's provisioning run reached that step with nothing to answer it. It now stubs both and checks that enrollment runs once, after the staged unlock is retired.

* Keep USB authorization from boot off Apple Silicon

Blocking USB from the start of kernel boot writes Limine's entry-tool drop-in
and checks the Limine menu and UKI directly, but a Mac's boot chain belongs to
its boot package. omarchy-setup-security-usb-authorization --boot is refused
there before anything changes, and Setup and Remove > Security hide USB at
Boot. The boot helper itself refuses enable on Apple Silicon and treats
disable as done when nothing is configured, so turning USB device
authorization off still works on a Mac; a boot-time setting configured some
other way is left with steps to remove it by hand rather than reported
removed. USB device authorization after login is unchanged.

* Skip fixture-only checks when the shell suite runs as root

Root's locale step always writes the real /etc/locale.conf and locale.gen, and root's platform check always reads this machine, so their fixtures do not apply there. The locale test now stops after its static checks as root instead of changing the runner's locale, and the Apple Silicon refusal in the USB authorization test runs unprivileged only.

* Fetch aarch64 packages from Omarchy's HTTPS Arch Linux ARM mirror

* Name platforms x86, aarch64 and aarch64-apple

omarchy-hw-platform now prints the CPU architecture, then the hardware
family when it needs handling of its own: x86, aarch64 (every ARM machine
without a family) and aarch64-apple (Apple Silicon). A later family such
as aarch64-qualcomm or aarch64-n1x follows the same form, so aarch64*
always means ARM.

The Apple predicate is now omarchy-hw-aarch64-apple; omarchy-hw-apple-silicon
stays as a hidden alias while omarchy-mac's packages and units call it.
The Apple package list and pacman templates move to
install/omarchy-aarch64-apple.packages and default/pacman/aarch64-apple/.

Image manifests and omarchy-pkg-defaults still accept apple-silicon,
generic-aarch64 and generic from builders written before the names
settled, and the mkinitcpio baseline accepts a detector that still says
apple-silicon while omarchy-settings and omarchy update apart.

* Add omarchy-hw-aarch64 and gate architecture checks on the helpers

omarchy-hw-aarch64 is the counterpart of omarchy-hw-x86: true on every ARM
machine, Apple Silicon included. The Windows VM asks omarchy-hw-x86 instead
of reading uname itself, and the keyring refresh asks omarchy-hw-aarch64, so
an x86 update no longer runs the platform detector for it.

* Document platform names, helpers and the consistency rule for agents

AGENTS.md gains a Platforms section: the x86 / aarch64 / aarch64-<family>
naming standard, which helper answers architecture and which answers
hardware family, where platform-specific packages, templates, setup and
boot code live, and that every platform should get the same experience,
with missing packages fixed upstream before any workaround and alternative
packages kept as a last resort.

---------

Co-authored-by: Marcelo Alcantara <maralc@gmail.com>
Co-authored-by: Birk Skyum <74932975+birkskyum@users.noreply.github.com>
Co-authored-by: Jimmy Van Veen <8206925+JimmayVV@users.noreply.github.com>
Co-authored-by: Scott Jones <444693+scottjones@users.noreply.github.com>
Co-authored-by: Scott Jones <scottajones@gmail.com>
Co-authored-by: Marcelo Alcantara <marcelo.alcantara@bp.com>
Co-authored-by: Oliver Lukschander <33756270+oliverlukschander@users.noreply.github.com>
Co-authored-by: Caleb Marshall <68087341+cajomar@users.noreply.github.com>
Co-authored-by: Ryan Hughes <1630358+ryanrhughes@users.noreply.github.com>
Co-authored-by: Issac Trotts <15530+ijt@users.noreply.github.com>
Co-authored-by: Gabe Ohlson <3355909+6abe@users.noreply.github.com>
Co-authored-by: Sparsh Kumar <22640187+skkumarsparsh@users.noreply.github.com>
Co-authored-by: dl-alexandre <166029845+dl-alexandre@users.noreply.github.com>
Co-authored-by: kx0101 <90087463+kx0101@users.noreply.github.com>
Co-authored-by: Jim Martin <1334144+jdvmi00@users.noreply.github.com>
Co-authored-by: Ryan Hughes <ryan@heyoodle.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Omarchybot <317366263+omarchybot@users.noreply.github.com>
Co-authored-by: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-10-09 20:15:56 -04:00
Emir Beganović 61263219b5 Merge pull request #14771 from omacom/boot/console-no-ansi-query
Fix the 1-in-10 black screen at boot, where systemd waits on the console
2026-10-10 00:19:04 +02:00
Emir Beganović 187a145ad5 Merge pull request #14603 from manuaudio/fix/repair-webp-background-links
Repair wallpaper links after bundled WebP conversion
2026-10-10 00:18:32 +02:00
Emir Beganovic 452835da8b Read the boot loader's config only where the migration needs it
The migration read ESP_PATH from /etc/default/limine at its top. On a
machine without that file sed fails, and under bash -euo pipefail the
migration stopped there with an error, before the check that lets a
machine without Limine skip it.

The read moves to after the rebuild, the one place the path is used; a
path given for the tests is taken first, and a missing file means /boot.
2026-10-10 00:06:46 +02:00
Emir Beganovic 3824577c12 Mark the boot image migration done only when a boot entry has the parameter
limine-mkinitcpio carries on past a kernel it could not build, with a
full boot partition for one, and still exits 0. The migration then wrote
its marker over an unchanged boot image, and no later update would try
again.

After the rebuild it now looks at the boot menu: if the menu has command
lines and none carries the parameter, the migration fails and stays
pending. A menu with no command lines in it, where they are inside the
boot images, cannot be checked this way and is accepted as before.
2026-10-09 23:54:17 +02:00
Emir Beganovic 295a926ffd Test the boot image migration as omarchy-migrate runs it, and a failed rebuild
The test ran the migration with plain bash. omarchy-migrate runs it with
bash -euo pipefail, and the migration depends on that: it is what stops
a failed limine-mkinitcpio before the marker is written, so the next
update tries again. The stand-in rebuild could not fail, so nothing held
that promise.

The test now uses the same options and adds the case: a rebuild that
fails ends the migration with an error and leaves no marker, and the
next run rebuilds and marks it.
2026-10-09 23:52:07 +02:00
Emir Beganovic f03bf06742 Stop the boot from waiting on a black screen for the console to answer
About 1 boot in 10 of an installed system stops on a black screen at
"Starting Switch Root" until a key is pressed. systemd 257 and later
asks the console for its size with ANSI escape sequences when PID 1
starts and waits for the reply; with Plymouth holding the VT the reply
sometimes never comes.

Measured in QEMU on repeated boots of one install: 3 hangs in 30 and 1
in 40 on the prebuilt UKI, 7 in 40 on the stock initramfs, every one of
them ended by pressing Enter; none in 40 with a serial console, none in
40 with plymouth.enable=0, and none in 80 with
systemd.tty.term.console=dumb, which makes systemd skip the query.
systemd fixed two variants of this in v261; Arch's 261.2 still shows it.

The parameter goes where the last Plymouth boot fix went (3f725531,
initramfs_async=0): into the packaged omarchy-defaults.conf for fresh
installs, and a migration rebuilds the boot image on existing systems,
once per machine, only where the booted command line lacks it. Nothing
visible changes under "quiet splash".

Refs #2931
2026-10-09 23:26:20 +02:00
Erik Melton 2519fd9a97 Merge pull request #13047 from AFOliveira/fix/factory-reset-packaged-path-20260923
Pin factory-reset elevation to the packaged command
2026-10-09 21:17:17 +02:00
Erik Melton 5cb31317b4 Merge pull request #11874 from acrogenesis/feature/usb-device-authorization
Require approval for new USB and Thunderbolt devices by default.

Reported-by: Jurre van Bergen me@jurrevanbergen.nl
2026-10-09 21:16:12 +02:00
David Heinemeier HanssonandClaude Fable 5.1 9d48fc402e Install gliff by default and show when a remote session is active (#14712)
* Install gliff by default

Gliff is the Hyprland remote desktop over SSH. Ship it in the base
package set for new installs, with a migration for existing ones.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Show a bar indicator while a gliff remote session is active

The machine being controlled should make it obvious that someone is on
it. A first-party remote-session service watches Hyprland's screencast
events, which gliff-server triggers through ext-image-copy-capture, and
probes for the server process, picking up the ssh client address from
its environment. The RemoteSession indicator in the default tray lights
up in the urgent colour while a session runs, and a notification
announces each session start and end.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Address review of the remote session indicator

Probe only the current user's gliff-server processes, forward refresh
through the third-party service proxy so cloned indicator widgets can
re-probe, exercise the Hyprland screencast event path in the service
test with a grim capture instead of calling refresh by hand, and
describe the indicator as status only in the manual.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Drop the remote session notifications

The bar indicator is the signal; a toast on every connect and
disconnect is noise on top of it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Tighten remote session detection after review

Hyprland reports a screencast stopped as soon as frames pause for half a
second and says nothing more when the server later exits, so poll every
two seconds while a session is active instead of trusting a stop event.
Match only the screencast event, not its screencastv2 twin, and debounce
the burst around one transition so it costs one probe. Move the probe
into its own script that the service and the test both run, poll for
state in the service test instead of fixed delays, and drop the unused
session count.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-09 17:02:42 +02:00
54fc12db76 Stop push-to-talk even when other keys are pressed during the hold (#14645)
Hyprland skips a release bind once any other key or mouse button is
released while its key is held (KeybindManager::shadowKeybinds), and
when the modifiers changed since the press. That is what makes a bind
like "bindr = SUPER, SUPER_L" tap-only, but it means typing or clicking
while holding F9 or Right Alt never runs omarchy-dictation stop, so the
recording keeps going.

o.bind_hold binds a press and a release action together. The release
half is transparent (it can't be shadowed), ignores modifiers, and is
non-consuming, so it doesn't take the key from apps when pressed with
other modifiers (Shift+F9 still reaches the app). It runs only after its
own press ran, so releasing the key without the rest of a chord does
nothing. Dictation's push-to-talk keys use it.

Co-authored-by: superduck00 <294879536+superduck00@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:04:59 -04:00
David Heinemeier Hansson 988f44ea1a Delete extra themes from the theme picker (#14713)
* Delete extra themes from the theme picker

Delete on an extra (user-installed) theme opens the shared faded confirmation dialog; confirming runs omarchy-theme-remove and selection lands on the theme before the deleted one. Stock themes and user overrides of them are not offered for deletion, apply/re-ask are blocked while a removal runs, and row refreshes queue so a deletion never leaves the list stale.

* Fail closed on theme deletion until stock themes are known

Keep Delete disabled until the stock listing finishes successfully, so a stock override cannot look deletable in the startup window. Also hold theme apply (and further delete asks) from removal exit until the refreshed rows load, so Enter cannot apply the just-deleted theme in the gap.
2026-10-09 13:18:28 +02:00
David Heinemeier Hansson fcf9eeb5c4 Show the image picker instantly, with no backdrop (#14709)
Remove the carousel's fade and geometry transitions, hold the reveal until every visible preview has decoded and two frames have pre-rendered (falling back to revealing anyway after 400ms), and drop the fullscreen scrim and its theme tokens so the picker sits directly on the desktop. Only Escape is accepted before the picker is visible.
2026-10-09 12:21:45 +02:00
David Heinemeier Hansson d38b70c3c2 Add Starship as a default theme (#14705)
* Add Starship as a default theme

Adapted from Starship To Orbit by Erik Johansson (@erikrjohansson).
https://github.com/erikrjohansson/omarchy-starship-to-orbit-theme
Copyright (c) 2026 Erik Johansson.

Retain the basic palette and three SpaceX launch photographs, with
standard Omarchy styling. Compress the backgrounds to WebP while
preserving their full 6016 x 3384 resolution.

The photographs remain copyright SpaceX and are not covered by MIT.

* Add engine cluster background to Starship

* Use Flight 14 dawn and reorder Starship backgrounds
2026-10-09 12:19:04 +02:00
David Heinemeier Hansson 7f95505dec Slow down the reveal effect a little on background changes 2026-10-09 11:21:23 +02:00
be052339d8 Fix lopsided cursor highlight on network panel header actions (#8787)
* Fix lopsided cursor highlight on network panel header actions

The QR and speed-test header buttons used raw Button components with
asymmetric horizontal/vertical padding, producing a highlight box wider
than tall around their near-square icons. Swap them for
PanelActionButton, the canonical square inline-action component already
used elsewhere in the kit (e.g. connectPwBtn), which sizes itself
symmetrically around the icon.

Fixes #8782

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* Center icons in PanelActionButton on whole pixels

A fractional size and anchors.centerIn put the glyph on half-pixel
coordinates, so it rounded a pixel off the center of the fill: the
speed-test icon sat low in the network header. Round the size, make it
odd so odd-width glyphs get even margins, and place the icon on whole
pixels.

Co-authored-by: Fabian Behrendt <7931874+fabianbehrendt@users.noreply.github.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Fabian Behrendt <7931874+fabianbehrendt@users.noreply.github.com>
2026-10-09 10:08:26 +02:00
Emir Beganović c352b62d67 Merge pull request #14637 from omacom/install/node-tarball-by-architecture
Unpack the bundled Node tarball for the machine's architecture
2026-10-09 01:21:27 +02:00
Emir Beganovic 15b3529ef2 Test that each architecture unpacks its own Node tarball
The existing test runs mise-work.sh in the runtime context, which skips
the bundled tarball altogether. This offers both tarballs to an x86_64
and an aarch64 machine and checks that each unpacks its own and hands
Mise that version, and that a machine with only the other architecture's
tarball fails. Against the old script the aarch64 case fails.
2026-10-09 01:13:27 +02:00
Emir Beganovic 057681b33c Unpack the bundled Node tarball for the machine's architecture
An aarch64 ISO bundles node-v*-linux-arm64.tar.gz, and mise-work.sh
looked for node-v*-linux-x64.tar.gz on every machine. An offline install
on aarch64 stopped at its last step: "Error: bundled Node.js tarball
missing from /opt/packages".

The name now follows uname -m. x86_64 looks for the same file as before.
This is the change the n1x branch already carries, from Spencer Bull's
6d022e36.
2026-10-09 01:07:03 +02:00
David Heinemeier Hansson 50d687a1f2 Ship Superwhisper cloud dictation by default (#14625)
* Ship Superwhisper cloud dictation by default

* Rely on Superwhisper cloud defaults without first-run setup
2026-10-08 21:23:50 +02:00
David Heinemeier Hansson 02d28d273a Fix Superwhisper setup with existing Right Alt shortcut 2026-10-08 21:19:48 +02:00
David Heinemeier Hansson 5ad44d62f6 Alphabetical order 2026-10-08 21:00:58 +02:00
David Heinemeier Hansson c24889096e Add selectable Voxtype and Superwhisper dictation (#14552)
* Add selectable dictation backends

* Move dictation migration after repository priority update

* Extract backend-specific dictation installers

* Keep dictation installer dispatcher executable

* Call dictation backend installers directly

* Drop legacy Superwhisper migration and runtime cleanup

* Separate dictation defaults from generic recording protocol

* Add backend-specific dictation removers

* Preserve existing Voxtype selection during upgrade

* Make dictation backend selection safe and retryable

* Show the dictation indicator only for selected Voxtype

* Allow switching away from stopped Superwhisper

* Remove the built-in bar dictation indicator

* Store default dictation selection under defaults

* Rename dictation backend selector to use

* Move dictation removers into their own menu category
2026-10-08 20:37:26 +02:00
manuaudio b6844dcc33 Fix WebP background migration boundaries 2026-10-08 08:52:21 -07:00
manuaudio a54bf5fd95 [verified] Repair background links after WebP conversion 2026-10-08 08:43:00 -07:00
David Heinemeier Hansson 1554a522cc Update packages after prioritizing OPR 2026-10-08 10:40:56 -04:00
David Heinemeier Hansson 717a0e9c50 Keep OPR priority migration independent of Quickshell 2026-10-08 10:40:56 -04:00
David Heinemeier Hansson 34016cfb27 Prioritize the Omarchy package repository 2026-10-08 10:40:56 -04:00
David Heinemeier Hansson b83d3df084 Qualify shell palette references for Qt 6.12 (#14553) 2026-10-08 10:27:08 +02:00
902fd8aebd Play matching background intros at boot and on theme switches (#9639)
* Play matching theme background intros at boot

* Add Tokyo Night sunset lake boot intro

* Stage Tokyo Night boot intros on update

* Add user controls for background boot intros

* Optimize Tokyo Night boot intro videos

* Add Tokyo Night cityscape boot intro

* Add Ristretto boot intro videos

* Add Solitude boot intro videos

* Add White boot intro videos

* Add Vantablack boot intro videos

* Add Miasma nature boot intro

* Add Matte Black boot intro videos

* Add Lupine wave boot intro videos

* Add Last Horizon blink boot intro

* Add Everforest tree tops boot intro

* Add Ethereal cosmic boot intro

* Add four Retro 82 boot intros

* Add Tokyo Night Quattro boot intro

* Add Catppuccin Latte color fade boot intro

* Add five Gruvbox boot intros

* Expand Retro 82 boot intro coverage

* Complete Ethereal boot intros

* Complete Retro 82 boot intros

* Stage boot intros for the active theme

* Add Catppuccin boot intros

* Complete Tokyo Night boot intros

* Expand Lupine boot intros

* Add Osaka Jade mountain boot intro

* Prime boot intros before revealing wallpaper

* Complete Osaka Jade boot intros

* Keep kernel migration test valid after timestamp reuse

* Shorten packaged boot intros to six seconds

* Revert "Shorten packaged boot intros to six seconds"

This reverts commit 5530403aa9e789b977a1a1a77ea7acf37a9e3598.

* Document five to seven second intro target

* Ease Matte Black intros into six seconds

* Play boot intros through OWE instead of a shell player

The merged desktop video path removes BackgroundVideo.qml and makes
BackgroundMedia image-only. This keeps the intro feature by resolving the
matching intro as before and playing it with 'owe intro', which owns the
decode and the reveal. OWE 0.2.3 adds that command. The shell holds the
theme color while resolving and cancels with 'owe intro-stop'.

* Make boot intro startup atomic

* Retry boot intro through login window

* Document required OWE intro protocol

* Add Last Horizon video boot intros

* Play boot intros with released OWE

Use `owe intro` instead of the unreleased intro-prepare/intro-commit
protocol from omacom/owe#5. The boot is recorded before playback, and
OWE's intro_result status decides whether a failed intro never started
(release the boot for retry) or was interrupted (keep it consumed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Replace Matte Black boot intros with shorter cuts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hide the still behind black until the boot intro starts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Shorten the Everforest boot intro

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document that boot intros should open dark

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refine the Osaka Jade boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Skip compositor fades on the background layers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Shorten the Catppuccin Latte boot intro

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Shorten the Catppuccin boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Shorten the White boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Shorten the Vantablack boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Shorten the Gruvbox boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Shorten the Retro 82 boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Shorten the Solitude boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rework the Ristretto boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Shorten the Tokyo Night boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Complete the Lupine boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Complete the Last Horizon boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Complete the Miasma boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add the Nord boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add the Rose Pine boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add the Flexoki Light boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add the Hackerman boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add the Kanagawa boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add the Lumon boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fade in the Last Horizon intros from black

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Animate the Catppuccin boot intros from their wallpapers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAPp37DLjmKQy8GZr3vFvF

* Animate the third White boot intro from its wallpaper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAPp37DLjmKQy8GZr3vFvF

* Animate the Flexoki Light, Lumon and Rose Pine plants intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAPp37DLjmKQy8GZr3vFvF

* Shorten the Ethereal boot intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAPp37DLjmKQy8GZr3vFvF

* Animate the remaining placeholder intros from their wallpapers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAPp37DLjmKQy8GZr3vFvF

* Replace Catppuccin intros with themotion scenes

Totoro moonrise, waves swelling from a line, and the blue eye tracing
itself open, rendered by https://github.com/yamz8/themotion
(scenes/catppuccin/*).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KAPp37DLjmKQy8GZr3vFvF

* Re-encode Catppuccin intros with exact final frames

Same motion; the settled frames are encoded at a fixed high quality so
the handoff lands on the wallpaper (themotion 0c5e86c).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Replace Lumon intros with themotion scenes

United in severance draws its lines and globe with a small pen-tip
light; Opinions equally wakes inside the picture's own screen and types
out the mark and tagline. Both keep the wallpaper's proportions, so the
handoff holds its shape. Made with https://github.com/yamz8/themotion
(scenes/lumon/*).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Land the Last Horizon intros exactly on their wallpapers

Same footage and timing; the last 0.4 s now blends into the wallpaper as
the shell draws it (libwebp decode, BT.709 limited range), with the
settled frames at high quality. Re-finished with themotion fit (09f156e).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add Nord video boot intro for Night Hawks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Replace Rose Pine intros with themotion scenes

Funky shapes pop in on a jelly spring and the doodles draw on (replacing
the reversed stock clip), the dot map spins in from a swelling dot, and
the plants spring up before the OMARCHY letters drop in. Made with
https://github.com/yamz8/themotion (scenes/rose-pine/*).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Replace Hackerman intros with themotion scenes

The synthwave terrain unfolds as the sun rises and the valley glow
flares into the floor; the geometric network bursts out from a triangle
at its core and locks into place. Made with
https://github.com/yamz8/themotion (scenes/hackerman/*).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Replace White 3-white with a themotion scene and land 1-2 exactly

3-white folds and slides its paper sheets into place along their
diagonal (themotion scenes/white/3-white). 1-white and 2-white keep
their footage and timing; their last 0.4 s now blend into the wallpaper
as the shell draws it (themotion fit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Replace Ristretto 0-1 with themotion scenes and land 2-3 exactly

0-launch lifts its stripes off and bends them into their curves, and
1-color-curves fans its colour bands in along their curves (themotion
scenes/ristretto/*). 2-coffee-beans and 3-industrial-moon keep their
footage and timing; their last 0.4 s now blend into the wallpaper as
the shell draws it (themotion fit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Replace Vantablack intros: new layer scenes, shared dot-hands cut

0-dot-hands uses the Matte Black cut, since both themes ship the same
wallpaper. 1-twisted-stairs keeps its footage and lands exactly on the
wallpaper (themotion fit). 2-layers-deep opens its depth front to back
and 3-layers-stacked lays its papercut stack down sheet by sheet
(themotion scenes/vantablack/*), replacing footage that opened on
unrelated shapes and a plain fade-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the shockwave ring from the Totoro intro

The moon now opens from its seed of light with only its halo; the thin
ring racing outward read as clutter (themotion 6970484).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the shockwave ring from the blue-eye intro

The eye closes with its pulse and halo only (themotion, same change as
Totoro's).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Land the earlier footage intros exactly on their wallpapers

Everforest, Osaka Jade, Catppuccin Latte and two Matte Black intros
were finished before the exact-ending tooling and ended 0.9-4 levels
off their wallpapers (up to 134 on small spots). Their footage and
timing are unchanged; the last 0.4 s now blend into the wallpaper as the
shell draws it (themotion fit).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fade two Matte Black intros up from black

ship-at-sea and dark-waters opened at brightness 17-20, so they popped
in after the black cover. They now fade up over 0.3 s; the cuts and
their exact endings are otherwise unchanged (themotion fit --fade-in).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the Flexoki Light intro for now

Its orb scene isn't good enough yet, so Flexoki Light starts on its still
until a better intro is ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit bfe60d6c0a7a0983509a2dc2b295693bec2ceeb8)

* Skip boot intros while animations are off

A VM starts with animations off, and there Hyprland renders on the CPU, so a
video intro costs the most. The launcher now consumes the boot without playing
when Hyprland reports animations:enabled false, and the shell's cover check
treats that, like the intros toggle, as nothing to wait for.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add Miasma video boot intro for Crowned

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep boot intro startup outside the background service

OWE destroys and recreates the background plugin during playback. Keep the launcher and black cover in the persistent shell host, consume the boot before waiting briefly for OWE, and start on the video's first frame. The startup check runs with a video background too, so enabling the still background service later cannot play an intro over an established desktop.

Co-Authored-By: Codex Medium <codex@openai.com>

* Release the boot marker lock before waiting for OWE

The marker settles startup once it is written. Keeping the lock through readiness and playback makes a fresh shell wait for an older launcher before it can discover that the boot was consumed, leaving the still covered during that wait.

* Keep the boot intro cover above native and cloned backgrounds

Cloned backgrounds receive a scoped shell interface without the startup coordinator. Keep the input-transparent cover in the persistent host so every background stays hidden until OWE has a frame, without widening the plugin interface.

Co-Authored-By: Codex Medium <codex@openai.com>

* Map the startup cover only for an unsettled boot

The cover must know the boot was already consumed before its first frame, so restarting the shell during playback leaves OWE visible. Snapshot the marker without watching subsequent writes: the launcher consumes this boot before the intro starts, while the cover remains needed until the first decoded frame.

* Exercise automatic boot cover release in the clone test

The fixture hid the cover itself before the fake launcher exited, so a broken exit handler still passed. Let the launcher finish before checking the actual background pixels and leave cover release to the service.

Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com>

* Finish the Miasma, Kanagawa, Ethereal and Nord intros

Start each intro on its scene instead of fading in from black, since
OWE now starts intros on their first frame.

- Miasma Crowned: ejuro's intro, shortened to 6.7 s and opening on the lit hand
- Nord Night Hawks: ejuro's intro
- Kanagawa: rendered in the wallpaper's own shape, so the handoff matches on
  16:10 and 3:2 screens too
- Ethereal, Nord and Miasma Crowned: 1080p endings that blend into the exact
  wallpaper, so the handoff no longer sharpens visibly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Finish the Tokyo Night intros

Start each intro on its scene instead of fading in from black, since
OWE now starts intros on their first frame.

- Winding Road: a new starry night to sunset with no added clouds or sun,
  ending on the wallpaper
- Quattro: a new take where the car lands in the wallpaper's exact pose,
  colour-matched to the wallpaper
- Oma Cityscape: drop the second, larger moon that faded out at the end
- Quattro, Swirl Buck, Omakub and Oma Cityscape: 1080p endings that blend
  into the exact wallpaper, so the handoff no longer sharpens visibly

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Pair background intros by filename and play them on theme switches

Keep matching clips in backgrounds/intros so the selected image determines playback without registration, overrides, or checksum sidecars. Retain one global toggle and play automatically at boot and on a different theme selection.

Launch theme playback after releasing the selection lock. Check the staged theme directory identity so an older launcher cannot play a newer selection when filenames are reused. Skipped or failed playback immediately hands the selected still to the shell, including new pixels behind an unchanged path.

Synchronize the clone screenshot fixture with capture instead of a fixed launcher lifetime, while still requiring the real exit handler to release the cover.

Co-Authored-By: Codex Medium <codex@openai.com>

* Settle pending background colors when applying the current theme

An intro applies its theme immediately while a previous still-image reveal may still hold an older palette. Replace and consume that pending palette through the background's existing handoff methods so its reveal or fallback timer cannot restore old colors. Reusing those methods also works with existing background clones.

Publish the fake renderer's PID before announcing playback readiness, so the lock test cannot read an unwritten PID file. Regressions reproduce both failures on 7b238cf1 and pass with the fixes, including a deliberate scheduling delay at PID publication.

Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com>

* Use the existing animations menu toggle for background intros

Keep the separate intro command available without another Style menu control. Disabled animations already suppress playback at boot and on theme changes.

Use remembered backgrounds with matching clips when testing suppressed theme intros, and require the normal still transition so the checks cannot pass merely because no clip was selected.

Co-Authored-By: Codex Medium <codex@openai.com>

* Finish the Lupine intros

Start each intro on its scene instead of fading in from black, since
OWE now starts intros on their first frame.

- Cherry Blossom White: a new take that pulls back from fresh leaves to the
  whole tree, easing to a stop on the wallpaper
- Pastel Clouds: drop the unrelated starry opening and its dip to black
- Elegant Blue Wave: drop the empty white opening
- Cherry Blossom Bokeh, Pastel Clouds, Elegant Blue Wave and Cherry Blossom
  White: 1080p endings that blend into the exact wallpaper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBE1V6gjrqn2aXCNNGhrUY

* Synchronize theme intro and bar crossfades

* Start theme intro fades from a cached first frame

* Open theme intros on moving scenes

* Start theme crossfades promptly

* Prepare theme intros before switching

* Finish the Catppuccin intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBE1V6gjrqn2aXCNNGhrUY

* Keep theme intro handoffs covered

* Reload Hyprland after theme intro playback

* Finish the Rose Pine intros

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JBE1V6gjrqn2aXCNNGhrUY

* Restore the wallpaper early on each login

* Keep the startup wallpaper cover transparent

* Wait for readiness in the intro lifecycle test

* Consume login intros for updates outside Hyprland

* Fade login directly into the ready desktop intro

* Sharpen Quattro, Swirl Buck and Oma Cityscape

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep compositor backgrounds black during login

* Keep the shell visible through compositor shutdown

* Bound the login cover when startup media fails

* Encode the 1440p intros at H.264 level 5.1

At 2560x1440 the previous encodes used 16 reference frames, which puts
them at level 6.0. Intel hardware decoding plays those with stray grey
blocks. Four reference frames keep them at level 5.1, like the other
intros. Swirl Buck's stag now changes colour as one silhouette instead
of leaving a shrinking black disc on its head.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hide the startup cursor until the desktop fades in

* Restore Swirl Buck's original stag

The stag now changes colour exactly as in the original clip again;
the previous edit made it jump from black to purple in one frame.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Initialize the startup cursor before the first compositor frame

* Render the Hackerman intros at 1440p

Both open on their first motion instead of fading in from black.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Render the Lumon intros at 1440p

Both open on their first motion instead of fading in from black.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sharpen the Lupine intros

Upscaled from the original 720p clips and exported at 1440p with a
gentler encode, keeping the same frames, timing and endings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Resolve login intros before the wallpaper daemon starts

* Synchronize cold-start captures with the fixture phases

* Sharpen the rest of the Tokyo Night intros

Winding Road and Sunset Lake are regenerated natively at 1440p from
their wallpapers, since their original clips were 360p and 720p.
Omakub and Oma are redrawn from their wallpapers with the same motion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Bjarne Oeverli <1419214+bjarneo@users.noreply.github.com>
Co-authored-by: Erik Johansson <172146456+ejuro@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Omarchy Bot <omarchybot@users.noreply.github.com>
Co-authored-by: Codex Medium <codex@openai.com>
Co-authored-by: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
2026-10-07 22:00:52 +02:00
David Heinemeier Hansson a137760c89 Reuse SSH connections for Herdr theme sync (#14460)
* Reuse SSH connections for remote theme sync

* Cover remote theme sync connection reuse
2026-10-07 21:45:16 +02:00
Rob ZolkosandDavid Heinemeier Hansson 060c57b4aa Enable overlay scrollbars in Chromium (#14456)
* Enable overlay scrollbars in Chromium

* Test overlay scrollbar migration

* Simplify Chromium overlay scrollbar migration

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
2026-10-07 20:03:36 +02:00
David Heinemeier Hansson 0a55a3c554 Keep overlays sharp without parked surfaces (#14458)
* Keep overlays sharp without parked surfaces

* Refresh overlay monitor scale after live changes

* Reset menu layout when its monitor changes
2026-10-07 19:42:41 +02:00
David Heinemeier Hansson d9b970dd62 Let users choose passwordless sudo duration (#14435)
* Let users choose passwordless sudo duration

* Warn in the menu bar when passwordless sudo is active

* Fix sudo indicator hover behavior and repeated authentication

* Disable passwordless sudo from the bar without a terminal

* Shorten passwordless sudo indicator tooltip

* Recover interrupted passwordless sudo duration switches

* Allow sudo grant changes when listings require authentication

* Start passwordless sudo setup with the duration question
2026-10-07 14:41:27 +02:00
David Heinemeier Hansson 982290fa42 Nicer order 2026-10-07 13:41:30 +02:00
David Heinemeier HanssonandClaude Opus 5.5 b6f2c1cef7 Describe the agents panel's limit rows and sign-in link as they are (#14430)
The manual still said the panel shows the percentage used and that an idle
account keeps its last known limits, and the README that sign-in trouble is
only text. Rows show the time to reset with the percentage on hover, and a
lapsed sign-in is a Sign-in required link that signs the account in again.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:15:38 +02:00
David Heinemeier HanssonandClaude Opus 5.5 7b68fee056 Add Slack to Install > Service (#14434)
Slack is one of the most popular apps people install from the AUR. It now
ships from the Omarchy package repository as slack-desktop, with native
x86_64 and aarch64 builds. Install it the same way as Bitwarden: one menu
entry that installs the package and then launches Slack. The commercial
apps page of the manual gets a matching section.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:15:00 +02:00
David Heinemeier Hansson 402128b6a2 Automatically approve orphan removal during updates (#14428) 2026-10-07 12:13:33 +02:00
David Heinemeier Hansson 2fa6d0ecc5 Approve Grok migration removal automatically 2026-10-07 12:03:54 +02:00
David Heinemeier Hansson 83957145a7 Replace Disk Usage TUI with Disktree (#14426) 2026-10-07 11:53:39 +02:00
Spencer Bull 0f8af9be30 Merge pull request #14017 from omacom/xps13-ptl-cirrus-firmware
Install XPS 13 Panther Lake speaker firmware
2026-10-06 22:58:04 -05:00
Spencer BullandGreptile db21abdef0 Request a reboot directly in the PTL firmware migration
Each pending user's one-time migration requests a reboot even when hardware setup has already installed the aliases. Let migration completion handle repeat runs and remove the shared marker and its privileged write.

Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-10-06 21:56:49 -05:00
Spencer Bull 499f50ffd6 Move speaker reboot bookkeeping into the migration
Keep hardware setup limited to installing the firmware package. The migration owns the pending-reboot marker and preserves prompts for every user migrating before reboot.
2026-10-06 21:47:56 -05:00
Spencer BullandCodex GPT-6.1-Sol XHigh 0d8232b9db Install the XPS 13 PTL speaker alias package
Install the separately owned firmware aliases through the package helper so stock firmware updates and stable refreshes retain them. Use a distinct migration to reach machines with the prior migration completed and preserve the reboot prompt until reboot. Restore the generic updater and leave display quirks to the kernel.

Co-Authored-By: Codex GPT-6.1-Sol XHigh <noreply@openai.com>
2026-10-06 21:29:54 -05:00
19e5941075 Fail updates that leave required Panther Lake firmware unrepaired
Honor package exclusions while returning failure when an installation leaves the required Cirrus firmware too old. Print-only and download-only operations remain successful without a repair or reboot request.

Co-Authored-By: GPT-6.1-Sol High <noreply@openai.com>
Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-10-06 00:46:53 -05:00
Spencer BullandGPT-6.1-Sol High 033b5ecd3d Retain XPS 13 Panther Lake firmware during package refresh
Constrain Cirrus firmware in the package transaction so a stable channel refresh cannot remove the required aliases. Recover old firmware independently of completed migrations, and request reboot only after verifying a successful repair. Preserve package exclusions through native pacman handling while keeping interactive conflict recovery answerable.

Co-Authored-By: GPT-6.1-Sol High <noreply@openai.com>
Co-Authored-By: GPT-6.1-Sol XHigh <noreply@openai.com>
2026-10-06 00:30:29 -05:00
Ryan Hughes 81145eb1fd Merge pull request #13963 from SorenHJohansen/fix/t2-firmware-fetcher
Name apple-bcm-firmware-fetcher in the T2 package list
2026-10-05 10:33:23 -04:00
b9e0ac4f1d Prevent clipboard capture hangs (#9488)
* Bound clipboard capture reads

* Bound the watched clipboard read and drop copies cut off at the deadline

In watch mode capture.sh reads the copy from the owner's pipe on stdin, to EOF, with no bound. An owner that stalls without closing its end keeps the callback alive, and wl-paste --watch handles no further clipboard events until it exits. That matches #9443, whose stuck capture.sh had no wl-paste child: --list-types never reads from the owner.

Every read now goes through one bounded reader into a temporary file, and the copy is recorded only when the read finished, so a snapshot that stalls halfway no longer records its first half.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Omarchybot <317366263+omarchybot@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:58:07 +02:00
David Heinemeier Hansson 5a0e7348af Show Elsewhen remove button only on mouse hover 2026-10-05 14:44:19 +02:00
David Heinemeier Hansson ec76c070d2 Give Elsewhen remove buttons a circular corner bubble (#14258) 2026-10-05 12:42:42 +02:00
Emir Beganović 65c0f3306e Merge pull request #13233 from Raj-Jagadeesh-A-P/fix/issue-7952
Fix grammar in navigation manual
2026-10-05 02:36:42 +02:00
Ryan Hughes 885e339037 Drop superseded T2 firmware handling from the install script 2026-10-04 19:22:04 -04:00
David Heinemeier Hansson 2f7302a777 Install Word, Excel, and PowerPoint as separate web apps (#14271)
* Open Microsoft Office on the apps page

* Drop the Office launcher migration

* Install Word Excel and PowerPoint as separate web apps
2026-10-04 18:23:31 -04:00
035ce29f03 Add Install > Service > Microsoft with Outlook, Office, and Teams web apps (#10367)
* Add Install > Service > Microsoft with Outlook, Office, and Teams web apps

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Prefix the Microsoft web apps with the brand so they sit together in the launcher

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Open the Office web app at the sign-in page

office.com now forwards to the Microsoft 365 marketing site when signed out, while
Outlook and Teams open straight to sign-in. The login path does the same for Office.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Add Microsoft OneDrive to the Microsoft web apps

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Point the Teams web app at teams.cloud.microsoft

Microsoft moved Teams to its cloud.microsoft domain; teams.microsoft.com is the
legacy address now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Document Microsoft web app customization

Explain individual app selection, organization-specific URLs, and a separate browser profile using existing launcher options. Keep the optional bundle separate from the manual's default apps.

Co-Authored-By: GPT-6 in Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 Medium <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Omarchy Bot <omarchybot@users.noreply.github.com>
Co-authored-by: GPT-6 in Codex <noreply@openai.com>
2026-10-04 14:02:56 -04:00
David Heinemeier Hansson e86c8f1ab7 Reveal indicators only before the clock (#14267) 2026-10-04 14:00:09 -04:00
OmarchybotandOmarchybot 0e7d6c270b Update test fixtures to current runtime contracts (#14259)
Recent runtime changes left the aggregate suite expecting a VS Code symlink and fixed extension version, pacman arguments without the end-of-options marker, a one-argument QML text signal, and background functions without preparation state. Align these fixtures with the current interfaces while retaining palette equality, package retry, keyboard action, and missing-background recovery assertions.

Co-authored-by: Omarchybot <317366263+omarchybot@users.noreply.github.com>
2026-10-04 13:30:00 -04:00
72c152a288 Retry systemd reload in the fingerprint recovery migration (#14260)
* Retry systemd reload after installing fingerprint recovery

A failed reload leaves the migration pending with the drop-in already installed. Reload existing configuration on retry so completion means systemd has applied the stop timeout, while preserving administrator changes.

Co-Authored-By: Codex Medium <317366263+omarchybot@users.noreply.github.com>

* Avoid sudo for an already-applied fingerprint repair

Migration completion belongs to each user, while the systemd repair is machine-wide. Query systemd reload state without elevation and reload only stale configuration, so interrupted repairs still retry and later users can complete without sudo. A failed state query remains a migration failure.

Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Omarchybot <317366263+omarchybot@users.noreply.github.com>
Co-authored-by: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-10-04 13:29:38 -04:00
879d6583da Fix fingerprint enrollment and lock-screen recovery (#7158)
* Restart fprintd after resume to clear a claim wedged by suspend

A fingerprint verify still open when the machine suspends leaves fprintd
unable to hand the reader back: the verify dies with "Cannot run while
suspended" and the follow-up ReleaseDevice fails on the still-busy device.
The wedged claim then rejects every lock-screen attempt after resume until
fprintd exits on its own 30-second idle timer -- and the retry loop keeps
it from ever reaching that timer, so the reader stays dead until the user
gives up and types a password.

Install a system-sleep hook that restarts fprintd on resume, dropping the
claim so the reader answers on the first touch. It is installed by
omarchy-setup-security-fingerprint and removed by its teardown, so it is
present exactly when a fingerprint reader is configured. try-restart is a
no-op when fprintd is not running, so a healthy resume pays nothing.

Approach suggested in #7229 and measured by @paracycle: 45 stray PAM
sessions after resume down to 2.

* Pace fingerprint retries and show when the reader is unavailable

The lock screen retried fingerprint auth on a flat 250ms timer with no
sign to the user, so a reader it could not reach -- a claim wedged across
suspend, one held by another client, or a sensor gone from the bus --
spun PAM sessions at four per second behind an icon still inviting
touches that could never unlock.

Pace and report on one signal: whether an attempt reached the reader at
all. pam_fprintd relays a finger prompt only once the claim lands, so an
attempt that ends without prompting never reached the device. Those
advance a streak that backs the retry off exponentially (to a ceiling
above fprintd's 30s idle exit) and, past a few in a row, crosses out the
icon and shows a "Fingerprint reader unavailable" notice. An attempt that
did prompt proves the reader works -- a finger that merely did not match
still reaches it -- so it clears the streak and the loop stays responsive.

User presence (a keypress or touch) collapses a backed-off wait to a
prompt retry, rate-limited so a moving cursor cannot respin the storm. An
attempt that never reaches the reader within a few seconds is aborted and
settled as unreached, so a claim orphaned by the resume restart surfaces
the notice and retries a fresh daemon rather than hanging silently.

The pacing, streak, nudge, and reach-timeout logic live in
FingerprintModel.js with Node coverage; the new Text elements declare
textFormat; lock status reports fingerprintUnavailable.

The attempt state machine tracks the open attempt with fingerprintAuthenticating alone; the first settle closes it, and one PAM attempt raising both onError and onCompleted still folds into the streak exactly once.

Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt

* Install the fprintd resume hook root-owned and keep it with the PAM file

cp -p carried the checkout's owner and mode into
/usr/lib/systemd/system-sleep/, so under dev-link the root-executed hook
was user-owned, and a tree whose exec bit had been stripped installed a
hook that systemd-sleep silently never ran. Use install -Dm755 -o root
-g root, as the migration that installs the same file already does.

The hook also belongs exactly where the fingerprint PAM file does:
omarchy-apply-lock creates and removes /etc/pam.d/omarchy-lock-fingerprint
on its own, and any apply-lock run after enrollment left PAM without the
hook while its removal branch left a hook behind without PAM. Have
apply-lock install and remove the hook together with the PAM file, and
teach apply-lock-test.sh to redirect the hook into its scratch tree and
assert the hardened run lands it beside the PAM fixtures.

Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt

* Only treat fingerprint as configured when a print is enrolled

The lock screen and omarchy-apply-lock decided fingerprint was set up with
fprintd-list | grep -qi finger, which also matches "has no fingers enrolled"
and "ListEnrolledFingers failed". A second account on a machine where one
user enrolled, or anyone who ran fprintd-delete, was therefore handed the
fingerprint loop: every attempt bailed before the claim, and with the new
pacing that showed up as a crossed icon and "Fingerprint reader unavailable"
for a reader the account simply has no print on. Match the per-print
" - #N:" lines instead.

apply-lock-test.sh follows: its fprintd-list stubs answer with a real enrolled-print row and its helper patcher matches the new probe line.

Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt

* Exercise the migration's default hook source in its test

Every case overrode OMARCHY_FPRINTD_RESUME_SRC, so the path the migration
really reads from was never checked, while its -f guard turns a missing
source into a clean exit and a permanent per-user marker. Add a case that
runs against the shipped hook under the repo, and adopt set -euo pipefail
like the sibling tests.

Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt

* Take the fprintd restart off the thaw and bound its stop timeout

The resume hook ran systemctl try-restart synchronously while user
sessions were still frozen, so its cost landed on the wake path: half a
second when fprintd answers SIGTERM, but a wedged fprintd on a stale
device handle (the reader re-enumerated across the sleep) does not, and
then the desktop stayed frozen for the whole stop timeout -- precisely in
the case the hook exists for.

Enqueue the restart with --no-block instead, as the unmount-fuse hook
already does for the same reason, and ship a drop-in capping fprintd's
TimeoutStopSec at 3s so the restart lands within seconds either way. The
drop-in is numbered 10-stop-timeout.conf, as the other Omarchy system
drop-ins are, so an administrator's override.conf sorts after it and
wins. It is installed and removed wherever the hook is (setup, teardown,
apply-lock, migration), and apply-lock-test.sh redirects it into its
scratch tree alongside the hook.

The hook's comments now say what actually happens on a locked resume --
Omarchy locks before every suspend and the lock screen opens a verify at
once, so the restart is real, not a no-op -- and name the upstream
defects this works around, fprintd#173 and fprintd#216, so the hook and
the drop-in can be retired when upstream fixes them.

Measured by MaxMad75 on an X390 Yoga (S3): 2 of 10 fprintd stops rode out
the timeout to SIGKILL; the 3s cap verified with systemctl show.

Co-authored-by: Omabot <omabot@omarchy.org>
Co-authored-by: MaxMad75 <44462964+MaxMad75@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt

* Close the status-check and start-failure exits through settle

Two paths left the fingerprint loop stuck or misreporting. A mid-lock status check that found fingerprint unconfigured aborted the PAM context directly; abort() delivers no signal, so fingerprintAuthenticating stayed true and every later attempt and nudge returned on it until the password unlock. And a fingerprintPam.start() that fails synchronously means the PAM file is gone -- a configuration problem, not a reader miss -- yet it fed the reader streak and reported "Fingerprint reader unavailable".

Route the abort through settleFingerprintAttempt like the reach timeout does, drop the pending retry with it, and on a start failure re-check the configuration so the icon disappears instead; a pending retry owns the next attempt when a status check comes back configured.

Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt

* Pace fingerprint nudges by the pending tier and the cap's idle stretch

The nudge cooldown was a flat 2s, shorter than every backoff step, so a
user moving the mouse at a wedged reader collapsed each wait to 2s --
thirty claims a minute against the cap's 1.5 -- and each claim re-armed
fprintd's 30s idle timer, so the hook-less recovery the cap exists for
never happened while anyone was present.

Grow the cooldown with the pending wait, so presence collapses each
backed-off wait once and repeat nudges are paced by the tier. At the cap
the wait itself is the cure -- it is what lets fprintd idle out and drop
a wedged claim -- so there the idle stretch is measured from the last
settle, not the last nudge: a nudged attempt that hung until the reach
timeout would otherwise eat most of the window, and under continuous
input fprintd would never be left alone long enough to exit.

Wall-clock steps are handled in both directions: a clock stepped back
past the last nudge does not hold a fresh nudge back, and one stepped
back past the last settle counts as no idle time at the cap rather than
as enough. The retry test drives continuous input against attempts that
hang to the reach bound and checks the gap fprintd is left.

Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt

* Give a slow fingerprint claim time to land before aborting it

The reach bound aborted any attempt that had not prompted within 5s by
SIGKILLing the PAM child mid-Claim. A reader whose device open takes
longer than that (out-of-tree drivers, and any reader right after the
resume hook forces a re-open) could then never prompt: each kill left
fprintd tearing the claim down until the open finished, the 1s retry hit
"already claimed", and three misses later the reader was reported
unavailable for good. Raise the bound to 20s, under GDBus's 25s Claim
timeout and pam_fprintd's 30s verify timeout (whose "Verification timed
out" is a non-error message that would read as reached), and name the
hazard the bound actually covers: a daemon restarted under the verify
fails the attempt promptly, a stuck device open does not.

Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt

* Detect resume and hold the streak through the restart window

Monotonic timers pause across suspend, so a backed-off wait armed before
the sleep picked up mid-count afterwards: with the streak at the cap the
"Fingerprint reader unavailable" notice stayed up for the remaining wait
after the resume hook had already freed the reader, and misses collected
around the suspend edge carried across it, so a healthy reader could
cross the notice threshold in the first seconds after waking. With the
restart enqueued off the thaw, the loop's first attempts after a wake can
also land on the old daemon while it is being stopped -- up to ~3s when
it ignores SIGTERM -- and three of those would show the notice for a
reader that was merely being restarted underneath.

Notice a resume from any of three signals -- a sleep watch ticking the
wall clock for the whole lock, a retry that fired late, or an unreached
attempt whose settle finds the watch's last tick far in the past (so a
suspend shorter than the reach bound is caught before the tick itself
gets a chance to) -- and open a grace window: the stale streak is
dropped, a pending wait retries the fresh daemon at once, and misses
inside the window hold the streak at the first tier without ever counting
toward the notice. Detection is idempotent within the window, since more
than one timer can notice the same resume.

Pinned by MaxMad75's reading: the window is armed by the resume, not by
the first miss. Verified on his X390 (S3, frozen sessions): six lid-close
cycles, fingerprint-resume at +15ms, streak held, notice never fired.

Co-authored-by: MaxMad75 <44462964+MaxMad75@users.noreply.github.com>
Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt

* Only let a definitive probe change whether fingerprint is configured

The status probe collapsed every fprintd-list result into yes or no, so
an unreachable fprintd -- restarting under the resume hook, or failing a
D-Bus activation mid-resume -- read as "not configured": the icon
vanished, the retry loop and the sleep watch stopped, and nothing asked
again for the rest of the lock. One transient miss killed fingerprint
until the next lock, with the password as the only clue. MaxMad75 hit it
on hardware in run 6 of the X390 series; osborng filed the stock repro as
#9453 (mask fprintd, lock, unmask -- fingerprint never returns).

Classify the probe's output instead: an enrolled-print row is yes,
fprintd's explicit no-prints answer (or a missing PAM file or binary) is
no, and anything else is unknown -- the probe could not tell, so nothing
changes and it is retried on the attempt-retry pacing. The unavailable
notice, backoff, and resume detection all sit downstream of this flag;
now only an answer that actually means something can clear it.

Fixes #9453.

Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt

* Log the fingerprint loop's misses, notice, and recovery as lock events

The reach timeout, an unreached settle, the streak crossing into the
notice, a resume restart, and the recovery all changed lock state without
touching logEvent, so a report of "Fingerprint reader unavailable" left
no omarchy lock line to line up with suspend and resume timestamps in
omarchy-debug-idle output. Log those transitions; reached attempts are
the steady state and stay quiet. A match that unlocks after a run of
misses is the recovery too -- the unlock resets the streak without
settling, so it logs fingerprint-recovered there as well.

Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt

* Abort an attempt stranded in flight when a resume is detected

A verify that survived into the suspend still prompted comes back to a
daemon the resume hook has already replaced, and the loop's resume
handling deliberately left it alone: the reach timer stopped at the
prompt, so nothing bounded it but pam_fprintd's own ~25s timeout, and
until that ran out the icon invited touches that could not work. Most
visible where user sessions are not frozen across sleep and the lock
races the hook.

Abort the stranded session when the resume is detected and route it
through settle: it lands inside the grace window, so the kill never
counts toward the notice, and the settle arms the fast retry against the
fresh daemon itself.

Suggested by sliekens in review.

Claude-Session: https://claude.ai/code/session_0168egYTXrVBVg16ugszGzQt

* Simplify fingerprint recovery and consolidate enrollment checks

Use the enrolled-entry matcher from #9551 while retaining the lock's tri-state probe recovery and the privileged /usr/bin/fprintd-list call. Unknown enrollment probes must preserve existing PAM and resume recovery rather than deleting the machinery needed to recover. Preserve administrator-owned unnumbered timeout files during migration.

Remove presence-driven retry overrides and their cooldown, clock, and idle-window state: resume has its own fast recovery path, while other errors can follow the bounded automatic backoff. Let the existing sleep watcher detect resume instead of also tracking the age of each retry. Setup now uses apply-lock so PAM and recovery installation have one implementation. Keep the restart, stop bound, unreachable-attempt pacing, unavailable feedback, reach watchdog, and probe rechecks because each handles a distinct failure.

Co-Authored-By: Karl Ahlin <kalle.ahlin@gmail.com>
Co-Authored-By: Codex Medium <noreply@openai.com>

* Preserve failed-enrollment coverage in the setup fixture

The successful-enrollment fixture accepts PAM commands, so failure checks must explicitly reject those commands instead of relying on an unexpected-command error. Log both sed and tee and stub apply-lock for every case so premature authentication setup is detected without reaching live PAM files.

Co-Authored-By: Codex Medium <noreply@openai.com>

* Complete fingerprint recovery and setup reporting

Back off immediate device errors after the verification prompt as well as failed claims, while retaining fast retries for mismatches and normal scan timeouts. Measure from the prompt so a slow claim cannot hide a fast failure. Paced user activity retries preserve the daemon idle window required to clear a wedged claim. Initial probe outages remain visible without inventing enrollment, and setup cannot claim lock-screen success when the PAM configuration was not installed. Exercise the real QML service rather than a copy of its state machine.

Co-Authored-By: GPT-6 <noreply@openai.com>

Co-Authored-By: Claude Opus 5.5 Medium <noreply@anthropic.com>

* Avoid competing fingerprint probes and partial setup

Known enrollment is recovered by the PAM retry loop, so failed status probes must not raise a false unavailable notice or interrupt its daemon idle window. Initial unknown enrollment still gets paced probes. Install recovery files before enabling fingerprint PAM so a missing source cannot leave a new partial configuration.

Co-Authored-By: GPT-6 <noreply@openai.com>

Co-Authored-By: Claude Opus 5.5 Medium <noreply@anthropic.com>

* Keep the fingerprint error clock at the first prompt

pam_fprintd also sends Verification timed out as an informational message. Updating the prompt timestamp on that message made a normal full scan window look like an immediate device error and caused unnecessary backoff. Record the first prompt of each PAM attempt so later status messages cannot move the error window.

Co-Authored-By: GPT-6 <noreply@openai.com>

---------

Co-authored-by: Omabot <omabot@omarchy.org>
Co-authored-by: MaxMad75 <44462964+MaxMad75@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Karl Ahlin <kalle.ahlin@gmail.com>
Co-authored-by: Omarchy Bot <omarchybot@users.noreply.github.com>
2026-10-04 12:49:44 -04:00
David Heinemeier Hansson 54e3b53b88 Make application transparency opt-in (#14253)
* Make application transparency opt-in

* Use the transparency helper for Kanagawa

* Address transparency review findings

* Toggle transparency for opaque windows too
2026-10-04 12:44:15 -04:00
Paul Wagner 6520ac7d25 Configure xdg-desktop-portal to use gnome-keyring (#9594) 2026-10-04 10:22:11 -04:00
MichaelandMichael de By baf0287138 Ask RTKit directly for the speaker tuning's realtime priority (#12958)
The tuning host loaded module-rt with default arguments, so it asked for
realtime through xdg-desktop-portal. A portal started before rtkit was
installed reports a realtime budget of zero. module-rt applies that as a
hard RLIMIT_RTTIME of 0, RTKit still makes the data thread SCHED_RR, and
the kernel kills the host with SIGKILL as soon as audio plays; systemd
restarts it every two seconds and the speakers stay silent until the next
login.

Set rtportal.enabled = false, as the stock pipewire.conf does. The host is
a user service in the same session as the PipeWire daemon and has no
reason to go through the portal.

The host config is only copied by `omarchy-audio-tuning on`, so a
migration replaces an installed copy that is still Omarchy's own with the
old setting, and restarts the host if it runs. It does not call `on`,
which would overwrite a tuning graph another tool wrote under the same
name.

Co-authored-by: Michael de By <contact@michaeldeby.nl>
2026-10-04 10:20:11 -04:00
245630786c Resolve effect_input/effect_output audio filter pairs (#14241)
* Resolve effect_input/effect_output audio filter pairs

User filter chains can name their input and output nodes differently, so the existing input-name prefix check cannot find their downstream sink. Match effect_output.<suffix> exactly for effect_input.<suffix>, avoiding similarly named filters while retaining prefix-compatible custom tunings and the existing EasyEffects fallbacks.

Generated by GPT-6 in Codex. Reviewed by Claude Opus 5.5 Medium.

Co-Authored-By: Claude Opus 5.5 Medium <noreply@anthropic.com>

* Prefer an exact filter output over legacy prefix matches

An earlier stream for a similarly named filter can satisfy the legacy prefix check and select the wrong device before the exact paired output is read. Keep the first prefix candidate as a fallback for effect_input sinks and finish searching for the exact effect_output pair. Preserve first-match behavior for all other sink names.

Generated by GPT-6 in Codex. Reviewed by Claude Opus 5.5 Medium.

Co-Authored-By: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

---------

Co-authored-by: Omarchy Bot <317366263+omarchybot@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 Medium <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-10-04 10:07:28 -04:00
David Heinemeier HanssonandClaude Opus 5.5 e73c4d863b Line up bar labels and icons (#14234)
Text labels centered their whole line box, which keeps room for
descenders, so the clock and workspace numbers sat a pixel above the
icons beside them. Center single-line labels on the figure height
instead. The menu logo comes from an icon font and opts out.

The Elsewhen globe also took the wide icon slot while its neighbors in
the center cluster use the status slot, so it drew extra space on both
sides. Give it the status slot like the weather widget.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 09:21:32 -04:00
David Heinemeier Hansson 27a3feffc6 Fix icon spacing 2026-10-04 09:10:55 -04:00
David Heinemeier Hansson f9646299bf Apply agent account selection to CLI subprocesses (#14233)
* Apply agent account selection to CLI subprocesses

* Fix account dispatch edge cases and remove redundant shell wrappers

* Use the current account when launching a new agent session
2026-10-04 09:01:06 -04:00
David Heinemeier Hansson 5657a91f8d Drop Lazydocker from new installs (#14230)
* Drop Lazydocker from new installs

* Preserve Lazydocker during Quattro upgrades

* Remove Lazydocker from the manual
2026-10-04 08:23:00 -04:00
454b67d95f Fix the bar startup stall and the shell restart race (#11015)
* Instantiate only the current orientation's indicator tree

Indicators.qml built both the horizontal Row and the vertical Column and
toggled them with `visible`, so every indicator existed twice per bar,
and so did every process an indicator spawns: Dictation.qml ran two
`voxtype status --follow` per monitor. On a six-monitor bar that is 72
indicator instances and twelve followers for six visible icons, and each
instance registers a click target and re-syncs the active-indicator model
as its state resolves at startup.

A Loader now instantiates the tree that matches `root.vertical`. Each tree
is wrapped in an Item that keeps the stock explicit implicit-size
expressions, so the root's size still follows the blocks synchronously; a
bare positioner only updates its implicit size on polish, which the
indicator contract test's center-hover check catches.

Measured on a six-monitor, 23-widget bar (three runs each, `omarchy
restart shell`): time from "Configuration Loaded" to "polkit agent
registered" 19.8-20.3s -> 15.5-15.7s, quickshell CPU 29-30s -> 24-25s,
voxtype followers 12 -> 6.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Coalesce plugin API resyncs and key bar object ownership by target

Every WidgetButton registers itself as a bar click target when it is
created. registerClickTarget replaced the clickTargets array, the change
handler ran syncAllPluginBarApiObjects() inline, and that walked every
plugin API times every click target times a linear scan of
pluginObjectOwners in pluginObjectRecord. Startup is a few hundred
registrations, so the cost is quadratic in bar size and multiplied by the
number of monitors: a six-monitor, 23-widget bar spent 16-20 seconds of
pegged QML thread before it was populated, and an 8-second qmlprofiler
capture showed 1.36 million pluginOwnsBarObject calls and 46-71ms per
registration.

- pluginObjectOwners is a Map keyed by target, so pluginObjectRecord,
  markPluginObject, unmarkPluginObject and releasePluginObjects are O(1)
  per object. Nothing outside Bar.qml read the array.
- The activePopout, clickTargets and layoutConfig change handlers schedule
  one resync per event-loop turn through Qt.callLater, the way
  onModuleSlotsChanged already defers prunePluginBarApis. bindPluginBarApi
  still syncs a brand-new API inline, and requestPluginPopout and
  releasePluginPopout sync the owning API inline, so a plugin never reads
  a stale API on its own actions.
- A flush serialises the layout once and hands each API its own parsed
  copy instead of deep-copying it once per API per sync.
- ModuleSlot's cursorShape read clickTargets for every slot on every
  monitor (26,700 evaluations per start). It is now gated on the slot's
  HoverHandler, which is the only time the cursor is over it.

Measured on the same bar, launching the shell from a checkout with this
and the indicators change (two runs): "Configuration Loaded" to "polkit
agent registered" 19.8-20.3s -> 0.70s, bar populated 1.7s after launch
(from ~30s), quickshell CPU 29-30s -> 2.0s.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Wait for the old shell to exit before restarting it

omarchy-restart-shell stopped the running shell with `quickshell kill`
under a five-second timeout and launched the replacement as soon as the
loop ended. A six-monitor bar takes 5.4-6.0 seconds to tear down (every
widget button unregisters its click target on destruction, and each
unregistration re-synced every plugin API), so the client timed out while
the shell was still exiting, the fresh instance's no-duplicate check saw
the dying one and quit, and the user was left with no bar and "Omarchy
shell did not become ready after restart".

Give the kill client thirty seconds, then wait, bounded, until
`quickshell list` shows no instance of the session config before
launching. The readiness check also waits on a sixty-second deadline
instead of twenty attempts: a large bar answers ping only after its
plugins have loaded, which on the stock bar was well past the old
twelve-second window.

Verified three consecutive restarts against the stock shell on the
six-monitor machine: each returned 0 in about six seconds with exactly
one instance and no "already running" in the journal.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Say what the bar's ownership Map actually saves

Qt's V4 Map (ESTable::get) finds a key by scanning its keys, so ownership lookups are not O(1). The win is that a registration no longer copies the owner array and rescans it in QML.

Co-Authored-By: Codex Medium <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: Omarchybot <317366263+omarchybot@users.noreply.github.com>
Co-authored-by: Codex Medium <noreply@openai.com>
2026-10-04 08:09:30 -04:00
David Heinemeier Hansson 2b2d462652 Merge pull request #13725 from houz42/fix/monitor-panel-display-toggle
Fix Display panel display toggle using rejected hyprctl keyword
2026-10-04 08:07:38 -04:00
David Heinemeier Hansson dda5b40820 Merge pull request #12569 from selfcrypto/install-gpu-drivers-before-steam
Install lib32 graphics drivers before Steam
2026-10-04 08:06:56 -04:00
David Heinemeier Hansson 00cee6d319 Merge pull request #7806 from berndb/lock-ignore-autorepeat
Drop key auto-repeat in the lock screen password field
2026-10-04 07:48:34 -04:00
David Heinemeier Hansson 35a0d59053 Merge pull request #12323 from atoslins/fix-windows-vm-setgid
Clear setgid when hardening Windows VM directories
2026-10-04 07:47:20 -04:00
David Heinemeier Hansson 7901d7d0b6 Merge pull request #12538 from paulogeyer/fix/10860-idle-timeout-zero
Treat idle timeout 0 as disabled, not immediate
2026-10-04 07:44:44 -04:00
David Heinemeier Hansson ea5e438f4f Merge pull request #13396 from stefanoverna/easyeffects-sink-resolver
Resolve the audio output sink through EasyEffects 8
2026-10-04 07:42:57 -04:00
David Heinemeier Hansson 58aa85721d Merge pull request #13076 from chemineer1/codex/shutdown-lid-inhibit
Prevent lid closure from interrupting shutdown
2026-10-04 07:39:20 -04:00
David Heinemeier Hansson cb865c2fa4 Merge pull request #9873 from Wheel-Smith/fix/polkit-faillock-system-auth
Defer to system-auth in the polkit stack written by fingerprint/FIDO2 setup
2026-10-04 07:37:24 -04:00
David Heinemeier Hansson 4a568a1388 Merge pull request #14225 from omacom/fix/agents-usage-display
Fix agent usage resets, stale display, and icon underline alignment
2026-10-04 07:14:39 -04:00
David Heinemeier Hansson 7f91a8d49e Show recovery guidance when paused usage has no cache 2026-10-04 07:10:41 -04:00
David Heinemeier Hansson 6809987892 Align panel underlines with native bar icons 2026-10-04 07:01:03 -04:00
David Heinemeier Hansson d21e5810b1 Reset elapsed agent usage and show stale ages on hover 2026-10-04 07:01:03 -04:00
1f100bdf3f Stop advertising forced user-setup reruns (#14194)
User setup recreates installer-seeded files, so presenting a full rerun as a refresh or an after-login recovery step implies that it safely preserves an existing account. Remove that advice from first-boot provisioning and both user-setup entry points. Setup failures still report the log path, and provisioning behaviour is unchanged.

Co-authored-by: Omarchybot <317366263+omarchybot@users.noreply.github.com>
Co-authored-by: GPT-6 <noreply@openai.com>
2026-10-04 06:47:39 -04:00
Omarchybot 5c4da02146 Merge pull request #7783 from omacom/fix/issue-6952
Keep PwNode objects out of the audio panel's Repeater models
2026-10-04 02:33:47 +02:00
75b327bc28 Apply the system keyboard layout to the SDDM greeter (#6896)
* Apply the system keyboard layout to the SDDM greeter

* Extract shared assert_resolved_input helper

* Make the missing vconsole.conf tests actually open nothing

The stub only returned nil for an unset OMARCHY_VCONSOLE, but bash passes it
through as "", so both fallback cases were reading an empty temp file instead
of exercising the failed open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-03 20:23:00 -04:00
David Heinemeier HanssonandClaude Opus 5.5 18328559b9 Keep the Wi-Fi icon steady on OWE transition-mode networks (#14133)
* Keep the Wi-Fi icon steady on OWE transition-mode networks

An OWE transition-mode network pairs an open SSID with a hidden "_owetm_"
twin on the same BSSID. Between scans NetworkManager reports the in-use
access point under the hidden SSID and drops the active profile from the
device's AvailableConnections, which is the only place Quickshell builds
known networks from. No listed network is then connected, so the bar fell
back to the disconnected icon until the next scan brought the open SSID
back, flickering on and off while the link stayed up.

Fall back to the Wi-Fi device's own connected state when no listed network
is connected, and read the in-use access point's strength from nmcli
(without a rescan) while the connected network has none of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Discard stale in-use AP reads and key Wi-Fi checks on the device

Bump a generation whenever the cached in-use access point strength stops
describing the link (leaving Wi-Fi or a device change), and drop any nmcli
read started before it, re-reading immediately instead of a full interval
later.

Key Wi-Fi connectivity checks on the device rather than the SSID, so the
listed network coming and going with each scan on an OWE transition-mode
network no longer schedules a check. A real network switch still passes
through "disconnected".

Add a QML fixture that runs the panel against a mocked device through the
scan churn, a mid-read disconnect, and a final disconnect.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:05:32 -04:00
81df05533c Invalidate the display rebuild marker before repairing its drop-in
A pending migration must rebuild after restoring its missing display configuration even when another user previously completed the machine-wide rebuild. Remove the fixed marker before writing the drop-in so failed writes and builds remain retryable. Fresh installation already rebuilds after hardware setup through the ISO finalizer.

Co-Authored-By: GPT-6.1-Sol XHigh <noreply@openai.com>

Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-10-03 17:09:14 -05:00
Spencer BullandGPT-6.1-Sol XHigh 8f7dd71fda Apply XPS 13 Panther Lake display parameters
Disable PSR2 selective fetch and Panel Replay on DX13260 Panther Lake through a Limine drop-in. Apply it during hardware setup and an update migration, rebuild once per machine, and request reboot until both parameters are booted. Reject diagnosed Limine build errors even when the wrapper returns success so interrupted repairs stay pending.

Co-Authored-By: GPT-6.1-Sol XHigh <noreply@openai.com>
2026-10-03 16:59:33 -05:00
bjarneo 8e02fc84f5 Merge pull request #14156 from bjarneo/fix/image-picker-hidpi
Keep image picker previews sharp on HiDPI displays
2026-10-03 18:25:39 +02:00
Bjarne Oeverli e5663c7e3a Keep image picker previews sharp on HiDPI displays 2026-10-03 18:19:16 +02:00
bjarneo dccd88b6c9 Merge pull request #14117 from keylimesoda/fix/image-picker-large-collections
Keep the image picker responsive with large theme collections
2026-10-03 18:10:45 +02:00
393a43d469 Pin root= before the packages that can drop it (#6951)
* Pin root= before the packages that can drop it

limine-entry-tool falls back to /proc/cmdline for root= only while nothing
appends to KERNEL_CMDLINE. Installing omarchy-settings lands a drop-in that
appends with +=, switching that fallback off, and a kernel bump in the same
transaction then bakes a UKI with no root= at all. preserve_kernel_cmdline_root
repaired that afterwards, so a completed upgrade booted — but the machine was
unbootable for the seconds in between, and an upgrade interrupted there left it
in an emergency shell.

Pinning cannot wait until after the packages land, and the old guard could not
be moved earlier as it was: it asked the tool for its effective cmdline, which
still resolves root= through the fallback right up until the drop-in arrives,
so it reported healthy on exactly the machines about to break. Ask the config
layers whether root= is stated explicitly instead, for the default profile
alone, and pin before the package transaction. Verification stays after it,
since that is what rebuilds the UKIs.

The pin no longer gates on limine-mkinitcpio being present, since it now runs
before the transaction that can install it, and a machine still missing it is
exactly one that needs pinning first.

Verified in a VM upgrading a 3.8.0 install, sampling the UKI every 2s across the
upgrade: a legacy install without the pin lost root= for ~10s, and booting that
state landed in "Failed to mount '' on real root". With this change the same
upgrade never loses it, and an install that already pins root= is untouched.

Closes #6894

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Merge the limine config layers when checking for a root= pin, and re-pin after the transaction

The pin check returned on any line that mentioned root= under KERNEL_CMDLINE[default]. limine-entry-tool merges its layers in order, /usr/share drop-ins, /etc/limine-entry-tool.conf, /etc drop-ins, then /etc/default/limine, where = replaces and += appends, so a later layer's = could remove a pin the check still counted, and a quote before root= let systemd.setenv="root=..." read as one. Both skip the pin, which is the direction that bricks the next boot. The check now merges the layers the same way, strips only the outer double quotes the tool strips, drops quoted values the kernel does not split, and looks for root= as a parameter of its own. It was checked against limine-entry-tool --get-cmdline on a worker for every fixture in the test.

Installing limine-mkinitcpio-hook runs limine-install, so a machine with no /boot/limine.conf when the pin runs ahead of the transaction can have one after it, and verification only warned about the UKI it found without root=. Verification now runs the pin for any machine the first call skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex Medium <noreply@openai.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: omarchybot <omarchybot@users.noreply.github.com>
Co-authored-by: Codex Medium <noreply@openai.com>
2026-10-03 08:08:13 -04:00
e0b0f349f8 Escape the output name in the Display panel's hl.monitor eval
Hyprland lets a headless output take any name (`hyprctl output create headless <name>`), so a name with a quote broke out of the Lua string and the toggle failed for that output. Escape backslashes and quotes instead of asserting a character set the names do not actually follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex Medium <noreply@openai.com>
2026-10-03 12:29:24 +02:00
Ric Lewis 520e4bca5b Retain lazy thumbnail jobs across concurrent refreshes 2026-10-03 00:10:16 -07:00
Ric Lewis a8e09b9ce2 Keep refreshed image picker selection inside the active filter 2026-10-03 00:03:44 -07:00
Ric Lewis 2ba1015ef2 Keep image picker work bounded for large theme collections 2026-10-02 22:45:39 -07:00
OmarchybotandClaude Opus 5.5 63c31bc68e Give the audio output sink test the standard header
docs/testing.md asks every shell test to start with set -euo pipefail and source base-test.sh through BASH_SOURCE; without it a failed mktemp leaves test_home empty and reset_scenario goes on to rm -rf /data.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 07:06:39 +02:00
OmarchybotandClaude Opus 5.5 357d84f17a Follow EasyEffects' links to Bluetooth sinks too
EasyEffects plays into Bluetooth headphones through the same direct port links as into a card, but only alsa_output targets were followed, so with headphones on the keys fell back to the settings file -- which can still name the speakers -- or to easyeffects_sink. Match bluez_output sinks as well, and compare the configured device name literally rather than as a regex.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 07:06:39 +02:00
Spencer Bull a85e29abb5 Merge pull request #13296 from omacom/openclaw-self-updating
Install OpenClaw as a self-updating copy under ~/.openclaw
2026-10-02 21:21:18 -05:00
Spencer Bull 0537ae121b Merge pull request #11925 from joewinke/fix/plugin-thirdparty-sourcedir
fix(shell): keep __sourceDir on third-party plugin manifests
2026-10-02 21:08:57 -05:00
+14 75250d37ac Fix Codex limits, Claude counting, and agent usage reliability from community PRs (#14049)
* Read Codex app-server replies from the raw fd (#13703)

* Resolve Codex through mise which instead of running the lazy launcher (#13109)

* Skip the Codex app-server probe when there are no credentials (#13106)

Adapted: credentials are checked in the home being probed rather than in
the CODEX_HOME environment variable, since each registered account is
probed in its own home, so a signed-out secondary account isn't hidden
behind the primary's login. A home without credentials reports "Waiting
for auth" like any other signed-out home. The credentials store setting is
read with tomllib, so a single-quoted value counts too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show the Codex CLI's own error when its app-server dies (#8977)

Detect an app-server that exits or stops answering, and report the end of
its stderr instead of a bare RPC method name. Rebased onto the raw-fd
reply reader; the switch from "-a on-request" to "-a never" is left out,
keeping the current approval flags.

* Count pi sessions when HOME is a git checkout (#13209)

* Count only OpenAI-backed native sessions as Codex usage (#12032)

* Deduplicate Pi usage across forked sessions (#8602)

* Skip unchanged native Codex token snapshots (#10531)

* Count omp and pi profile sessions in the agent usage collectors (#9546)

`omp --profile=<name>` (and pi's equivalent) relocates the whole agent
tree under <base>/profiles/<name>/. The Claude and Codex collectors only
ever scanned <base>/agent/sessions, so a subscription driven entirely
through a profile was invisible to the agents panel: no tokens by day, no
tokens by model, no prompt or session counts.

Discover the profile roots alongside the default one. Sessions are keyed
by file path, so a profile adds sessions instead of double-counting the
default root, and a missing or unreadable profiles directory leaves the
existing behavior untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zFbJcDEEpV5BAmsH6kAB3

* Skip unrelated Codex session lines before JSON parsing (#12803)

Adapted: session_meta lines also pass the pre-filter, since the provider
filter from #12032 reads them to skip rollouts served by a non-OpenAI
provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read only the Codex session files that changed since the last scan (#12595)

Native Codex rollouts keep per-file totals between runs, replayed while a
file's mtime and size are unchanged. Rebased onto the session_meta
provider filter, snapshot dedup, and line pre-filter, which now live in
the per-file reader. pi and omp sessions are left out of the per-file
cache: a forked pi session repeats its parent's messages, so they are
deduplicated across the whole tree on every scan.

* Count streamed Claude messages by their highest-output usage line (#10606)

Claude Code writes a streamed assistant response as several transcript
lines that share one message id, one per content block. Each line
carries a usage object. The first line's output_tokens is a placeholder,
often 1, and the last line has the real count. Input and cache fields
usually match across the lines.

The scanner dedupes by message id and keeps the first line it sees, so
it under-counts output tokens. On a machine with 2,577 transcripts it
reported 39.0M output tokens against 60.1M used, a 35% shortfall. Input
and both cache fields differed by under 0.01%.

Keep the line with the highest output count, with the last one scanned
winning a tie. The whole line is kept because a response can fall back
to another model mid-stream. Those lines are separate snapshots with
different cache figures and a different model, and taking a maximum per
field across them over-counts cache tokens and credits the wrong model.

The zero-usage check now runs before dedup, so a zero-usage first line
no longer claims a message id and hides a later line with real usage.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: GPT-6 Astra <noreply@openai.com>

* Index Claude transcripts so the agents refresh reads only what was appended (#8313)

omarchy-agent-usage-claude re-parsed every line of every transcript under
~/.claude/projects on each refresh: no mtime cutoff, no memory of the last
pass. The agents widget is on by default and ticks every 15 minutes, so the
cost grew for the life of the machine. After one month here that was 803
files, 640 MB, 127k lines and 57k JSON parses per tick, about 1 core-second,
pushed through the page cache every quarter hour forever.

Keep a per-file index next to the scan cache: the unique usage records
already parsed out of each transcript and the byte offset they end at. A
file whose size and mtime match is not opened; a file that grew is read
from the stored offset; a file that shrank or was rewritten is read from
the start. --force drops the index and rescans from scratch.

The summary is built from the indexed records in the same directory order
the walk always used. That matters: when a resumed session carries earlier
messages, the same message id appears in two files with different usage,
and the first file visited wins. 91 ids differed on this machine; sorting
the walk moved one model's output total by 25k tokens. Output is now
byte-identical to the previous scan on a frozen copy of the corpus, cold,
warm, and after an append.

Warm refresh: 1.0 s -> 0.10 s of CPU, of which the scan itself is 70 ms;
the index for this corpus is 2.9 MB.

Adapted:
- Rebased onto #10606: the highest-output rule for streamed messages now
  lives where the index parses records, and decides between files too.
- The index records the timezone it was written in, and a change rereads
  every transcript, since its records hold local days.
- A file only counts as appended to when its inode and the hash of what
  was already read still match, so a transcript replaced by a larger one,
  or rewritten in place, is read from the start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Label a Claude Team seat by its subscription, not its rate-limit tier (#11109)

The collector built the plan label from the OAuth rateLimitTier first, so a
Team premium seat, which runs on default_claude_max_5x, showed in the agents
panel as "Max 5x". Lead with subscriptionType and keep the multiplier as its
qualifier: Max still reads "Max 5x", a Team seat reads "Team 5x".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Label the Claude plan from the profile the CLI refreshes (#7225)

Adapted: the profile is found the same way current_account_id() finds it,
now shared as profile_path(): ~/.claude.json for the default home, the
home's own .claude.json otherwise. The original fell back to ~/.claude.json
for any home without CLAUDE_CONFIG_DIR set, so a secondary account read the
primary's tier. The profile's tier also keeps the subscription in the label,
so a Team seat stays "Team" (#11109).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Call a lapsed Claude access token paused, not signed out (#8093)

* Refresh Claude usage after the clock moves backwards (#9956)

* Bound unreadable Claude transcript warnings (#12414)

* Count Claude usage from opencode v2 sessions (#13894)

* Reload agent usage records when an inotify watch fails to rearm (#10067)

* Reload agent usage records after each update run instead of on a timer

Rather than #10067's two-minute timer per record, reload every record when
the omarchy-agent-usage-update process exits, the moment its files can have
been replaced. A reload that finds a file unchanged keeps its record, so the
panel isn't stirred up by identical data. The grep test now runs the QML
functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show the agent status when the trouble line has no help text (#8497)

* Clear stale agent login guidance after a successful probe (#8892)

* Clear the Grok login hint after a successful probe

#8892 cleared the default login hint after a successful probe in the
Claude and Codex collectors; Grok's collector had the same stale hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read Fireworks credentials from pi's auth.json (#7455)

The Fireworks collector skipped pi, Omarchy's default agent, when
walking its credential ladder, so a machine signed in to Fireworks only
through pi (/login fireworks) never showed the tab. Insert the key pi
stores in $PI_CODING_AGENT_DIR/auth.json (default ~/.pi/agent) between
the firectl auth.ini and the opencode fallback.

pi keys can be literals, $ENV_VAR/${ENV_VAR} references, or !command
shell lookups. The collector resolves the first two; command lookups
stay pi-only and are skipped rather than sent to the API verbatim.

* Call a lapsed Grok access token paused, not signed out

Grok's access token lives six hours and Grok mints a new one from its
refresh token whenever it starts, so a lapsed one is routine. Reporting
it as an expired sign-in made the panel offer Sign-in required several
times a day, sending people through grok login for nothing. With a
refresh token present it now reads as paused, like Claude's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep showing Grok's last limits while it sits idle

While Grok hasn't run, nothing on the machine has spent its allowance,
so with a refresh token on hand the last numbers still stand: they show
as current rather than dimmed under a status line. A weekly window that
reset in the meantime starts over at 0%, a whole number of weeks on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ask for a Grok sign-in once its refresh token is past 30 days

A refresh token older than Grok's 30-day sign-in can't renew anything,
so the panel offers Sign-in required again instead of showing the last
limits as current. With nothing cached yet it says to start Grok, rather
than showing an empty section without a word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check both ends of what the Claude index read before resuming a transcript

A transcript rewritten in place could grow and change only after its
first kilobytes, and the index took it for an append. It now compares
the last kilobytes before the resume point too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Simplify the agent usage collectors

- Codex: pass the forced-scan choice down instead of a module global, make
  the per-file reader's cache arguments required, shrink the cache record
  check, and drop guards for shapes that can't occur: an empty launcher
  path, realpath raising, mise itself being a lazy launcher, multi-line
  `mise which` output, and probing without a temp file for stderr.
- Claude: decide an append by the digest of both ends of what was read
  alone; the inode and mtime checks it made redundant are gone.
- Snapshot: the device id falls back to the hostname, which always exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Share fixture setup in the agent usage scanner tests

Every fixture home lives under one scratch directory with a single cleanup
trap, instead of a trap rewritten with a longer list for each new home, and
the Codex test builds its signed-in homes with one helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Treat a replaced Claude transcript as new even when its ends match

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Probe Codex without its error text when there's no temporary space

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: tossbaws <17258053+tossbaws@users.noreply.github.com>
Co-authored-by: surim0n <suritech@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: anonwurcod <anonwurcod@proton.me>
Co-authored-by: Kevin Rajan <7121943+kvnloo@users.noreply.github.com>
Co-authored-by: Nate Ashby <nate.ashby11@gmail.com>
Co-authored-by: Aris Gysel <aris.gysel@me.com>
Co-authored-by: Brams <76213579+Brams-s@users.noreply.github.com>
Co-authored-by: This_Is_NPC <gabrielfollone27@gmail.com>
Co-authored-by: sanjyay <102979855+sanjyay@users.noreply.github.com>
Co-authored-by: PapistProtocol <12738904+PapistProtocol@users.noreply.github.com>
Co-authored-by: steez <stevedimakos97@gmail.com>
Co-authored-by: GPT-6 Astra <noreply@openai.com>
Co-authored-by: Ryan Yogan <ryanyogan@gmail.com>
Co-authored-by: Oli Denton <41393837+omdenton@users.noreply.github.com>
Co-authored-by: Igor Kramar <i@ikramar.ru>
Co-authored-by: Martin Eidensten <martin@meibe.se>
Co-authored-by: Romain Perron <rdj.perron@gmail.com>
Co-authored-by: Omarchy Contributor <contributor@users.noreply.github.com>
Co-authored-by: manuaudio <manu@arimaka.com>
Co-authored-by: Tyler South <tsouth2@gmail.com>
Co-authored-by: whathek <Hek846@users.noreply.github.com>
Co-authored-by: Ty Richards <me@tyrichards.com>
2026-10-02 22:03:07 -04:00
96af8d4c02 Escape firmware test fixture paths
Quote the marker path as a Bash literal before escaping it for sed, so TMPDIR metacharacters survive both fixture copying and script evaluation. Rerun the firmware regression under a special-character TMPDIR while preserving the original PATH for real pacman resolution.

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>

Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-10-01 23:46:32 -05:00
Spencer BullandGPT-6.1-Sol 8be14d5ac6 Explain why the Cirrus firmware repair uses pacman directly
Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
2026-10-01 23:41:22 -05:00
89fcd6a1b7 Keep the firmware reboot marker destination fixed
A caller-controlled marker path would let the privileged install command replace an unrelated file. Keep the production path fixed and redirect that literal only in isolated test copies. Cover hostile environment overrides without touching system files.

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>

Co-Authored-By: Greptile <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-10-01 23:09:06 -05:00
Spencer BullandGPT-6.1-Sol 45d2e807db Install the Dell XPS 13 Panther Lake speaker firmware
Explicitly select the Omarchy Cirrus firmware when the installed package predates the 1028:0e54 aliases. Apply it during hardware setup and through an update migration, and request a reboot for each user until the firmware can load. Require the same minimum version in the offline package list so newer Arch snapshots keep their genuine firmware.

Co-Authored-By: GPT-6.1-Sol <noreply@openai.com>
2026-10-01 23:00:51 -05:00
821ae58905 Reorder the agents in the panel, count Grok's tokens, and install Grok through mise (#14004)
* Let the agents in the panel be put in any order

Drag an agent by its mark to move its section; the header it will land
on lights up, and the move happens on release. Each agent's header is
now a keyboard stop with its own highlight, and Ctrl+Up/Down moves the
agent the cursor is in. The order is kept in agents/order.json beside
the usage records. The key catcher turns Ctrl+Up/Down into a reorder
only for panels that opt in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Light only an agent's mark when the cursor or a drag is on it

The mark is the handle the agent moves by, so the keyboard cursor and
the drop spot while dragging box it alone rather than the header line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a lit agent mark's box from being clipped at the panel's edge

The box overhangs the content's left edge, which the scrolling area
clipped. The scrolling area now reaches a little into the panel's
padding with the content shifted back, so nothing moves and the box
draws whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reuse a Grok session scan only on the day it was made

A limits-only refresh just after midnight reused a scan from the evening
before, which counted yesterday's sessions as today's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep Ctrl+Up/Down from moving an agent when the cursor is outside one

The hero's buttons and the starter tiles carry indices too, and the
lookup read them as accounts, so with several accounts Ctrl+Up/Down on
one of them moved an unrelated agent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report a failed mise update even after the Grok upkeep runs

The Grok block ran after `mise up` and its last command set the script's
status, so a failed tool update could read as a success to callers that
warn about it. The update's own status is now the script's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Unpack a Grok update into ~/.grok whatever GROK_HOME says

The upkeep replaces ~/.grok's link, but the npm launcher unpacks into
GROK_HOME when it's set, so with a custom home the link never came back
at the new release and the old one was restored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold off reordering agents while an account name is being edited

Ctrl+Up/Down reached the key catcher during an inline rename, and moving
the agent rebuilt its section, dropping the unfinished name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Install Grok through mise's first-party package

The npm launcher keeps an old ~/.grok/bin binary because mise skips its
postinstall. Use mise's grok tool, and drop the npm tool so its shim
does not stay ahead of the stub.

* Drop the npm Grok workarounds now that mise installs Grok itself

With Grok installed through mise's first-party package, the CLI is the
binary mise manages, so the update upkeep that repointed ~/.grok/bin and
the shared bin directory for added Grok accounts have nothing left to do.
omarchy-update-mise is back to running mise up and nothing else, and
adding a first Grok account installs the same package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count Grok's tokens and prompts from its usage ledger

Each Grok session keeps a usage.json, the ledger `grok usage` prints, with
every finished turn's end time and tokens by model. The collector now
reads it for tokens today, by day for the last week, and by model, with
cached input kept apart, and counts today's prompts by the turns that
ended today. Found in #12352's research.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count cache writes in Grok's daily token totals

Grok's totalTokens leaves cache writes out while the per-model buckets
count them, so a turn with cache writes added less to its day than to
its model. The day's total is now the sum of those same buckets.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove the npm launcher's old Grok binaries when moving to mise's Grok

Omarchy's npm wrapper ran the binary the launcher unpacked into
~/.grok/bin, where x.ai's installer also puts a copy with a PATH entry
ahead of mise. Once the wrapper is replaced, a binary left there would
keep shadowing the mise tool, so the migration removes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Jesse Miller <jmiller@jmiller.com>
2026-10-02 03:34:05 +02:00
OmarchybotandClaude Opus 5.5 d95c68f9fb Match only active include lines when deciding polkit-1 is already fixed
The layout check skips comments, but the already-fixed check matched `include system-auth` anywhere on a line, so a comment such as `# auth include system-auth` made the migration skip a stack that still listed bare pam_unix, and it was marked complete with the lockout still missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 03:12:21 +02:00
60c73865e4 Rewrite polkit-1 in place with sed -i so an interrupted migration cannot truncate it
`sudo tee` truncates the live PAM file before writing it, so a migration interrupted in between left /etc/pam.d/polkit-1 empty; the next run then failed the layout check, exited 0, and was marked complete with polkit authentication broken. sed -i writes a temporary file and renames it into place, as the setup commands already do, so the original stays intact until the rewrite is whole and a failed sed needs no restore.

The test's broken-write stub now empties the file instead of discarding the write, so the restore assertion fails if the restore is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex Medium <noreply@openai.com>
2026-10-02 03:04:07 +02:00
David Heinemeier HanssonandClaude Opus 5.5 f45461a38f Bring Grok up to par with Claude and Codex in the agents panel (#13992)
* Let Grok updates through mise take effect

Grok's npm launcher runs ~/.grok/bin/grok whenever it exists, and the
install script that repoints it at a new release doesn't run under mise,
so every machine kept running the release it first unpacked. Updating
mise tools now drops a link to an older release and the old binary, and
lets the launcher unpack the installed one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep several Grok accounts, the way Claude and Codex do

Grok honors GROK_HOME, so an added Grok account gets its own home holding
only its login and settings cache, with the CLI binary, sessions, skills,
plugins, memory, and config linked back to ~/.grok. The account commands,
the add flow (a second account signs in through a private window), the
launcher, and a grok shell function all take it like the others, and its
identity and plan come from the files Grok writes at login.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show Grok's plan and credits in the agents panel

A grok collector reads each account's plan from the settings Grok caches
and its credits from the endpoint behind Grok's own /usage view, so Grok
gets a section, per-account limits, and autoswitch like Claude and Codex.
A signed-in agent with a plan now shows before its first numbers, so a
lapsed sign-in has somewhere to say so. Grok's mark joins the assets, and
with every agent able to take another account the add screen no longer
needs to dim one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document Grok accounts and limits alongside Claude and Codex

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read Grok's credits the way xAI actually answers

The answer nests under config, names a weekly period with its end, and
as protobuf JSON leaves the usage percentage out while it's zero. The
collector now reads that shape, so a fresh week shows as 0% until it
resets rather than as nothing known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count Grok's prompts and sessions from its session summaries

Grok keeps a summary beside each session with when it was last active
and how many prompts it had, so its section and the hero's summary get
today's prompts and sessions and its active days. It records no token
counts there, so none are claimed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Give each Grok limits cache write its own temporary file

Two overlapping collector runs wrote the same cache through one .tmp
path, so one rename could leave the other's failing and its record not
updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read a single Grok account from GROK_HOME when it's set

The launcher and the CLI honor GROK_HOME, but the collector always read
~/.grok, so a Grok signed in only in a custom home showed nothing. With
one account, the collector now reads the home the CLI would; with
several registered, the primary stays ~/.grok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop guesses the real Grok credits answer made unnecessary

The collector carried fallbacks for fields and shapes guessed from the
binary before xAI's actual answer was seen, a numeric timestamp branch
nothing writes, the panel's default for prompt stats, and guards that an
empty sign-in already covers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the working Grok release until its update is in place

The update dropped the link and older binaries before the new release was
unpacked and ignored a failed unpack, which could leave every Grok
account without a CLI. The old release now stays until the new one is
linked, and its link comes back if it never is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count only Grok sessions for today, not prompts

A session's summary holds all its prompts and only when it was last
active, so a resumed session put its whole history on today. Today now
counts the sessions active in it; prompts stay an all-time total.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reuse the Grok session scan on a limits-only refresh

Opening the panel and near-limit checks only need fresh limits, so they
reuse a scan up to 15 minutes old, as the Codex collector does; --force
still rescans.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 23:37:56 +02:00
Søren H. Johansen 25db463cd9 fix(t2): drop the superseded firmware package before adding the fetcher 2026-10-01 19:40:35 +02:00
Søren H. Johansen aeeacce566 Name apple-bcm-firmware-fetcher in the T2 package list 2026-10-01 18:42:43 +02:00
David Heinemeier HanssonandClaude Opus 5.5 c05d90196f Switch between several Claude and Codex subscriptions, and build apps the Omarchy way (#13770)
* Plan multiple Claude and Codex accounts with manual or automatic switching

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep several Claude and Codex accounts and start new sessions as the active one

Each added account gets its own home holding only its login, with history,
settings and skills linked back to ~/.claude or ~/.codex so --continue works
across a switch. Accounts are added through the CLI's own login, and cx, cy,
plain claude/codex and omarchy-agent all start as the active account unless
CLAUDE_CONFIG_DIR or CODEX_HOME is already set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report limits for every Claude and Codex account in the usage records

Each registered account is probed with its own sign-in and cached on its own,
and a parked account whose sign-in lapsed keeps its last-known numbers marked
stale. The record's top-level limits keep describing the active account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Switch accounts automatically near a limit, or notify with a one-click switch

After each usage update, an active account at or over its provider's
threshold (95% by default) moves new sessions to the account with the most
headroom in auto mode, or offers that switch as a notification in manual mode.
It never flaps back to an account that just reset, and says once when every
account is over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show every subscription account's limits in the agents panel

With several Claude or Codex accounts, the limits become one card per account
with the active one badged. Pick a card with its number and press Enter (or
click Use) to move new sessions to it, press a to add an account, and m to
toggle automatic switching. Limits refresh every minute while an active
account is above 80%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add Setup > Agent Accounts to list, switch and add Claude and Codex accounts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Document switching between several Claude and Codex subscriptions

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Count a rested parked account as available, and pin Main to its own home

A parked Claude account whose windows all reset now reads as 0% instead of
unknown, so switching can pick it. Main's Codex limits come from ~/.codex even
when CODEX_HOME is set, and duplicate logins are checked against who each home
is signed in as now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Key limits caches by subscription and report when an account truly frees up

An added account's limits cache follows its account id, so a new account
reusing a removed one's label never inherits its allowance. The all-accounts
notice now names when an account's blocking windows have all reset, not the
earliest reset of any window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep account ids clear of routing keywords and refresh the panel after removal

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Share Codex plugins and hooks across accounts, and key Claude caches by current sign-in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the key hint from the Add account button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sign new agent accounts in through a private browser window

The main browser is almost certainly signed in to the account you already
have, and the login would silently reuse it. Both CLIs open their login page
through $BROWSER, so it now points at omarchy-launch-browser --private.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let agent account commands default to your default agent's provider

With Claude or Codex as the default agent, the provider can be left out:
omarchy agent account use work, and primary names the primary account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the primary alias, which shadowed an account named Primary

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Replace the auto switch button with a small Notify / Autoswitch toggle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add accounts from a small + beside the switch toggle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fix the punctuation of the switch toggle's README line

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the border around the add account +

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rename Claude and Codex accounts without moving their homes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Lay out agent accounts on accent rails instead of boxed cards

The active account gets an accent rail and the others a quiet one, each
window is a single compact line, and the switch toggle, add, and Use are text.
Clicking an account's name renames it in place. A window without a reset time
no longer leaves an empty line, and last-known numbers are only red when the
sign-in needs attention.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read Codex limits without waiting on account/read

Codex 0.158's app-server can leave account/read unanswered, and asking it
first lost the limits whenever it did, leaving the agents panel showing
"Codex limits unavailable". The limits name the plan themselves, so they're
asked first and account/read is only a short fallback when they don't.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Switch agent providers from their marks in the panel header

The row of provider buttons gives way to a small mark per provider in the
hero's corner, the selected one at full strength, with the add account +
beside them in place of the + by the switch toggle and the full-width Add
account button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the agents panel's scrollbar off its contents

The panel's content narrows to leave the scrollbar its own strip whenever it
scrolls, and the add account + leads the provider marks instead of sitting at
the very edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add Claude, Codex, or Grok subscriptions from one Add Account menu

The first account for a provider installs its CLI if needed and signs in to
the CLI's own home through the normal browser; only additional accounts get a
home of their own and a private window. Grok signs in its first account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reveal Autoswitch beside Use instead of a Notify / Autoswitch row

The panel's + now opens the Add Account menu for any provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Split agent accounts with plain separators instead of rails

ACTIVE already marks the account new sessions use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Only call Claude limits stale once they're old, and poll near a limit less

Anthropic rate-limits its usage endpoint, and polling two accounts every
minute near a limit got every re-check refused, so both accounts read
"Last known" with numbers a minute old. A refused check of numbers under
15 minutes old now counts as current, older ones say how old they are,
and near-limit polling is every three minutes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show every agent's limits on one page under an Agents hero

The panel stops switching between one provider at a time and lists every
agent and account with its limits, dropping the tokens-by-day and by-model
charts. The hero carries the agents robot and rotates through what the token
counts add up to: tokens this week and today, the most used model, the
busiest day, and today's prompts and sessions. Middle-clicking the bar icon
refreshes, since there's no provider left to advance to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Report Codex's free rate-limit resets in its usage record

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Give the agents panel more room, and show Codex's free resets

Limit lines are a notch larger with more space between them, each account's
limits sit a clear step below its name, and sections breathe. Codex has one
limit on Pro, so its section now also says how many free full resets are
waiting and when the next one lapses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Mark Claude's Fable limit on the Weekly meter instead of its own row

A model-scoped allowance on the same clock as a base window is drawn as a
tick on that window's meter and named in the row's tooltip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let omarchy-default-agent set the default without launching it

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let the agents panel drive adding an account without a terminal

--check says whether adding one now would be each provider's first sign-in
or an additional account, and --events reports progress as tagged lines
alongside the CLI's own output, notifies with the result, and cleans up the
login and its scratch home when cancelled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add subscriptions and pick the default agent right in the agents panel

The + is a proper accent button, and it swaps the list for a picker of
Claude, Codex, and Grok that signs in without a terminal: name a further
account, then follow the sign-in with its status, Grok's confirmation code, a
field for Claude's pasted code, and a link to reopen the page. A dropdown at
the bottom sets the default agent without launching it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Draw the Fable tick in its meter's own color

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Revert "Let omarchy-default-agent set the default without launching it"

This reverts commit da96261f7d26bb76774cbdc1cb9e0abde92bb841.

* Make the first agent signed in the default when none is picked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the agents panel in the bar, with setup, starters, and adding in it

A machine with no agent opens the panel on setting one up. Once set up, the
list ends with starter prompts for a new theme, plugin, or app, and a quiet
Add a subscription. The panel no longer sets the default agent, and the hero
drops its add button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Offer the starter prompts as tiles, and adding as a row beneath them

Theme, Plugin, and App each get a tile with its glyph, and Add a
subscription a matching row with the + in a tinted square. The panel is
allowed to grow tall enough to show it all without scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Put the tinted add button in the agents panel's hero corner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show when a limit resets in small type under its meter

The percentage keeps the right edge to itself, so the meter runs longer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Revert "Show when a limit resets in small type under its meter"

This reverts commit 108d9de59da1af12cddbf232b0b5333ad8994c64.

* Put each account's plan beside its name, and its email in a tooltip

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Stack each limit's percentage over its time left, so the meter runs longer

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show only each limit's time left, with the exact percentage on hover

The meter already says how full a window is. Dropping the percentage beside
it leaves one calm figure per row, and the row's tooltip carries the number.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sign an existing account in again with omarchy-agent-account-add --reauth

It signs in where the account already lives: the CLI's own home (:primary)
through the normal browser, an added one through a private window. The usage
records now say which account is the primary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Offer Sign-in required in the agents panel instead of how old the numbers are

A lapsed sign-in shows as a link that signs that account in again right in
the panel. The "as of" and "last known" notes are gone; trouble that isn't
about signing in still shows as text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the separator dot out of the Sign-in required link

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the Setup > Agent Accounts menu and the stale plan

The agents panel now lists, switches, and adds accounts, so the menu's
duplicate of it goes, and the plan written before the design settled no
longer describes it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Install the compiler and Qt pieces for building Omarchy-style apps

base-devel plus qt6-base, qt6-declarative, qt6-multimedia, and qt6-wayland,
which is what Hype, Monologue, and Omacut build with through qmake6 and make.
Qt was only there as a dependency of those apps, and the compiler not at all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Add an omarchy-app agent skill for building apps the Omarchy way

It teaches how Hype, Monologue, and Omacut are built: C++ and Qt Quick in
one flat project, qmake6 and make into a single binary, a theme that follows
Omarchy's accent live, portal dialogs, keyboard-first conventions, Qt Test
offscreen, and a PKGBUILD that puts the app in the launcher, with starter
files that build and pass their tests as written. The agents panel's App
starter uses it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Link the omarchy-app skill on existing installs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let Cancel stop a waiting login, and list a lone account's limits

Bash holds a trap until the foreground command finishes, so a login waiting
on the browser outlived Cancel. It now runs behind an interruptible wait.
With one account the usage record keeps its limits at the top level, which
omarchy agent account list now reads for the primary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Re-read identity even when signed out, and count Grok as set up

A home whose login is gone no longer keeps the identity the registry saved,
so it reads as signed out and can be added again. The agents panel leaves its
setup screen once any agent is signed in, not only once one has usage to
show, since Grok has no usage collector.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Save each account's current identity before collecting usage

A home signed in to someone else since it was added kept the old email in
the registry, which the usage records name accounts by. The usage update now
refreshes the registry from each home first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Offer Claude's paste field from the start, and keep providers with accounts

Claude's login prompts for a pasted code without a newline, so the panel's
line reader never saw it; the field is simply there for Claude sign-ins. A
provider with accounts stays listed even when the active one's limits are
unavailable, so its other accounts can still be switched to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never autoswitch to a signed-out account, and report Codex's missing sign-in

An account nobody is signed in to is left out of switching, however much
room its cached numbers show. Codex answers a home without a login with an
error, which now reads as Waiting for auth, so the panel offers Sign-in
required for Codex accounts too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Link shared files before they exist, and only call every account over when it is

A shared file the primary home didn't have yet was left unlinked, so an
added account made its own copy and the two diverged for good; it's linked
up front now, and written in the primary home by whichever account writes it
first. The all-accounts-over notice waits until every account's limits are
actually known.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Make the agent starter tiles compact and call the section Make something cool

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hold the agents hero line until its next fade

Every usage record that landed rebuilt the summary phrases, and the hero
indexed that live list, so opening the panel could swap the line several
times between fades. It now keeps what it shows until the timed swap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Start the default agent from the agents panel hero

A console button beside the add button runs the same launcher as the
right click: the default agent, or the picker when none is set. It hides
while adding a subscription, where the add button becomes the way back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Put the add button before the agent launcher in the hero corner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bring keyboard navigation back to the agents panel

The one-page redesign dropped left and right with the per-agent pages,
leaving the arrows only to scroll. They now walk everything that does
something, row by row: the hero's add and launch buttons, each
switchable account, and the starter tiles. Enter acts on the cursor,
the cursor scrolls into view, and hovering moves the same cursor so only
one thing is lit. Number keys still jump to an account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let adding an account take over the agents hero

While adding, the hero's line reads "Add an account" in place of the
rotating summary, and the X in its corner is the only way back, so the
add view drops its own title and Back link. Each agent to add is just its
mark and name; one that can't be added is dimmed and says why on hover.
The arrows walk that list too, and Enter picks one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show the agents to add as large marks, three across

Each agent is a large mark over its name with no box around it, in one
row the arrows move along. The chosen one turns accent and grows a touch.
The reason an agent can't be added is shortened to fit inside the panel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move between Autoswitch and Use on an account with the arrows

An account that isn't active is now two stops, Autoswitch then Use, so
left and right move between them and Enter acts on the one that's lit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Land on Use when moving up or down onto an account

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hide Use on an autoswitching account until you're on its line

With Autoswitch on, the line shows only Autoswitch. Use appears when the
line is hovered anywhere or the keyboard is on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Start adding an account with the first agent focused

Opening the add screen puts the keyboard cursor on the first agent, so
Enter picks it straight away. A focused agent lights up even before the
check says whether it can be added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the Sign in link under the account name field

Enter in the field already starts the sign-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Key the primary Claude account's limits cache by its subscription

The primary home always used claude-limits.json, so signing it in to
another subscription could carry the old one's numbers over when the
first probe failed, and autoswitch would act on them. Once the home says
who it's signed in as, its cache is keyed by that like every other
account's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave Qt Multimedia out of the base packages again

Quattro dropped it once the shell no longer needed it, and the app skill
already has an app that plays audio or video add it and list it in its
own depends. The compiler and the rest of Qt stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Render the agents panel's dynamic text as plain text

Sign-in status, help text, and provider names come from outside the
shell, so none of them should be read as markup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep an account's plan and trouble clear of its Use and Autoswitch links

The details beside an account's name grew to their full width, so a long
plan or warning could run under the links on the right. They now shorten
with an ellipsis instead, and Sign-in required keeps its whole width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let primary name the first account, as the manual says

`omarchy agent account rename primary Hey` was documented but failed,
since the primary account's id is main and lookups took exact ids only.
primary now reaches the account marked primary, whatever it's been
renamed to, and no new account can take primary as its id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Install everything an Omarchy app needs by default

The app skill builds with Qt Multimedia, SVG icons, and ffmpeg as well as
the compiler and the rest of Qt, so qt6-multimedia, qt6-svg, and ffmpeg
join the base packages and the migration. The shell still plays no video
through Qt Multimedia, which is what its test now checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Dim agent limits kept from an earlier check, with their age on hover

When a Claude probe fails, the last numbers carry on and looked just like
fresh ones. Those meters now dim, and their tooltip says how old they
are. The record carries limitsStale and limitsFetchedAt for this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Give the app skill templates for every file its build needs

The templates named src/backend.{h,cpp} and a test file without showing
them, so each app had to invent its own. They're now templated, with a
starter icon, a note that LICENSE and the icon must exist for package(),
and qt6-wayland in the PKGBUILD's depends. Scaffolded from the templates
alone, the app builds and its tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Start the agents panel cursor over when the agent list comes or goes

Right after the shell starts, the panel can briefly look like a first
setup and focus the first agent to add. When the records land the rows
change under the cursor, which then lit an account nobody had picked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop dead hover and limit checks from the agents panel

The tiles and hero buttons fell back on their own hover when there were
no keyboard rows, but the hero always has one, so hover only ever moves
the cursor. Stale meters only exist when there are limits, so neither
the panel nor the record needs to check for some.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Drop the a hotkey for adding an agent subscription

The + in the hero is the way in, by mouse or by arrowing to it. The
plugin README also catches up with the panel: the launcher, dimmed stale
limits, the new add screen, and the arrows walking a cursor rather than
scrolling.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* List a command family that sits under a flags-only command

`omarchy agent account` reached omarchy-agent, which takes only flags,
and failed on the word instead of listing the account commands. When the
command matched so far takes only flags and the next word names visible
commands, the router now lists them. `omarchy update aur` likewise lists
the update aur commands rather than running a full update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Refuse to remove an agent account a running session uses

Removing an account deleted its home at once, pulling the login out from
under any session started in it, though running sessions are never meant
to be touched. It now says to quit that session first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Prefer an account checked just now when switching near a limit

Autoswitch weighed numbers kept from an earlier check like fresh ones.
A parked account's sign-in lapses within hours, so a stale one stays a
candidate, but an account checked just now wins over it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Leave a user's own omarchy-app skill in place when linking ours

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reach Sign-in required with the keyboard in the agents panel

Both the link on a lapsed account and the one on a single-account agent
are now stops in the cursor's walk, and Enter signs in again. A picked
link scrolls into view.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Bring the manual's agent accounts paragraph up to date

Near-limit checks run every three minutes, not every minute, and
Autoswitch now appears on hovering an account's line and takes Use's
place while it's on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the agents panel cursor on things you can act on

The active account was a stop with nothing to do, so the cursor seemed to
vanish there. It now only stops on it to sign in again. The hero's
buttons also show the cursor plainly, with an accent border and a deeper
tint instead of a shade's difference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Never leave an added agent account's login outside the registry

Registering moved the login into its home before carrying settings over
and saving the registry, so a failure in either stranded a home holding
a sign-in that the account commands couldn't see. Settings are now
carried while the login is still pending, and a failed save moves the
home back there for the add command to clean up.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Put room before freshness when picking an account to switch to

Preferring accounts checked just now outright could pick a fresh one at
94% over a stale one at 10%. An account within 15 points of the
threshold now counts as near its limit however fresh, so accounts with
room come first and freshness only decides among them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check agent limits more often relative to the switch threshold

Faster checks started at a fixed 80%, so a threshold set lower could be
crossed and wait out the 15-minute interval. They now start 15 points
below the threshold, which is still 80% at the default 95%.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:36:22 +02:00
3cebdc3412 End a running idle cycle when both timeouts are set to 0
With both at 0 the monitor is disabled and handleIdleChanged returns early, so a cycle already running when shell.json changed never cancels: omarchy-system-wake never runs, and if the screensaver never opened a window nothing else ends it. Cancel it the way turning on stay-awake does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex Medium <noreply@openai.com>
2026-10-01 12:30:46 +02:00
3839d0b4e0 Keep a pending idle action on its deadline when the timings change
With 0 meaning off, setting the screensaver to 0 while the screensaver is up moves the first idle deadline to the lock's, which turns the pending lock's bound interval to 0 and locks at once, minutes early. Set each timer's interval when the cycle starts, so a change to shell.json takes effect from the next cycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex Medium <noreply@openai.com>
2026-10-01 11:45:45 +02:00
OmarchybotandClaude Opus 5.5 377b15be4b Don't fire a pending idle action whose timeout was set to 0
The timers' intervals are bound to the configured delays, so setting the lock or screensaver to 0 while the screensaver is up turns a pending timer's interval to 0 and it fires at once. Check the action is still enabled when its timer fires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:29:30 +02:00
OmarchybotandClaude Opus 5.5 84e88b4a8e Guard third-party manifests keeping __sourceDir
No suite failed with the strip restored, so nothing kept the 4.0.3 regression from coming back.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 04:49:25 +02:00
OmarchybotandClaude Opus 5.5 146fe5d368 Say only what is true in the __sourceDir comment
Bar widget instances are not handed sourceDir (ModuleSlot.injectProps sets only bar, moduleName and settings), and a plugin could reach its directory through Qt.resolvedUrl, so the comment's rationale was wrong on both counts. Two lines carry what the code cannot say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 04:49:25 +02:00
bjarneo 8b4eae66da Merge pull request #13771 from omacom/fix-aether-nvim-path
Change the aether nvim part to the omacom org
2026-09-29 19:44:16 +02:00
Bjarne Oeverli 199ac6fc28 Change the aether nvim part to the omacom org 2026-09-29 19:43:31 +02:00
acrogenesis c3cdcb1e82 Keep USB approvals portable and preserve approval prompts 2026-09-29 10:16:56 -06:00
David Heinemeier HanssonandClaude Opus 5.5 b421b1b479 Read Codex limits without waiting on account/read (#13733)
Codex 0.158's app-server can leave account/read unanswered, and asking it
first lost the limits whenever it did, leaving the agents panel showing
"Codex limits unavailable". The limits name the plan themselves, so they're
asked first and account/read is only a short fallback when they don't.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 12:20:11 +02:00
Junjie Hou 1095f73aa6 Fix Display panel display toggle using rejected hyprctl keyword
Under the Lua config system, 'hyprctl keyword monitor ...' is rejected
("keyword can't work with non-legacy parsers") yet still exits 0, so the
panel's enable/disable row silently did nothing. Use the hl.monitor eval
API instead, as omarchy-hyprland-monitor-scaling already does.

The re-enable must pass disabled = false explicitly: hl.monitor rules
merge, so a disabled = true already in config (e.g. the
internal-monitor-disable toggle file) otherwise survives and the
re-enable no-ops.
2026-09-29 14:30:22 +08:00
David Heinemeier HanssonandClaude Opus 5.5 e332dc975d Get the bash prompt up ~30ms sooner (#13591)
fzf's completion.bash rewraps the completions of ~76 commands on every
interactive shell start, costing ~25ms once bash-completion is loaded. Keep
only the key bindings (Ctrl-R, Ctrl-T, Alt-C); **<Tab> fuzzy completion goes.

`starship init bash` only prints a line that runs starship again with
--print-full-init, so call that directly and start the binary once.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 21:07:30 +02:00
acrogenesis 60d6b255da Require approval for new Thunderbolt accessories 2026-09-28 09:16:57 -06:00
a383e3efcb Say what nodeFor resolves a row to after PipeWire recreates its node
Co-Authored-By: Codex Medium <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:48:23 +02:00
92e1a27618 Make the audio row test fail when a row is the node itself
The fake nodes carried only id and name, so a projection that handed back the node passed every assertion. Giving one an object-valued audio property, as a live PwNode has, makes the deep comparison catch it.

Co-Authored-By: Codex Medium <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:48:23 +02:00
David Heinemeier HanssonandClaude Opus 5.5 b18ab4952b Add a no-animations mode, on by default in VMs (#13550)
A machine without a GPU, like most VMs, renders through llvmpipe on the
CPU, where every animated frame and every translucent window costs. In a
VM, opening and closing a terminal took ~5s of CPU; a panel ~3.4s.

omarchy toggle animations (also under Toggle > Animations) places a Hyprland
flag that turns off animations, blur and shadows and makes windows opaque.
The shell follows Hyprland's animations:enabled, rereading it on every
config reload: its one-shot animations run for Style.duration(ms), which
is then 0, and its spinners, pulses and title marquee hold still. A new
install in a VM starts with the flag in place.

Measured in the ISO test VM (llvmpipe), CPU per interaction:
terminal open+close 5000ms -> 481ms, workspace switch 1670ms -> 409ms,
audio panel 3446ms -> 796ms, volume OSD 2324ms -> 584ms, menu 2028ms ->
1241ms.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 15:46:05 +02:00
David Heinemeier HanssonandClaude Opus 5.5 97a9fce54f Hand browser and web app launches to the running browser directly (#13530)
* Hand browser and web app launches to the running browser directly

With a Chromium-based browser already running, each launch started a second
browser process only to pass its command line over the profile's singleton
socket and exit, ~165ms before the running browser even heard of it.
omarchy-cmd-browser-handoff sends that message itself with socat and waits
for the ACK, so a web app opens in ~210ms instead of ~430ms.

It hands off only what it can do exactly as the browser would: the packaged
browser binary rather than a wrapper, a flags file holding nothing beyond the
process flags Omarchy ships, no other data directory, and a socket we own.
Anything else, or no answer, launches the browser as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hand Brave Origin launches to the running browser too

/usr/bin/brave-origin is the package's own launcher, reading
brave-origin-flags.conf, with the profile in BraveSoftware/Brave-Origin.
A web app now opens in ~158ms instead of ~345ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:04:31 +02:00
David Heinemeier HanssonandClaude Opus 5.5 de63134b88 Collapse identical notifications from the same sender into one toast (#13522)
* Collapse identical notifications from the same sender into one toast

A web app open in several tabs fires one notification per tab for a single
reminder, stacking identical toasts. A new notification matching an on-screen
toast's app, summary, and body now replaces it like a replaces_id update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Only collapse live duplicates that share image and click target

Screen recording toasts share text but preview and open different files, and
replayed history rows share images with entries still in history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Compare a notification's current content when collapsing duplicates

A replaces_id update can land while the insert is deferred, so the initial
snapshot may no longer describe what the notification says.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 12:04:15 +02:00
David Heinemeier HanssonandClaude Opus 5.5 f814639556 Hand the terminal launcher the focused window's pid from Lua (#13521)
To open a new terminal in the focused terminal's directory, the launcher
asked Hyprland for the active window with hyprctl, ~10ms on every
SUPER+RETURN. The binding now reads the pid from hl.get_active_window() and
passes it as --pid. Without one, from the menu or a shell, the launcher asks
Hyprland as before.

The bind is now a Lua function, which Hyprland reports only as __lua, so
o.bind_commands records the command it stands for and the keybindings menu
still launches a terminal when the entry is picked.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:48:28 +02:00
David Heinemeier HanssonandClaude Opus 5.5 fa729f139c Read the default browser from mimeapps.list when launching (#13520)
* Read the default browser from mimeapps.list when launching

omarchy-launch-browser and omarchy-launch-webapp asked xdg-settings for the
default browser on every launch, ~72ms through several POSIX shell scripts.
The new omarchy-cmd-default-browser reads the http handler from the
config-dir mimeapps.list files the way xdg-mime does, desktop-specific list
first, taking the first listed entry whose command is installed, in ~10ms.
With nothing set there it falls back to xdg-settings as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Let the first copy of a browser entry decide whether it is installed

A user-local entry whose browser is gone could be rescued by a system copy
of the same ID, which the launchers never read. xdg-mime judges the first
copy found, as the launchers use it, so do the same.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 11:43:34 +02:00
Erik Melton 8273b6c2d7 Merge pull request #13049 from AFOliveira/fix/fingerprint-template-cleanup-20260923
Remove saved fingerprint templates for the invoking user. Reported-by: Sean "seanmhuber" Huber
2026-09-28 10:43:13 +02:00
Erik Melton 091d65fc40 Merge pull request #13048 from AFOliveira/fix/sshd-firewall-cleanup-20260923
Remove standard SSH allow rules when disabling SSH. Reported-by Sean "seanmhuber" Huber
2026-09-28 10:39:10 +02:00
Erik Melton b2ffea5742 Merge pull request #9682 from AFOliveira/codex/replace-yt6801-dkms
Replace YT6801 DKMS driver with upstream support
2026-09-28 10:27:11 +02:00
David Heinemeier HanssonandClaude Opus 5.5 4ba4a53209 Nudge running Hunk sessions to pick up new theme colors (#13518)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 10:25:13 +02:00
324f0ba5e6 Add SSH Agent (gcr-ssh-agent) as an optional service install (#9399)
* Add SSH Agent as an optional service install

Enables gcr-ssh-agent (already shipped via the gnome-keyring dependency)
so passphrase-protected SSH keys work from any context: the agent prompts
graphically on first use, with opt-in passphrase storage in the keyring.
Adds install/remove commands and Install > Service / Remove > Service
menu entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BReZWvvLsDRUaqgjiRzvZ5

* Clear the agent's SSH_AUTH_SOCK when removing the SSH agent service

The socket's ExecStartPost exports SSH_AUTH_SOCK into the user manager with set-environment, which disabling the socket does not undo, so apps launched after removal still pointed at a stopped agent. The environment.d file also has to be deleted before disable reloads the manager, or the generator re-exports it. Only an SSH_AUTH_SOCK pointing at gcr is cleared, so another agent's stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Move SSH Agent setup to Setup > Security

Turning on an SSH agent is security configuration of the machine rather than installing software, so it sits with SSHD and the other Setup > Security entries. It hides once enabled, as Sudoless Docker does, and Remove > Service > SSH Agent turns it back off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
2026-09-28 08:46:30 +02:00
Spencer Bull 9ace84e6c3 Keep OpenClaw's gateway probes off the wizard's terminal
The wizard writes its config while prompts remain, so the watcher starts probing a gateway that is not up yet; each probe hangs until its 10 second timeout, and OpenClaw's CLI, killed by it, takes the terminal it inherited on stdin out of raw mode on the way out. The wizard does not set raw mode again, so from then on its prompts echo keys instead of reading them: the first letter typed at the channel search landed and the next one was printed below the prompt. The probes now read from /dev/null. This is the change reverted earlier on a test that only ever saw probes that exited on their own, never one the timeout killed.
2026-09-28 01:40:39 -05:00
Spencer BullandCodex XHigh de8645490b Leave a moved OpenClaw service stopped or disabled if it was
Installing a service enables and starts it, so moving an old-package gateway or node host the user had stopped or disabled turned it back on. What was running and enabled is read before the move and put back after it.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-28 00:59:44 -05:00
Spencer BullandCodex XHigh 9f0d4641bd Know the runtime's OpenClaw by its own script
A service counted as the runtime's, or as a half-moved old one on /usr/bin/node, whenever it ran anything under ~/.openclaw, so a script of the user's in that directory could be stopped and rewritten, and seeding could go ahead beside it. The runtime is now OpenClaw's own script as upstream's installer lays it out, under ~/.openclaw/tools/*/lib/node_modules/openclaw, and both checks use that one definition.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-28 00:52:56 -05:00
Spencer BullandCodex XHigh 72da1ec851 Move only services still on the old package's script or Node
Judging a moved service by whether its program sat under ~/.openclaw missed a retry after a half-finished move, whose unit already ran the runtime's script on /usr/bin/node and so was never selected again; misread a symlinked home against the resolved Node it pins; and would have moved a gateway the user runs on Bun. A service now needs moving while it runs the old package's script, or the runtime's script on /usr/bin/node, the Node the old package pulled in; any other runtime is left alone.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-28 00:40:59 -05:00
Spencer Bull 8942b0cd4c Keep a moved OpenClaw gateway on the runtime's own Node
Since 2026.9.6 upstream's installer keeps the Node a gateway service already ran when it rewrites the unit, so moving a gateway the old package installed left it running the runtime's code on /usr/bin/node, from the nodejs package the old openclaw package pulled in and the seed no longer depends on. A moved service now counts as moved only once its program is under ~/.openclaw, and the reinstall pins the runtime's Node with --runtime-path, which gives the same unit a fresh install gets.
2026-09-28 00:32:20 -05:00
David Heinemeier HanssonandClaude Opus 5.5 3faafba234 Install Hype, the Markdown presentation app, by default (#13455)
Hype joins Omacut, Monologue, Omacalc and Omawrite as an Omacom app in
the base install and the preinstall set, so Remove and Install
Preinstalls cover it too. A migration installs it on existing machines,
unless their owner has removed the preinstalls, and the GUIs chapter of
the manual introduces it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:48:22 +02:00
David Heinemeier HanssonandClaude Opus 5.5 349ecc09a2 Flip Elsewhen between Fahrenheit and Celsius with Alt+T (#13450)
* Flip Elsewhen between Fahrenheit and Celsius with Shift+T

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Flip Elsewhen's temperature units with Alt+T instead of Shift+T

PanelKeyCatcher's textKey now carries the held modifiers, which is how the
panel tells Alt+T from T. Existing handlers take only the text and are
unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:19:31 +02:00
David Heinemeier HanssonandClaude Opus 5.5 ed5837a05b Give the picked Elsewhen city one bright highlight (#13452)
A row lit itself by lifting its own time-of-day fill, so a picked night row
could read darker than the daytime rows around it. The picked city now takes
midday's lifted fill, the brightest, whatever its time of day; hover still
lifts each row from its own.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:19:11 +02:00
David Heinemeier HanssonandClaude Opus 5.5 944fa24fd1 Add Monologue as a default app (#13449)
Monologue is Omarchy's own webcam recorder: pick a camera and microphone
once, press Space to record, and stop straight into a built-in editor
to split, trim and remove clips before saving an MP4.

Ship it in the base packages alongside Omacut, include it in the
preinstall restore/remove lists, and describe it in the manual. A
migration installs it on existing machines unless the user removed the
preinstalls.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:14:28 +02:00
David Heinemeier HanssonandClaude Opus 5.5 25f9795221 Flip Elsewhen's time format with t and line up its weather (#13448)
* Flip Elsewhen between 24-hour and AM/PM time with t

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Line up Elsewhen's weather in one column

The temperature and weather icon sat against each row's time, which is
narrower for "4:06" than "10:06", and shared the name's slack. The time
column now takes the widest time's width in every row, and a spacer holds
the weather against it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:12:04 +02:00
David Heinemeier HanssonandClaude Opus 5.5 773e19e145 Drive Elsewhen from the keyboard and summon it with Super+Ctrl+Alt+E (#13445)
* Navigate Elsewhen from the keyboard and summon it with Super+Ctrl+Alt+E

Up and down walk home and the cities, lighting the picked row and turning
the globe to it. Left and right move the clocks an hour, held until Escape
or close. Escape now clears a shifted time before leaving the globe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Reach Add a city from the keyboard and shift time past a day

Down past the last city selects Add a city, where Return or Space opens the
search. The arrow keys no longer stop at twelve hours, and shifts past a day
read in days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Delete the picked Elsewhen city from the keyboard

Delete (or x) removes the picked city and moves the cursor to the one that
takes its place. The picked row shows its × so the target is plain. Delete
now reaches every panel's deleteRequested, as it already does in the
clipboard and the menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Center bar glyphs on the open-panel underline

The 16px icon canvas in the 27px slot was snapped from 5.5 to 6, and the
glyph's fractional centering correction was snapped too, so glyphs sat up to
0.9px right of the underline, which centers on the slot. Both are now placed
exactly, and the geometry test measures against the slot to within 0.05px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 22:05:41 +02:00
David Heinemeier HanssonandClaude Opus 5.5 82979b9a18 Remove the IPC socket of each shell a test starts (#13443)
* Answer the Elsewhen panel over the shell's IPC socket

The Elsewhen panel arrived with a plain IpcHandler, so omarchy-shell
reached it only through the slower qs ipc fallback, and the test that
every first-party handler registers as ShellIpc failed on quattro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove the IPC socket of each shell a test starts

The runtime smoke and screenshot sanity tests start the real shell from a
temporary copy and kill it, which leaves its IPC socket behind in
XDG_RUNTIME_DIR. A session that runs the suite a few times collected
dozens of them.

base-test.sh gains shell_ipc_socket, which derives a shell's socket as
omarchy-shell does, and both tests remove theirs in cleanup. The socket
test derives its fixture's socket through it too, so the helper cannot
drift from omarchy-shell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:57:46 +02:00
David Heinemeier HanssonandClaude Opus 5.5 0066ea216b Keep panel loaders across plugin changes so the OSD loads once (#13439)
Every plugin change handed the panel Instantiator a fresh array, which
rebuilds every panel. The panels were rebuilt four times in the first
700ms of startup, each rebuild starting a new asynchronous load of every
keepLoaded panel while the last was still in flight. Under load two OSDs
could run at once, each registering the osd IPC handler: the runtime
smoke test's intermittent "another handler is registered for target
osd".

The entries now live in a ListModel synced in place. A panel whose
plugin still loads the same way keeps its loader, so startup adds each
panel once, and a full plugin reload still clears the model to rebuild
from fresh code.

Under a six-core busy load the smoke test hit the duplicate in 1 of 5
runs on quattro and in none of 10 with this change.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:29:06 +02:00
5929a9b80c Set Elsewhen's here by tapping a city on the globe (#13438)
A city on this machine's zone becomes homeCity, the zone's own city clears
it, and a city on another zone leaves it alone. Ported from omacom/elsewhen#5.

Co-authored-by: Shawn Yeager <shawn@shawnyeager.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:26:00 +02:00
David Heinemeier HanssonandClaude Opus 5.5 e7218e36f2 Move Elsewhen into Omarchy as omarchy.elsewhen (#13429)
* Move Elsewhen into Omarchy as omarchy.elsewhen

The world clock ships in the shell tree instead of its own package. A
migration renames existing bar entries with their settings and removes
the retired package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Remove Elsewhen's shelved Earth row, overlap band and sky tint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Tidy Elsewhen's models, data helper and docs, and drop currency

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Restructure Elsewhen's panel and globe on the shared shell components

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show Elsewhen's bar globe upright

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Use the shared city search for Elsewhen's globe jump bar

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep legacy Elsewhen entries single and retry offline geocodes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Fetch Elsewhen's weather with curl instead of a Python helper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:15:00 +02:00
David Heinemeier HanssonandClaude Opus 5.5 5c7cef1e78 Keep Hyprland from sliding the OSD in from the corner (#13436)
The OSD, the reminder flow and the Wi-Fi QR code now keep a surface that
parks as a 1x1 in the corner and grows when shown. They were missing from
the layer rule that turns Hyprland's layer animation off for the shell's
overlays, so Hyprland animated that growth: the OSD flew in from the left.

Add them to the rule, and test that every OverlayWindow's namespace is in
it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:07:27 +02:00
David Heinemeier HanssonandClaude Opus 5.5 c231097df7 Answer omarchy-shell calls over the shell's own socket (#13435)
* Answer omarchy-shell calls over the shell's own socket

Every omarchy-shell call started a qs ipc client, ~45ms of startup for one
IPC call: a theme switch makes two, and every script-driven OSD, toggle
refresh and lock query paid it too.

The shell now serves a socket in XDG_RUNTIME_DIR, named from its config
path and Wayland display as qs ipc selects its instance, and omarchy-shell
tries it first through socat, which starts in ~5ms. First-party handlers
register as ShellIpc, an IpcHandler that qs ipc still reaches, and the
socket calls only the functions a handler declares with their exact
argument count, allowed by name so QObject methods such as destroy() stay
out of reach.

When the shell ran nothing it answers SKIP, and omarchy-shell asks qs ipc
for its exact answer, so errors, third-party plugins and an unreachable
socket behave as before. A call that may have run is never retried: a
timeout or a connection closed without an answer reports the shell as not
responding.

omarchy-shell shell ping takes ~13-18ms instead of ~61ms, and omarchy-osd
reaches the screen in ~36ms instead of ~77ms. Output and exit status match
the qs ipc path across 26 calls, errors and quiet mode included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Only accept whole socket replies and retry only unmade connections

A reply cut off after its OK prefix passed for the whole answer, and an
empty reply with socat failing was retried through qs ipc although the
request might already have been delivered.

An answer now counts only once its record separator arrived. socat's own
errors join the reply, so only its connect error, a socket nothing
listens on, falls back to qs ipc beside an explicit SKIP; anything else
is reported as not responding rather than retried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:46:00 +02:00
David Heinemeier HanssonandClaude Opus 5.5 616feda103 Add opt-in theme sync to herdr machines (#13430)
* Add a theme-set sample hook that mirrors themes to herdr machines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Mirror theme changes to herdr machines by default

Replaces the sample hook with omarchy-theme-sync, which omarchy-theme-set
starts detached after every theme change, and a theme-sync-off toggle that
stops a machine from both sending and receiving.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Rename omarchy-theme-sync to omarchy-theme-set-herdr-machines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Label the toggle Herdr Theme Sync and extract its menu guard

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Answer the Herdr Theme Sync menu guard from omarchy-theme-set-herdr-machines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Sync herdr machines one at a time and drop the dry run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check the theme sync toggle under the lock and stop special-casing this machine

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Make herdr theme sync opt-in

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:31:02 +02:00
David Heinemeier HanssonandClaude Opus 5.5 387fcf599a Keep the lock wallpaper decoded so waking shows it with the password field (#13431)
The lock started decoding its wallpaper only once locked, with the cache
off, and first at the view's unsized native resolution. A machine
suspending right after locking froze that decode partway, so waking
showed the password field on a bare background and the wallpaper
popped in after it. On this machine the wallpaper took ~208ms to become
ready, and the suspend followed the lock by 66ms.

The lock service now keeps each screen's lock wallpaper decoded in the
image cache, as the lock view requests it: same URL, the screen's
logical size, PreserveAspectCrop. The view waits for its size and reads
from the cache, so the wallpaper is ready within ~3ms of the lock
starting. The version in the cached URL follows the file's mtime and
size, so a wallpaper overwritten in place still reloads.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:08 +02:00
David Heinemeier HanssonandClaude Opus 5.5 d3fb0284a8 Step the internal backlight in the shell instead of a script per keypress (#13427)
Each brightness key ran omarchy-brightness-display: resolving the focused
monitor and backlight device, checking for an Apple display, reading,
writing and reading back through brightnessctl, then omarchy-osd over a
qs IPC client. That is 64-133ms from keypress to OSD depending on load,
on keys that repeat while held.

The brightness up and down keys now dispatch global shortcuts. On an
internal panel the shell reads the level from sysfs, steps it by the
script's rules, writes it with one brightnessctl, and shows the OSD from
the level read back. A press overlapping one still being applied is
dropped, as the script's flock drops it. External and Apple displays
fall back to the script, which drives them over DDC or their own helper.
The absolute and precise brightness keys still run the script.

Across 24 steps from eight starting levels the shell and the script land
on identical levels, and keypress to OSD drops to ~31ms.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:34:36 +02:00
David Heinemeier HanssonandClaude Opus 5.5 25ead28f90 Trim launcher overhead for terminals and web apps (#13423)
omarchy-launch-webapp asked xdg-settings for the default browser with
BROWSER still set to omarchy-launch-browser, which sends it down a
slower path to the same answer: ~105ms instead of ~30ms on every web app
launch. Unset it, as omarchy-launch-browser already does.

omarchy-cmd-terminal-cwd found the focused terminal's shell with pgrep,
which scans all of /proc on every new terminal. Read the terminal's
child lists from /proc instead, keeping the newest child as before.

Web app wrapper overhead drops from ~111ms to ~34ms, and a new terminal
appears in ~77ms instead of ~90ms.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:03:54 +02:00
David Heinemeier HanssonandClaude Opus 5.5 804749970a Handle volume, media and notification keys in the shell (#13424)
* Step the volume in the shell instead of a script per keypress

Each volume key ran omarchy-audio-output-volume: several pactl calls in
bash, then omarchy-osd delivering the OSD over a qs IPC client, ~165ms
from keypress to OSD, on keys that repeat while held.

The volume up, down and mute keys now dispatch global shortcuts that the
media service handles over PipeWire, stepping, clamping, unmuting and
debouncing by the script's rules and showing the same OSD. It acts only
when the default sink is an ALSA sink, which is its own physical sink.
Any other default, a DSP chain or EasyEffects above all, falls back to
the script, which resolves the physical sink from the live routing on
every press. The precise +1/-1 keys still run the script.

Keypress to OSD drops from ~168ms to ~20ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Run media and notification keys in the shell without an IPC client

The media keys and the notification dismiss, invoke and history keys ran
omarchy-shell, starting a qs client for one argument-free IPC call.

A new ipc shortcut kind names such a call as target.method. The shell
hands it to the service that owns the target, which runs its own
IpcHandler function, so the key behaves exactly as the omarchy-shell
call did. A call missing from the list binds through omarchy-shell.

Dismissing a notification with SUPER+comma clears the popup in ~11ms
instead of ~40ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 19:02:59 +02:00
bf44355a98 Fix: Restore former window fullscreen state after screensaver exit (#3284)
* fix(screensaver): open in own special workspace instead of active

- The screensaver window rule sets fullscreen, and a workspace holds
  only one fullscreen window
- Launching it on the active workspace drops the fullscreen window
  already there, and leaves it windowed after the screensaver quits

* Keep a showing special workspace open through the screensaver

A monitor shows one special workspace at a time, so opening the screensaver on its own replaced a visible scratchpad, and it stayed hidden after the screensaver closed. Open it on the special workspace that is already showing instead; it covers that just as well, and the workspace is still there when it goes.

The test's stubbed event stream follows the one in #10870.

Co-Authored-By: Willem van Ede <37050539+WillemCR@users.noreply.github.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Hand focus back after the screensaver closes

Hyprland focuses a monitor when its special workspace empties, so on more than one monitor keyboard focus ended up wherever the last screensaver happened to close, rather than where it was. A detached reader on the launcher's event stream waits for the last one to go and focuses the original monitor again; the launcher itself still exits straight away, as the idle service expects.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep a remapped screensaver off the regular workspaces

The terminal occasionally maps its window again as it closes, after its special workspace is gone. The launcher's workspace only applies to the first map, so the window landed on the focused monitor's regular workspace, where its fullscreen rule took fullscreen from the window there. On Hyprland 0.56.2 with two monitors this happened in 4 of 45 cycles, each time losing the user's fullscreen. A class rule now sends any later map to a hidden special workspace; the launcher's exec rule still wins on the first map.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check for screensavers before waiting on their close

The focus watcher only looked for remaining screensavers after reading a closewindow, so one that never mapped, or closed while the launcher was still waiting on another monitor, left it blocked until some unrelated window closed, and then it pulled focus back to the old monitor. It now checks before each wait, and matches the exact class, passed through the environment so the screensaver's pgrep and pkill never see it in argv.

Co-Authored-By: Codex Medium <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Willem van Ede <37050539+WillemCR@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Codex Medium <noreply@openai.com>
2026-09-27 18:05:20 +02:00
David Heinemeier HanssonandClaude Opus 5.5 48de7823da Keep overlay surfaces mapped so they open sharp (#13419)
The menu, emoji picker, clipboard, OSD, reminder flow and Wi-Fi QR code
mapped a fresh surface on every open. Qt drew its first frames before
Hyprland sent the surface's fractional scale: the pixel ratio stepped
2, 1, then 1.6, so on a 1.6 display each overlay showed blurry for
~350ms before going sharp.

A new OverlayWindow keeps the surface. Hidden, it parks as a 1x1,
input-less layer below windows, off the overlay layer so it never
blocks direct scanout. Showing only resizes and raises it, so the scale
is already settled. Content stays hidden until the surface has grown,
so no 1x1 frame is stretched across the screen, and the window follows
the focused monitor each time it is shown. The image picker's own
fullscreen parking moves onto it too.

Each parked overlay keeps a Qt window alive: at rest the shell holds
~40 MiB more RSS and ~20 MiB more GPU memory.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:03:01 +02:00
David Heinemeier HanssonandClaude Opus 5.5 6762769bbf Open shell menus and panels through Hyprland global shortcuts (#13418)
Every binding that opened a menu or panel ran omarchy-menu or
omarchy-shell, which starts a qs client just to deliver one IPC call:
~60ms before the shell heard about the keypress.

The shell now registers a Hyprland global shortcut for each menu route
and panel in default/omarchy/shortcuts, and o.bind turns { menu = ... }
and { panel = ... } into hl.dsp.global for those, so a keypress spawns
nothing. A route or panel missing from the list binds through the
command as before. The default menu and panel bindings use the new form.

SUPER+SPACE opens the menu in ~31ms instead of ~94ms, measured from a
simulated keypress until the menu layer maps.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 18:02:01 +02:00
Chemineer bedc2a3979 Keep shutdown worker on the same script path 2026-09-27 09:25:39 -05:00
fd961e5300 Speed up the theme switch's browser refresh (#13038)
* Speed up theme switch by backgrounding preload and parallelizing browser refresh

`omarchy theme set` was blocking on `omarchy-theme-switcher --preload` (~1.1s)
and serially refreshing every Chromium-family browser (~1.0-1.5s), making a
picked theme feel slow even though the shell recolored instantly.

- Background `omarchy-theme-switcher --preload` like the background cache
- In `omarchy-theme-set-browser`, skip the privileged policy write and browser
  refreshes when every existing `color.json` already has the requested color
- Refresh only running browsers, and run those refreshes in parallel

Fixes #12627.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Harden the unchanged-color shortcut in omarchy-theme-set-browser

Review feedback identified that the original shortcut could vacuously skip
when no managed policy directories existed, and that it accepted a color.json
whose ownership or contents the privileged writer would have rewritten.

- Only skip when at least one managed directory exists and every existing
  color.json is a regular, root-owned 0644 file containing the canonical JSON
- Add OMARCHY_BROWSER_POLICY_DIRS as a test-only override so the setter's
  unchanged-color check does not depend on the host's real /etc policy files
- Update browser-policy-sudoers-test.sh to use that override, and make
  theme-set-browser-test.sh behavioral rather than grep-only

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Find running browsers with one process scan during the policy write

Each browser check ran its own pgrep, and every pgrep rescans /proc,
~40ms apiece. Read the process table once with ps while the policy
writer runs, keeping its stdin so a terminal sudo prompt stays in the
terminal, then refresh the running browsers in parallel.

Chrome's fallback to plain google-chrome never ran: the refresh helper
returned success for a missing browser, so the || branch was dead. Pick
whichever Chrome binary exists up front instead.

The unchanged-color shortcut keeps its root:root 0644 check without the
fallback for hosts lacking stat -c, which Omarchy never runs on. Its
tests stub stat, ps and command discovery, so they neither depend on
nor touch the host's browsers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 16:24:04 +02:00
f3dbc464fc Decode theme backgrounds earlier, and at screen size (#13408)
* Decode the next theme background while the theme stages

The wipe waited 125-290ms after the transition arrived, decoding the new
wallpaper. Most stock wallpapers are WebP, which Qt decodes at full size
and scales afterwards, so a screen-sized sourceSize does not shorten it.

Start the decode earlier instead. omarchy-theme-set chooses the next
background and snapshots it before rendering templates, then sends a new
background prepare call in the background. The shell loads it into the
hidden incoming frame, so the transition finds it decoded. A prepare that
arrives after its transition is ignored, and one no transition claims is
dropped after five seconds.

The wipe now starts ~255ms after omarchy-theme-set begins instead of
~345-490ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Decode the wallpaper at screen size instead of shipped size

Wallpapers decoded at the resolution they were shipped at: a 5120x2880
stock wallpaper took 5120x2880 of RGBA on a 1920x1200 panel, and a
transition held up to three such frames. Bind sourceSize on the
displayed wallpaper and both transition frames to the screen's physical
size. PreserveAspectCrop treats it as the area to cover, so the image
still fills the screen.

Qt scales a decode up as well as down to cover sourceSize, so the native
size is read from the file header first with magick identify, and a
wallpaper smaller than the screen decodes at its own size. The images
wait for both sizes, so nothing decodes at native size first.

Ported from #8324 onto BackgroundMedia and the prepared incoming frame.
Measured with a 5120x2880 wallpaper, the shell's GPU memory at rest
drops from 264 MiB to ~148 MiB. The size probe delays the reveal by
~30ms, which the earlier prepare still more than covers.

Co-authored-by: Ryan Yogan <ryanyogan@gmail.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Ryan Yogan <ryanyogan@gmail.com>
2026-09-27 16:11:36 +02:00
Spencer Bull b71b905548 Revert "Keep OpenClaw's gateway probes off the wizard's terminal"
The probes do not take the wizard's terminal out of raw mode: a raw pty stays raw across `openclaw dashboard --json`, and the channel search takes keys normally in foot and Ghostty with the probes running, with or without the change. The break seen at the channel prompt has another cause.
2026-09-27 08:57:33 -05:00
Spencer Bull 64e102aaf2 Keep OpenClaw's gateway probes off the wizard's terminal
OpenClaw's CLI takes a terminal on stdin out of raw mode as it exits, and the onboarding watcher's `openclaw dashboard --json` probes inherited the wizard's terminal. Current releases write the config partway through the wizard, so the probes started while prompts remained, and every two seconds the terminal fell back to line mode: typing at the channel prompt echoed instead of selecting. The probes now read from /dev/null.
2026-09-27 08:34:08 -05:00
David Heinemeier HanssonandClaude Opus 5.5 7e8d35efb3 Render theme templates in one awk pass (#13406)
A theme switch waits on omarchy-theme-set-templates before the shell
transition starts, and it spent ~670ms spawning processes: a subshell
per color for its rgb form, a grep per template per function family, an
awk per mix, and a sed per template matching every line against
hundreds of patterns.

Helpers now return through REPLY, one grep finds the function tokens
across all templates, and one awk computes the mixes with the same
floating point rounding and renders every template. Output is byte for
byte identical across all shipped themes. Generation takes ~65ms, and
the transition starts ~130ms after omarchy-theme-set begins instead of
~740ms.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:29:34 +02:00
Spencer Bull 731fc8d051 Merge pull request #13350 from omacom/fix/sudo-passwordless-migration-path
Run the passwordless sudo helper from $OMARCHY_PATH in its migration
2026-09-27 08:18:53 -05:00
David Heinemeier HanssonandClaude Opus 5.5 24f1243120 Make the theme picker open instantly (#13403)
* Run menu summon actions in-process

A menu action that only summons another shell plugin spawned bash and a
qs ipc client to ask this same shell to do it, about 60ms of the path.
Call shell.summon directly instead, and fall back to bash when the call
is refused or the action is anything more than a bare summon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Open the theme picker from rows held in the shell

Opening the theme picker ran omarchy-theme-switcher to rebuild its index
and then made a second IPC call, about 170ms before the picker mapped.
The picker now holds the theme rows itself, opens from them at once, and
refreshes them behind the open via omarchy-theme-switcher --print-rows.
It applies the chosen theme with omarchy-theme-set directly, so
omarchy-theme-set no longer preloads the picker.

From the keybinding to the overlay mapped drops from ~245ms to ~83ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the image picker surface mapped between opens

Each open mapped a fresh surface, which rendered its first frames before
Hyprland sent its fractional scale: the pixel ratio stepped 2, 1, then
1.6, so the picker flashed blurry for ~130ms and re-uploaded every
thumbnail texture. Keep the surface and park it transparent and
input-less on the bottom layer while closed, since anything on the
overlay layer blocks direct scanout for fullscreen apps. It follows the
focused monitor on each open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 15:10:49 +02:00
David Heinemeier HanssonandClaude Opus 5.5 e558224acf Loosen existing offline installs' exact Node pin (#13400)
5db4a401 fixed the installer, but machines already installed offline
still pin Node to the ISO's exact version, so mise up never updates it.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 14:01:16 +02:00
Stefano VernaandClaude Fable 5.1 91fd24c2b1 Resolve the audio output sink through EasyEffects 8
omarchy-audio-output-sink follows a DSP sink to the physical sink through the
DSP's playback stream. EasyEffects 8 has no such stream: its output node links
its ports straight to the sink's playback ports, so the search found nothing
and the volume keys, the bar's audio panel and the output switcher all acted on
easyeffects_sink itself. That sink's volume is inert -- its monitor, which
EasyEffects reads, does not follow it -- so the OSD moved while the speakers
did not.

Follow the port links to the alsa_output sink instead, and when EasyEffects is
idle and has dropped them, use the output device its settings name, if that
sink exists. Filter-chain tunings keep resolving through their stream as before.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-27 12:57:13 +02:00
David Heinemeier Hansson c5b4db77d6 Move AUR more out of sight
Now that every package we offer an installer for no longer needs it
2026-09-26 23:11:07 +02:00
David Heinemeier HanssonandClaude Opus 5.5 8cd727dfdf Stop the update sleep inhibitor without a signal error (#13361)
Stopping stay-awake sends TERM to the held process, which was an exec'd
sleep. systemd-inhibit reports a child killed by a signal as an error, so
every update ended with "'/usr/bin/setpriv' terminated by signal TERM."

The held process now stays a shell that traps TERM, kills its sleep, and
exits cleanly.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 23:07:58 +02:00
Spencer BullandCodex XHigh 92ef7e9407 Reach the migration's helper through $OMARCHY_PATH rather than by name
By name, the sudo call resolved through secure_path, which puts /usr/local/bin ahead of /usr/bin and falls back to the caller's PATH where no secure_path is set, so an install could run a different helper than the pinned path did. $OMARCHY_PATH/bin is the package's symlink into /usr/bin on an install and the checkout under a dev link, so neither PATH nor secure_path takes part.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-26 14:49:34 -05:00
Spencer BullandCodex XHigh 2ffae36a18 Resolve the passwordless sudo helper by name in its migration
Pinning /usr/bin ran the packaged helper even where the migration came from somewhere else. Under a dev link the migration is read from the checkout while /usr/bin still holds the last installed package, and a package predating __migrate prints its usage and fails the update. By name, the unprivileged call goes through PATH and the sudo call through secure_path, which is how other migrations reach their helpers and which lands on /usr/bin on an install.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-26 14:43:00 -05:00
David Heinemeier HanssonandClaude Opus 5.5 e1614f2bdb Ask for the sudo password once per omarchy update (#13323)
* Ask for the sudo password once per omarchy update

Every sudo call in omarchy update prompted, because the no-update wrapper
covered the whole run on top of per-phase revokes, and stay-awake revoked
the timestamp on its own entry and exit. A single update could ask four
times before the snapshot finished (#13319).

Authorize once, right after confirmation, starting from a revoked
timestamp so the prompt always belongs to this update. A background
keepalive refreshes it until the update is done. Prune, snapshot,
stay-awake, keyring, system packages, migrations, orphan removal, service
restarts, the post-update hook, and mise all share that authorization.

AUR builds run third-party PKGBUILD code, so they move to the end and run
cold: the keepalive stops, the timestamp is revoked, and yay and any bare
sudo use the no-update wrapper. The timestamp is revoked again after AUR
and on every exit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep the single authorization for passwordless sudo and ttyless inhibition

Authorize by running a command instead of sudo -v. Under the default
verifypw=all, -v prompts even when passwordless sudo is enabled, which
would have added a prompt those users never had.

Inside an update without a terminal, stay-awake now reuses the update's
authorization with a non-interactive sudo instead of asking again through
polkit. It falls back to polkit only if that authorization is gone.

The test sudo refuses a cold non-interactive call, as the real one does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-26 15:31:40 +02:00
Afonso Oliveira 9735414603 Run fingerprint removal fixtures as real nonroot callers 2026-09-26 13:00:15 +01:00
Spencer BullandCodex XHigh abf2f756f2 Judge a service by its program, and name any service left stopped
A unit is Omarchy's or the old package's by the program its ExecStart runs or the script it hands that program, not by a path appearing anywhere in the line, so a gateway that merely names ~/.openclaw in an argument is not taken over. The state directory and config overrides are cleared with the profile selectors, since Omarchy's OpenClaw is the default one. Any failure after a running service was stopped for the move names that service, a partial stop included.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-26 01:48:45 -05:00
Spencer BullandCodex XHigh 8d25d6aef3 Require a moved OpenClaw service to be running again
Upstream's installer rewrites a loaded gateway onto the new copy but only warns when it will not start, which let the install finish with no gateway running. A service that was running before the move now has to be running after it, or the install fails and says the service is stopped; a failed seed says so too.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-26 01:38:40 -05:00
Spencer Bull d2305add6a Keep the OpenClaw that can open the state when removal keeps the state
Removing the runtime while keeping ~/.openclaw left state that its own updates had migrated past the packaged release, and reinstalling seeded a release that refused to open it; even `openclaw update` refused. Removal now takes openclaw off PATH and leaves the runtime inside ~/.openclaw with the state, where a reinstall picks it back up; deleting ~/.openclaw still takes both.
2026-09-26 01:16:26 -05:00
Spencer BullandCodex XHigh 44f5fd8f0e Stop an old gateway before seeding, and refuse before installing
Upstream's installer runs `gateway install --force` on any gateway it finds loaded, so the old package's gateway has to be stopped before seeding rather than after, and a stop that fails ends the run. The profile selectors are cleared for the whole command, installer included, and every refusal now comes before the package is installed.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-26 01:06:36 -05:00
Spencer BullandCodex XHigh 24d591da14 Refuse before touching anything, and move an old gateway cleanly
The first E2E run of the migration found that the new runtime cannot install its gateway service while the old package's gateway, still running from deleted files, holds the state directory, so that service is stopped first. Removal also takes the unit backups `openclaw update` leaves behind.

Codex's review found the rest: upstream's installer takes over any loaded gateway service, so a gateway running another OpenClaw, a foreign command on PATH or an openclaw shadowing it are refused before anything is set up; root is refused before --check runs the user's wrapper; --check needs the package, since --now would otherwise run pacman where no terminal can ask for a password; and moving a service clears the selectors that would aim the install at another unit.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-26 00:18:45 -05:00
Spencer Bull be18b324f8 Install OpenClaw as the self-updating copy under ~/.openclaw 2026-09-25 23:48:46 -05:00
Chemineer eaaca77efb Report shutdown failures and test blocked window closing 2026-09-25 20:06:46 -05:00
Ryan Hughes 7b336b1b0d Merge pull request #11486 from omacom/fix-remote-neovim-yanks
Migrate remote Neovim providers to reliable clipboard yanks and paste
2026-09-25 14:57:16 -05:00
Ryan Hughes 289f36ab97 Require the corrected Neovim package before clipboard migration 2026-09-25 15:35:10 -04:00
Kike FerraterandClaude Opus 5.5 65883c3890 Keep the lib32 driver helper from failing when no GPU is detected
With an empty package list the trailing && left the helper with exit
status 1, which stopped the Steam installer before Steam was installed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 19:26:21 +02:00
Erik Melton d201fb9564 Merge pull request #9467 from AFOliveira/codex/om-sec-12-update-inhibitor-identity
[codex] OM-SEC-12: Bind update inhibitor cleanup to process identity

Reported-by: Afonso "AFOliveira" Oliveira
2026-09-25 15:56:39 +02:00
Erik Melton b5aa835fb3 Merge pull request #13046 from AFOliveira/fix/color-reference-cycles-20260923
Guard against cyclic theme colour references
2026-09-25 15:54:32 +02:00
David Heinemeier Hansson c599d23a39 Document automatic screenshot saving (#13218) 2026-09-25 07:28:27 -05:00
Spencer Bull c3e67f5d40 Merge pull request #12844 from omacom/hermes-desktop-default-agent
Install Hermes for the default agent as the desktop app's self-updating runtime
2026-09-24 23:41:18 -05:00
Ryan Hughes 5bc098d749 Merge pull request #11690 from Yarlord/fix-hybrid-nvidia-vaapi
Only force NVIDIA VA-API/GLX env when NVIDIA drives the display
2026-09-24 21:52:04 -05:00
Ryan Hughes 28405af27f Request reboot for hybrid NVIDIA fix and streamline tests 2026-09-24 22:49:41 -04:00
5630c6f561 Count Hermes run as a module from an activated venv as Hermes too
With the venv active, a gateway started by hand is `python -m hermes_cli.main gateway run`: the interpreter is bare, the executable resolves outside the runtime, and no runtime path is on the command line, so the removal took it for a stranger and refused over the files it holds rather than closing it. Hermes's own package named with -m is the program.

Co-Authored-By: Codex XHigh <noreply@openai.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 21:41:08 -05:00
14eb9430c3 Close Hermes on removal instead of asking the user to close it
Remove > AI refused whenever anything had Hermes's files open and told the user to close it and try again, and the thing open was Hermes: the agent in the terminal that choosing it as the default agent leaves running, the desktop app, a gateway. Those are the removal's to close. It now stops the gateway unit that upstream's `hermes gateway install` wrote, since that unit starts the runtime about to be deleted and would start it again the moment it was killed, then ends every process whose program lives in that runtime or in the package, and only then refuses over whatever is left, which is somebody else's: an editor on a skill, a shell sitting in ~/.hermes, a writer on the state database. Both are judged by the program, never by a later argument or by the home served, so an editor opened on a runtime file is the user's and a unit running a Hermes kept elsewhere is left alone whatever home it serves; for an interpreter the program is the script it runs, so a gateway started by hand as `python .../hermes gateway run` is found too. The refusal comes before anything is touched, so a run that stops leaves Hermes running as it was. A unit that will not stop still aborts the removal, as dropping the package would strand a live gateway on deleted code.

Co-Authored-By: Codex XHigh <noreply@openai.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 21:35:46 -05:00
Afonso Oliveira 99e3c8ca2f Test factory reset version mismatches and packaged invocation 2026-09-24 22:42:46 +01:00
Afonso Oliveira 45a6433139 Refuse factory reset handoff to a different installed version 2026-09-24 22:42:46 +01:00
bjarneo 93e8cd56b1 Merge pull request #12774 from MicahelE/fix/fastfetch-swap-label
Fix Fastfetch swap label spacing
2026-09-24 22:47:46 +02:00
Afonso Oliveira f8ead5cf5f Test SSH service and application firewall cleanup 2026-09-24 10:18:44 +01:00
Afonso Oliveira d03e2f2cf5 Test fingerprint cleanup with numeric username collisions 2026-09-24 10:18:44 +01:00
Afonso Oliveira 27e1f73e35 Remove SSH service limits and application firewall rules 2026-09-24 10:18:44 +01:00
Afonso Oliveira fdd2c5dc14 Resolve fingerprint sudo callers strictly by UID 2026-09-24 10:18:44 +01:00
Chemineer 92f9864c72 Prevent lid closure from interrupting shutdown 2026-09-23 20:06:48 -05:00
bjarneo 28ceaae70e Merge pull request #7718 from OldJobobo/feature/remember-theme-wallpapers
Remember the selected wallpaper for each theme
2026-09-23 20:39:46 +02:00
OldJobobo 0385610cfe Merge quattro and preserve per-theme wallpaper memory 2026-09-23 10:41:32 -07:00
bjarneo c737c4975f Merge pull request #10134 from Macho0x/fix/vscode-theme-reload
Make VS Code-family editors reload the Omarchy theme when it changes
2026-09-23 19:04:39 +02:00
bjarneo b76a35d633 Merge pull request #12864 from anandude/fix-catppuccin-colorscheme
Point catppuccin theme at its flavour colorscheme
2026-09-23 18:54:59 +02:00
bjarneo a54668dae3 Merge pull request #9964 from ivanskodje/fix/vscode-theme-invisible-states
Fix invisible VSCode status bar labels and current line
2026-09-23 18:46:48 +02:00
bjarneo 9217abeea2 Merge pull request #10016 from ivanskodje/fix/vscode-theme-invisible-hover-states
Fix invisible hover and active states in the generated VS Code theme
2026-09-23 18:46:00 +02:00
Afonso Oliveira 0a8ccbbfa9 Test fingerprint template cleanup and account boundaries 2026-09-23 15:07:12 +01:00
Afonso Oliveira 93f566d258 Remove saved fingerprint templates for the invoking user
Reported-by: Sean Huber
2026-09-23 15:07:12 +01:00
Afonso Oliveira 1d0165aeb3 Test SSH firewall rule removal and failures 2026-09-23 11:49:54 +01:00
Afonso Oliveira 09c2b28afa Remove standard SSH allow rules when disabling SSH
Reported-by: Sean Huber
2026-09-23 11:45:50 +01:00
Afonso Oliveira 4dd5414bfc Test factory-reset elevation paths and arguments 2026-09-23 09:45:24 +01:00
Afonso Oliveira 7d4773311e Pin factory-reset elevation to the packaged command
Use the installed executable for non-root self-elevation, including calls from a development checkout or symlink.

Reported-by: Aleksej <aleksejhairov@yandex.ru>
2026-09-23 09:45:24 +01:00
Afonso Oliveira f5ad8a0b9e Stop cyclic shell color references
Resolve color references iteratively and use the caller's fallback when a reference repeats. Preserve normal palette roles, color parsing and gradient first stops.

Reported-by: Aleksej <aleksejhairov@yandex.ru>
2026-09-23 09:19:45 +01:00
Anandakrishnan CV 8d6a738269 Describe mocha as compatible with older plugin checkouts 2026-09-23 10:57:42 +05:30
f3989092b2 Refuse before anything of the user's is touched, whatever state the runtime is in
The refusals that leave a self-installed Hermes alone came before the command was replaced only for a finished runtime. An unfinished one was still bootstrapped over a git status that could not be read, since a failed status read as a clean tree, and a half-built app beside it, or an edit the Linux runtime patch could not land on, was found only after the user's command had been saved aside and replaced and main switched. All three are asked first now, in both states, so a run that is going to stop leaves the launcher, the checkout and ~/.local/bin as they were.

Co-Authored-By: Codex XHigh <noreply@openai.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-23 00:05:51 -05:00
Spencer BullandClaude Fable 5.1 73a2b91cf5 Install Hermes Desktop whenever Hermes is chosen as the default agent
Hermes is only ever installed through the app. Choosing it as the default agent used to stand aside for a hermes that worked but came from somewhere else, and to refuse one that predated seeded sessions; both left the machine on a Hermes that was not the app's, which is the one Omarchy prepares for in-app updates and hands the theme to. Now --check answers only for the app's own Hermes, and --now installs the app whatever answered to hermes before, saving the previous command aside as it always did for the app path. The desktop installer no longer adds the package itself, since the shared install does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 23:11:16 -05:00
Raj-Jagadeesh-A-P 56df5dd71d Fix grammar in navigation manual 2026-09-23 00:26:25 +05:30
Erik Melton d3cfd53b99 Merge pull request #10217 from omacom/security/ocaml-removal-no-update
Isolate OCaml cleanup authorization
2026-09-22 19:40:27 +02:00
bjarneo 5dc3a41c0d Merge pull request #10033 from vjohansen/fix-sublime-text-and-emacs-icons
Add editor icons for Sublime Text and Emacs
2026-09-22 19:28:56 +02:00
Ryan Hughes 588f339610 Merge pull request #12915 from omacom/terminal-clipboard-shortcuts
Add select all and standard terminal clipboard shortcuts
2026-09-22 12:04:55 -04:00
Ryan Hughes 129c67e25a Add select all and standard terminal clipboard shortcuts 2026-09-22 11:57:55 -04:00
Erik Melton a4dd253287 Merge pull request #11495 from lalvarezt/command-operands-as-literal
Treat command operands as literal values
2026-09-22 17:45:31 +02:00
Vagn Johansen 9b8a906de2 Add editor icons for Sublime Text and Emacs 2026-09-22 17:05:10 +02:00
Anandakrishnan CV 4e404da2b7 Point catppuccin theme at its flavour colorscheme 2026-09-22 08:05:58 +05:30
Spencer Bull 947e2fc002 Merge pull request #12861 from omacom/elsewhen-migration-rerun
Rename the Elsewhen migration so the link repair reaches machines that already ran it
2026-09-21 21:26:59 -05:00
Spencer Bull 73b4fc0d8a Rename the Elsewhen migration so the link repair reaches machines that already ran it
The re-point of a user plugin link stranded by the elsewhen package moving from plugins/ to shell/plugins/ landed in migration 1789581661, which every machine updated between that migration shipping and the package moving had already applied. omarchy-migrate keys completion on the filename alone, so the repair never ran where it was needed. On a dev checkout the shell discovers the packaged plugin only through that link, and a rescan drops a dangling one, so Elsewhen vanished from the bar on the first shell start after the package upgrade.

Renaming the file runs the whole migration again everywhere. Each step is safe to repeat: the package add is a no-op once installed, both link branches are guarded, the rescan is best-effort, and the put keeps an existing placement. The new test holds a state directory with only the old marker and checks the renamed file is still pending, which the old name could not pass.
2026-09-21 21:14:46 -05:00
44b0f2a70f Match the retired mise tool exactly in the global listing
Whether the environment mise built is gone was read by searching the global listing for the prefix `"pipx:hermes-agent`, so a tool that merely starts the same way, `pipx:hermes-agent-tools` say, read as the retired one still requested. Removing the real one changed nothing, the recheck failed again, and the migration stayed pending on every update. The key is matched whole now, with or without its options.

Co-Authored-By: Codex XHigh <noreply@openai.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 20:42:26 -05:00
72608aed85 Find a busy git in the runtime by its path as well as its name
The wait before clearing a stale shallow.lock looked for a git whose command line began with `git`, so one started as `/usr/bin/git`, which is what a caller that resolved it with `which` runs, was never seen: its lock, once a minute old, would have been cleared under it and a second fetch started against the same shallow file. The path is allowed only ahead of the name, at the start of the command line, so a process that merely names git in an argument, an editor opened on /usr/bin/git from inside the runtime say, is not waited for.

Co-Authored-By: Codex XHigh <noreply@openai.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 20:42:26 -05:00
Spencer BullandClaude Fable 5.1 8bffe142ce Answer npx for upstream's installer so the menu install does not stop to ask
Upstream's installer runs `npx playwright install chromium` for the browser tools, and npx asks before fetching a package it does not have. Over ssh, with no terminal, it goes ahead; in the floating terminal the menu opens for choosing Hermes it printed "Ok to proceed? (y)" and waited, so an install a user had every reason to walk away from sat there until someone typed y. The mise-built Hermes this replaces never asked anything. `--skip-setup` already answers the wizard the same way, and the app's own bootstrap never had a terminal to ask in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 20:42:26 -05:00
Spencer Bull 5364fea467 Merge pull request #12853 from omacom/elsewhen-migration-absent-shell
Let the Elsewhen migration survive a shell it cannot reach
2026-09-21 20:24:29 -05:00
Spencer Bull aa5c4beb50 Re-point the Elsewhen link stranded by the package's move
The 2026-09-19 revision of this migration, and a symlink then shipped under config/, pointed every install's ~/.config/omarchy/plugins/omacom.elsewhen at /usr/share/omarchy/plugins/omacom.elsewhen. elsewhen 1.0.0-2 moved to shell/plugins, so the link dangles, the shell lists the plugin as enabled but never loads it, and the bar carries an empty slot. The migration kept any existing link, resolving or not, so a rerun could not repair it. A link into /usr/share/omarchy that no longer resolves is Omarchy's own and is re-pointed; a link the user made is still left alone.
2026-09-21 19:34:04 -05:00
Spencer Bull 3208fe09ae Let the Elsewhen migration carry on without a shell to ask
A bare `omarchy-shell shell rescanPlugins` exits 1 when no shell answers on the caller's tree, and omarchy-migrate runs migrations under `set -e`, so an update run from a TTY or over ssh, or one whose shell had gone down while the package replaced its QML, died at "omarchy-shell is not running" before its post-update hooks, shell restart and reboot prompt, and re-failed the same way every time until a shell could be asked. The rescan is now best-effort, like `omarchy-bar put` already is and like #11117 makes the plugin commands' closing rescan: the update restarts the shell once the migrations are through, and a session without one has no bar to place on. An unknown widget on a live shell still leaves the migration pending.
2026-09-21 19:33:42 -05:00
7eb818e37b Install Hermes for the default agent as the desktop app's self-updating runtime
Choosing Hermes as the default agent built it through mise: a pipx environment with no checkout, so `hermes update` had nothing to move, and the only Hermes that could update itself was the one Hermes Desktop set up. Both paths now run the same setup. omarchy-install-hermes-cli installs the hermes-desktop package and runs upstream's installer from it, pinned to the packaged release and started on main, exactly as Install > AI did; omarchy-install-ai-hermes is that plus opening the app. The terminal, the default agent and the app share one runtime, and it updates itself.

--check answers whether --now has anything left to do, not merely whether a hermes runs: choosing Hermes from the menu asks first and opens a terminal only on a no, so a yes has to mean no minutes-long step would run where nobody can see it. With the app installed that means the runtime's own command, its completion marker and the seeded packaged app; a finished runtime whose command is gone, somebody else's, or its own but unable to run gets it back from upstream's path stage without bootstrapping again. Either way the command has to be the one PATH finds, because omarchy-agent runs bare `hermes` and Omarchy puts mise's shims ahead of ~/.local/bin; a command in the way is named rather than installed over. The modes are named outright because the app's launcher used to call this command with no arguments to reconcile a mise copy; a default of --now would turn every launch into an install. --check still refuses to run the retired wrapper, since running it built Hermes through mise, and a machine whose migration is pending can still have it on PATH.

Provisioning no longer writes the wrapper, Remove Preinstalls no longer looks for it, and the wrapper, the environment it built and what proves them Omarchy's are known to the installer alone: --retire-mise is the migration's whole job, and --now runs the same removal once the runtime installer has saved the wrapper aside, so a user who chose Hermes before their migration ran is not left with mise's shim answering `hermes`. Only the wrapper proves the environment is Omarchy's, at its path or in that saved copy, so the environment goes first and the wrapper last, judged by mise neither having it installed nor still requesting it; a removal that leaves either behind, or a listing that cannot be read, mise missing included, stops with the commands to finish by hand and leaves the migration pending. The migration that once installed the wrapper is kept as a no-op for late updaters, and one whose default agent was Hermes is told to choose it again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-21 19:26:02 -05:00
bjarneo b8c382da9e Merge pull request #9656 from Chessing234/fix/sublime-default-editor-uses-subl
Use subl for the Sublime Text default editor
2026-09-21 23:11:07 +02:00
bjarneo 04f6145a16 Merge pull request #9125 from animadito/dropbox-requires-sudo
Dropbox install not flagged as needing sudo
2026-09-21 23:09:04 +02:00
bjarneo 29959d1051 Merge pull request #12839 from Bartok9/fix/drop-libretro-database-on-retroarch-remove
Drop libretro-database-git when removing RetroArch
2026-09-21 22:41:32 +02:00
bjarneo 74f96add07 Merge pull request #10280 from Kenny1291/patch-1
Remove link to Omarchy 3 manual
2026-09-21 22:14:39 +02:00
Bartok 6810131e1d Drop libretro-database-git when removing RetroArch 2026-09-21 16:01:53 -04:00
Afonso Oliveira 6af052fcc3 Bind update inhibitor cleanup to owned process identity
Keep inhibitor state in validated private directories and verify the recorded owner, PID, start time and launch token before signaling. Authenticate the held command before detaching and drop it back to the invoking user.

Serialize launch and cancellation, identify the child before publishing its state, and preserve caller-owned idle choices. Cover cross-account fallback state, process identity, cancellation, retry, and update-lock handling with isolated regressions.
2026-09-21 16:09:04 +01:00
acrogenesis 27bbbb2fe9 Merge quattro into USB authorization branch 2026-09-21 08:23:51 -06:00
Afonso Oliveira 56ca654dc8 Merge quattro into update security foundation 2026-09-21 14:14:59 +01:00
Michael Etokakpan c3b9e011b4 Fix Fastfetch swap label spacing 2026-09-21 12:50:45 +01:00
David Heinemeier Hansson 8f324c90b8 Merge pull request #12758 from omacom/simplify/test-skip-reporting
Report skipped checks separately in shell tests
2026-09-21 11:55:29 +02:00
David Heinemeier Hansson 599a6a665b Report skipped checks separately in shell tests 2026-09-21 11:49:32 +02:00
David Heinemeier Hansson 961ec7f39f Merge pull request #12748 from omacom/simplify/network-qr-test-environment
Fall back to connected Wi-Fi when QR route lookup fails
2026-09-21 11:37:46 +02:00
David Heinemeier Hansson 07fc39c8be Merge pull request #12747 from omacom/simplify/pacman-test-environment
Isolate pacman tests from inherited LC_ALL
2026-09-21 11:37:20 +02:00
David Heinemeier Hansson 457474f41e Merge pull request #12746 from omacom/simplify/about-test-environment
Isolate About tests from inherited NO_COLOR
2026-09-21 11:35:38 +02:00
David Heinemeier Hansson 5c2be2e653 Fix Wi-Fi QR fallback after failed route lookup 2026-09-21 10:54:38 +02:00
David Heinemeier Hansson 6f9f31ecea Isolate pacman tests from inherited LC_ALL 2026-09-21 10:50:25 +02:00
David Heinemeier Hansson 86bf7ebc71 Isolate About tests from inherited NO_COLOR 2026-09-21 10:50:25 +02:00
David Heinemeier Hansson 5b22f50be3 Stub route discovery in network QR tests 2026-09-21 10:50:25 +02:00
David Heinemeier Hansson ef81d37a87 Merge pull request #12745 from omacom/simplify/locate-test-history
Remove obsolete repository scan from locate test
2026-09-21 10:46:36 +02:00
David Heinemeier Hansson 5a066de66a Merge pull request #12744 from omacom/simplify/kernel-test-history
Remove stale migration filename assertion
2026-09-21 10:46:19 +02:00
David Heinemeier Hansson 273c340dfa Remove obsolete repository scan from locate test 2026-09-21 10:40:17 +02:00
David Heinemeier Hansson faffb8ee4d Remove stale migration filename assertion 2026-09-21 10:40:17 +02:00
David Heinemeier Hansson 45f51f1876 Merge pull request #12740 from omacom/simplify/shell-docs
Point shell documentation to the canonical reference
2026-09-21 10:23:00 +02:00
David Heinemeier Hansson c7e52153b7 Merge pull request #12739 from omacom/simplify/retired-copy-url-test
Retire the shipped Copy URL migration test
2026-09-21 10:21:35 +02:00
David Heinemeier Hansson 72648651cd Preserve shell reference details and repair incoming links 2026-09-21 10:13:54 +02:00
David Heinemeier Hansson 1aa1423eac Retire the shipped Copy URL migration test 2026-09-21 10:10:02 +02:00
David Heinemeier Hansson b094787f1a Point shell documentation to the canonical reference 2026-09-21 10:09:24 +02:00
Ryan Hughes ee8ebf615d Merge pull request #9446 from omacom/use-omasnap-for-screenshots
Use Omasnap for screenshots
2026-09-21 02:27:25 -04:00
Ryan Hughes 39d1cb956d Merge quattro into use-omasnap-for-screenshots 2026-09-21 02:19:10 -04:00
Ryan Hughes 95120838d3 Document Omasnap's default capture flow 2026-09-21 02:14:55 -04:00
Ryan Hughes c0ba99670d Clean up legacy screenshot tools in Omasnap migration 2026-09-21 02:14:54 -04:00
David Heinemeier Hansson ab18321bb5 Merge pull request #12429 from omacom/owe-video-backgrounds
Replace the shell's desktop video path with OWE
2026-09-21 03:55:16 +02:00
David Heinemeier Hansson 0d5232ed7b Fix Elsewhen migration for dev checkouts 2026-09-21 03:38:10 +02:00
David Heinemeier Hansson b423f4993d Complete OWE service setup and lock feed fallback 2026-09-20 20:36:19 -05:00
Ryan Hughes 45748a2812 Remove obsolete Elsewhen plugin symlink 2026-09-20 14:25:34 -04:00
Ryan Hughes e265934bb1 Use Omasnap for screenshots 2026-09-20 13:18:36 -04:00
selfcryptoandClaude Opus 5 4c5f363df7 Install lib32 graphics drivers before Steam
Steam depends on the virtual package lib32-vulkan-driver. omarchy-pkg-add
runs pacman with --noconfirm, which silently resolves that to the first
provider, lib32-nvidia-utils, pulling the NVIDIA stack onto machines with
no NVIDIA GPU. Running omarchy-install-gaming-gpu-lib32 first satisfies
the dependency with the driver that matches the detected GPU.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:02:56 +02:00
David Heinemeier Hansson e38c1d1289 Ignore claude 2026-09-19 21:53:09 +02:00
Paulo Geyer 14921a956e Treat idle timeout 0 as disabled, not immediate
idle.screensaver / idle.lock of 0 was included in Math.min(), so
IdleMonitor's timeout became 0. Releasing a Wayland idle inhibitor
(SDL's default screensaver inhibit) then reported idle immediately and
locked the session even when lock had been "disabled" by setting it to 0.

Treat 0 as disabled when computing the first idle deadline, do not start
the corresponding action, and leave IdleMonitor off when both timeouts
are 0.

Fixes #10860
2026-09-19 13:57:52 -03:00
Spencer Bull 60663faf87 Merge pull request #12157 from omacom/elsewhen-default-plugin
Install Elsewhen, the world clock plugin, by default
2026-09-19 00:22:54 -05:00
Spencer Bull 16cc7d7a9d Leave the shell restart to the update flow
Restarting immediately after the plugin rescan races Quickshell IPC handler creation and can crash the exiting shell. The normal update flow already restarts after migrations. Let this migration finish through live enablement and placement without adding timing workarounds.
2026-09-19 00:06:29 -05:00
Spencer Bull 9e3ff71f48 Simplify Elsewhen installation and bar migration
Link the package into the existing plugin directory and let bar put handle enablement, clock-relative placement, and the missing-clock fallback. Preserve user checkouts and existing placements, and seed the same link for new users. This removes the Atreyu packaged-discovery prerequisite and the checkout cleanup and JSON rewrite machinery.
2026-09-19 00:01:29 -05:00
Bjarne Oeverli b0bf4876b1 Describe the lock feed in the background manual 2026-09-18 22:55:27 +02:00
Bjarne Oeverli eff410f91e Draw the lock screen video from the OWE lock feed
The shell drops its own player: BackgroundMedia is image-only, and the
lock loads Owe.LockFeedSurface through a Loader, so a system without the
module shows no lock video instead of losing the whole lock screen. The
feed pauses per output when the panel blanks or power saver turns on.

The lock view keeps its still effect path and darkens the feed for
legibility. QtMultimedia and the shell video pause policy are gone, and
the base package list requires owe and owe-lockfeed instead.
2026-09-18 22:49:47 +02:00
acrogenesis e12c8ed452 Restore signed boot files when USB boot policy changes fail 2026-09-18 13:30:03 -06:00
Bjarne Oeverli 2e3142a105 Point the owe package note at the omarchy-pkgs pull request 2026-09-18 21:28:04 +02:00
David Heinemeier Hansson 8675600e9e Merge pull request #12141 from nunomaduro/feat/improves-php-and-laravel-installation
Improves `php` and `laravel` installation
2026-09-18 15:08:49 -04:00
Bjarne Oeverli 831626daea Note that OWE plays the desktop video audio 2026-09-18 21:02:01 +02:00
acrogenesis de72b64872 Re-sign historical UKIs before changing USB boot policy 2026-09-18 12:43:29 -06:00
Bjarne Oeverli dbebc458db Replace the desktop video path with OWE
The desktop background no longer plays videos. OWE owns video
backgrounds, and the shell layer stays empty behind one. The shell keeps
stills, which OWE hands back to it.

Remove the desktop video pause plumbing that only existed to stop an
unseen player: the lock, idle, and battery service lookups, the
per-output fullscreen check, the first-screen audio opt-in, and the audio
output in BackgroundVideo. The lock screen keeps its own silent playback.

Update the background tests, the manual, and the package note.
2026-09-18 18:46:17 +02:00
Bjarne Oeverli 02bb92bc74 Mark the desktop video path as the no-OWE fallback 2026-09-18 18:36:07 +02:00
Bjarne Oeverli d01ef2d5d0 Let OWE own video backgrounds while it runs
The background plugin now watches for the OWE daemon socket. While OWE is
running, the desktop yields video playback to it and the shell keeps
stills. The lock screen keeps its own playback.

This lets Omarchy cooperate with OWE without OWE editing shell.json, so
the engine can ship as a package.

Add a package-list note that owe-wallpaper-engine must be added once it is
packaged.
2026-09-18 18:23:21 +02:00
David Heinemeier Hansson d174d4aa27 Add omarchy up alias 2026-09-18 15:34:03 +02:00
David Heinemeier Hansson d437b91312 Merge pull request #12402 from omacom/fix-1password-installer-launch
Detach 1Password from installer terminal
2026-09-18 09:31:54 -04:00
David Heinemeier Hansson 5f34ce4581 Detach 1Password from installer terminal 2026-09-18 14:10:14 +02:00
David Heinemeier Hansson f2cf3ce9d2 Merge pull request #11656 from omacom/loosen-offline-node-pin
Loosen the offline Node pin so mise up tracks new releases
2026-09-18 08:03:00 -04:00
Atos Lins 45fd98f344 Report why Windows VM directory hardening failed
The mode check returned 1 without any output, so a launch from the app
menu died after the polkit prompt with nothing to diagnose. Name each
source directory and the mode it was left with.
2026-09-18 07:49:06 -03:00
Spencer BullandCodex XHigh e8a8236a22 Preserve local Elsewhen work and fallback bar layouts
Retire only checkouts whose refs and reflogs are reachable from recorded origin history, and preserve ignored files. Leave configs without an explicit supported bar layout untouched so migration does not replace the shell fallback with an almost empty bar.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-17 22:11:46 -05:00
Spencer Bull 49286d0e66 Place Elsewhen immediately before the center clock 2026-09-17 21:41:39 -05:00
Spencer Bull 531c3e890f Install Elsewhen, the world clock plugin, by default
Elsewhen (omacom.elsewhen) arrives as the elsewhen package under
/usr/share/omarchy/plugins, the packaged root the shell scans between its
bundled plugins and the user's. It opens the right section of the default
bar, just before the tray, and a migration installs the package, writes the
widget into a customized shell.json in the same spot, and retires a pristine
pre-package clone of the upstream repo that the package now shadows.
2026-09-17 21:41:01 -05:00
acrogenesis db6ab5a8bf Resume USB approval verification after transient failures 2026-09-17 15:55:32 -06:00
Atos Lins bce0f512e0 Clear setgid when hardening Windows VM directories
A numeric chmod keeps setuid/setgid on a directory, so a setgid ~/Windows
stayed 2700 and failed the exact 700 privacy check, aborting the launch.
dockur marks an initially empty /shared setgid (chmod 2777) on first boot,
so this hits fresh installs the next time the bind anchors are recreated.
2026-09-17 17:44:26 -03:00
Afonso Oliveira 689771bdc1 Merge branch 'fix/9457-e-20260917' into fix/9469-c-20260917 2026-09-17 20:48:14 +01:00
Afonso OliveiraandClaude Fable 5.1 67a813d8d2 Bound the grant lock wait
The settings package's pre-transaction hook runs this command under
pacman's transaction. Its lock wait was unbounded, so a stalled grant
operation could hang pacman indefinitely before AbortOnFail ever saw a
result. Grant operations are short; wait at most 60 seconds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 20:41:45 +01:00
Afonso OliveiraandClaude Fable 5.1 c8407ee73f Cover completion failures in the package scriptlet contract
pacman continues a transaction after a failed scriptlet. Model that in
the shared package-scriptlet contract: a failed pre_upgrade followed by
post_upgrade must keep the removal blocker while a rule remains, a
clean retry recovers, a stranded blocker with a live rule is cleaned by
the next completed installation, a fresh install that cannot remove a
leftover rule leaves publication refused, and the sweep does not depend
on inherited glob state.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 20:41:45 +01:00
Afonso OliveiraandClaude Fable 5.1 43b91163f6 Install cleanup traps before the entry revocation and bound the bus probe
The protected entrypoints revoked the sudo timestamp before installing
their cleanup traps, so a signal or failure during that first sudo -k
exited without the cleanup path. Install the traps first.

The shell restart probed the notification bus name with busctl's
default 25 second timeout, so an unresponsive user bus could stall the
restart by that much per probe. Bound each probe to one second.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 20:33:59 +01:00
acrogenesis 90d604f41d Expire USB approval deduplication across daemon and watcher restarts 2026-09-17 12:04:02 -06:00
acrogenesis afad853062 Disable restored USB policy until factory owner enrollment 2026-09-17 11:02:10 -06:00
Afonso OliveiraandClaude Fable 5.1 b34b117fd0 Recover the session lock before waiting for notifications
Restarting the shell waited for the notification bus name to reappear
before it would re-acquire a lock whose client had died. A slow or
failed notification plugin then left the user stranded behind
Hyprland's failsafe even though the lock service worked.

Wait for the shell's core IPC, re-secure the lock immediately, and only
then wait for a previously running notification service, reporting it
separately if it never returns. Cover the never-returning case: the lock
comes back and the restart still reports the missing service.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 17:02:43 +01:00
Afonso OliveiraandClaude Fable 5.1 13a4306a8e Run the refresh hook before its transaction and keep the inhibitor through user work
Three review findings on the update-hook boundary:

The pre-refresh-pacman hook had been moved after the refresh transaction
and, during a channel switch, deferred to the very end. That defeated the
hook's purpose: custom repositories and IgnorePkg entries were not in
place when the downgrade-capable -Syyuu ran. Run the hook where it used
to run, after the package config is re-synced and before the transaction,
but cold: revoke the timestamp, run it behind the no-update wrapper with
the caller's original PATH, and revoke again before continuing. Every
later privileged command authenticates with --no-update, so a detached
child left by the hook has no reusable timestamp to wait for. Channel
switching hands the caller's PATH to the refresh the same way the updater
receives it, and no longer defers or re-runs the hook.

Stay Awake was released before AUR builds, hooks and mise, so the machine
could sleep during the longest part of an update. Releasing the inhibitor
needs no privilege because the held command already dropped to the user,
so stop it after mise and before the reboot prompt, as before.

A packaged channel destination cannot be inspected before its package is
installed, and a transaction can replace the running tree with a release
that predates the command-scoped wrapper; from then on a bare sudo would
resolve to /usr/bin/sudo and publish a timestamp, and the destination's
own updater authenticates the same way. The switch used to abort only
after the packages had changed, with generic rerun advice. Now it checks
for the wrapper after each transaction before any further privileged
step, completes what it safely can, and stops cold with instructions to
run that release's update from a fresh session instead of launching it.

Boundary tests pin the hook between the config copies and the transaction
with a cold timestamp on both sides, the older-destination stop with its
guidance and no launched updater, the new inhibitor position, and the
post-update hook staying unreached on failures and signals. Docs, the
manual and the sample hook describe the restored timing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 11:11:08 +01:00
Afonso OliveiraandClaude Fable 5.1 99ddf35138 Merge the current passwordless sudo expiry head
Bring in the legacy-grant classifier fix and the reserved-prefix
quarantine from #9457 so this branch no longer carries a stale copy of
that command.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 10:44:30 +01:00
Afonso OliveiraandClaude Fable 5.1 ea617b9126 Quarantine unrecognized policy under the generated sudoers prefix
Matching a legacy grant by its filename and rule relationship is not a
complete fingerprint: the legacy writer took the filename from $USER but
produced the rule with echo, and under BASH_ENV with xpg_echo a name such
as ali\0143e yields an alice rule in a mismatched file. Preserving that
as administrator policy let the migration certify success with an
unrestricted grant still live until the next boot.

The prefix is reserved anyway: boot cleanup and the package hook remove
everything under it. Move any file the classifier does not recognize
into a fresh root-only directory under /var/lib/omarchy/sudoers-quarantine/
as `policy`, with the original name stored beside it, so nothing there
stays live, the administrator keeps the content, and a legacy filename
already close to NAME_MAX still fits. An untrusted quarantine directory
keeps the migration pending. Cover the mismatched and maximum-length
legacy files in the unit cleanup and through the real migration runner.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 23:32:17 +01:00
acrogenesis 58a9c95bf6 Verify USB approval before reporting success 2026-09-16 16:06:13 -06:00
acrogenesis 67d173c9f9 Defer USB enrollment until owner provisioning completes 2026-09-16 16:05:59 -06:00
Afonso OliveiraandClaude Fable 5.1 3eb3142313 Recognize legacy sudo grants for any account name
The legacy command wrote the caller's unvalidated name into both the
sudoers filename and the rule. Cleanup applied the current lower-case
account pattern to that suffix, so an exact legacy grant for an account
such as Alice was classified as administrator policy, left active, and
the machine-wide migration marker was written anyway.

Match a legacy grant by its exact filename and rule relationship instead
of the account policy, and cover it in the lifecycle suite through both
the unit cleanup and the real migration runner.

The account pattern itself stays lower-case: sudoers reads an upper-case
word such as ALICE as a User_Alias reference, and ALL as every user, so
such names must never reach the generated rule. Pin that with a test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 23:01:13 +01:00
Afonso OliveiraandClaude Fable 5.1 4bd593f4ed Merge quattro and route refreshes through omarchy-update-pacman
Upstream now runs every Omarchy-owned pacman transaction through the
hidden omarchy-update-pacman helper so a mid-transaction systemd reexec
cannot kill it. Keep the deferred pre-refresh-pacman hook and the
command-scoped sudo wrapper, and call the helper from the refresh and
channel commands; the wrapper still applies to the helper's own sudo.

The sudo boundary fixture copies the helper into its root and runs a
systemd-run stand-in that execs the wrapped pacman step in place.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 21:54:06 +01:00
Afonso Oliveira 824be4b524 Keep YT6801 fallback through hardware reruns 2026-09-16 21:49:50 +01:00
Afonso Oliveira efa476114b Verify YT6801 cutover before retiring the vendor driver 2026-09-16 21:49:50 +01:00
Afonso Oliveira 71be7b16af Propagate package discovery failures before removal 2026-09-16 21:49:50 +01:00
Afonso Oliveira ea21c37d98 Make YT6801 migration retry-safe 2026-09-16 21:49:50 +01:00
Afonso Oliveira 6d2069230e Replace YT6801 DKMS driver 2026-09-16 21:49:50 +01:00
nunomaduro 85da80dd7c chore: removes comment 2026-09-16 20:46:40 +01:00
nunomaduroandClaude Fable 5.1 486e1cab9b fix: removes the laravel binary even when php is already gone
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 20:35:34 +01:00
nunomaduro 456af5cc88 chore: removes useless comment 2026-09-16 17:44:30 +01:00
Erik Melton 9c5482c58d Merge pull request #8170 from Adolanium/hook-state-name-guard
Refuse hook and state names that are paths
2026-09-16 18:09:20 +02:00
Erik Melton f6ce7c554f Merge pull request #10379 from AksharP5/fix/factory-reset-password-hashes
Erase old password hashes during factory reset
2026-09-16 18:07:22 +02:00
nunomaduro 181ad4b4d3 feat: improves php and laravel installation 2026-09-16 16:56:47 +01:00
acrogenesis 54bacefbf4 Explain USB availability in older recovery snapshots 2026-09-15 14:11:46 -06:00
acrogenesis 9e20c2a185 Retry USB approval delivery when notifications become available 2026-09-15 14:06:32 -06:00
acrogenesis 79db1a1826 Allow USB authorization setup with an empty device inventory 2026-09-15 14:06:32 -06:00
acrogenesis 666800eb0c Persist USB boot policy in Limine snapshot manifests 2026-09-15 13:35:57 -06:00
acrogenesis 4ea410d6a1 Retry USB approval notifications after delivery failure 2026-09-15 13:35:57 -06:00
acrogenesis 7309746ac8 Fix USB approval alerts after policy changes and reconnects 2026-09-15 13:05:25 -06:00
David Heinemeier Hansson 2fbac0c8e8 Merge pull request #11934 from omacom/claude-browser-extension
Set up browser integration when choosing Claude
2026-09-15 12:41:39 -04:00
David Heinemeier Hansson 677e69d4f1 Make Claude browser extension installation best effort 2026-09-15 12:40:25 -04:00
David Heinemeier Hansson 8fa9f4d03e Leave Claude browser integration settings unchanged 2026-09-15 12:38:00 -04:00
Erik Melton 6ea3215542 Merge pull request #7902 from dicemans/rdp-password-not-in-argv
[Security] Keep the Windows VM password out of the RDP client's argument list
2026-09-15 18:08:10 +02:00
Erik Melton a73bcbfc0a Remove unsafe project bin PATH injection (#11336)
* Remove unsafe project bin PATH injection

* Cover customized unsafe Mise paths

* Revoke legacy Mise Work trust

* Harden legacy Mise trust cleanup

* Preserve ignored Mise Work configs

* Scope Mise path cleanup to env

* Accept paranoid Mise ignore marker

Reported-by: infosec-us-team
2026-09-15 18:02:17 +02:00
b44fb74780 [Security] Keep screen-recording state out of world-writable /tmp (#8374)
* Keep screen-recording state out of world-writable /tmp

* Compare the /tmp name across the run instead of requiring it absent

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Fall back to the state directory when there is no runtime dir

* Let the /tmp snapshot come back empty

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Resolve the region file the same way in the resizer

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Protect recording fallback state and document its path

---------

Co-authored-by: Omabot <omabot@omarchy.org>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 17:53:37 +02:00
Adolanium 49418942c8 Merge pull request #7807 from Adolanium/keyring-fail-loud
Stop update-keyring from claiming success when key operations fail
2026-09-15 17:50:20 +02:00
David Heinemeier Hansson 45e32c8c2d Set up browser integration when choosing Claude 2026-09-15 08:10:58 -04:00
David Heinemeier Hansson f2b419d9a9 Merge pull request #11658 from omacom/tcp-bbr-fq
Switch TCP congestion control to BBR with fq pacing
2026-09-15 07:17:03 -04:00
Joseph WinkeandClaude Opus 4.8 6df574cbb4 fix(shell): keep __sourceDir on third-party plugin manifests
publicPluginManifest() stripped __sourceDir from every non-first-party
manifest, but service and panel plugin instances receive only that
stripped manifest and no separate sourceDir (unlike barWidgets, which
get meta.sourceDir). That left third-party service/panel plugins with
no way to locate their own bundled scripts and assets: any plugin that
launches a bundled script resolves it against an empty base path and
fails silently (e.g. Omarchy-Spotify hangs on "Checking playback
support…", its keyring/credential scripts never run).

__sourceDir is the plugin's own install directory, not a privilege
signal — the plugin's code already runs from there. Keep withholding
the real privilege markers (__isFirstParty, __hostCapabilities) but
stop stripping __sourceDir. The plugin auth-boundary test suite still
passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-15 06:37:33 -04:00
Ryan Hughes 47de63290f Merge pull request #11900 from omacom/fix/complete-kernel-headers
Treat kernel headers as part of the base system
2026-09-14 22:33:56 -07:00
Ryan Hughes 24417bf191 Treat matching kernel headers as a base system guarantee 2026-09-15 00:46:30 -04:00
acrogenesisandOutfoxxed 44f68f4329 Require approval for new USB devices by default
Co-authored-by: Outfoxxed <outfoxxed@outfoxxed.me>
2026-09-14 22:13:36 -06:00
Ryan Hughes 662051ecde Install matching kernel headers for every DKMS setup 2026-09-15 00:06:17 -04:00
Ryan Hughes d237995b3b Merge pull request #11897 from omacom/fix/hardware-omarchy-headers
Use Omarchy kernel headers for hardware DKMS drivers
2026-09-14 20:47:04 -07:00
Ryan Hughes f464b6087d Use Omarchy kernel headers for hardware DKMS drivers 2026-09-14 23:45:34 -04:00
Ryan Hughes 86a2e5830e Merge pull request #11845 from omacom/fix/linux-omarchy-default-kernel
Migrate to linux-omarchy except on T2 Macs
2026-09-14 13:49:49 -07:00
Ryan Hughes 08a875852e Leave fresh-install kernel selection to the ISO 2026-09-14 16:46:45 -04:00
Ryan Hughes ff85faf8dd Make linux-omarchy the default kernel except on T2 Macs 2026-09-14 16:32:09 -04:00
Ryan Hughes 905ff1f792 Replace Neovim providers atomically and preserve dotfile links 2026-09-13 19:48:41 -04:00
Ryan Hughes b679363bed Merge pull request #11729 from omacom/fix/omarchy-ptl-kernel-migration
Migrate Panther Lake systems to the Omarchy PTL kernel
2026-09-13 11:51:18 -07:00
Ryan Hughes 8a972da975 Migrate Panther Lake systems to the Omarchy PTL kernel 2026-09-13 14:43:47 -04:00
David Heinemeier Hansson 8247eb36b7 Merge pull request #11637 from omacom/fix-powerprofile-poll-coredumps
Poll the active power profile over D-Bus instead of powerprofilesctl
2026-09-13 18:06:00 +02:00
David Heinemeier Hansson 5e261369bc Merge pull request #11712 from omacom/remove-codeowners
Remove CODEOWNERS
2026-09-13 17:58:56 +02:00
David Heinemeier HanssonandClaude Fable 5.1 8369601f26 Remove CODEOWNERS
No branch on the repo is protected and there are no rulesets, so the
file never enforced owner review. Its only effect was auto-requesting a
review from the other owner on every PR, which nobody acted on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 17:58:17 +02:00
David Heinemeier Hansson 90f0054658 Upgrade known clipboard providers with backups and package version guard 2026-09-13 17:46:13 +02:00
David Heinemeier Hansson a2629d831a Merge pull request #11707 from omacom/fix-basecamp-github-urls
Point GitHub URLs at omacom/omarchy instead of basecamp/omarchy
2026-09-13 17:27:15 +02:00
David Heinemeier Hansson f8ceedcb50 Merge pull request #11703 from omacom/add-cloudflare-cli
Add Cloudflare CLI lazy wrapper
2026-09-13 17:23:04 +02:00
David Heinemeier HanssonandClaude Fable 5.1 d78ea9e158 Point GitHub URLs at omacom/omarchy instead of basecamp/omarchy
The repo moved to the omacom org. GitHub redirects the old URLs, but
`omarchy channel set dev` was still cloning from basecamp/omarchy, which
left every dev checkout with a stale origin remote that confuses gh
(pr create fails with "No commits between omacom:quattro and
basecamp:<branch>"). Update the clone URL, the quattro upgrade tarball,
the update-confirm release link, the systemd Documentation link, and
the manual.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSFKDatumRZHB8zqk5CP5C
2026-09-13 17:20:29 +02:00
David Heinemeier Hansson 694e7b43a8 Merge pull request #11704 from omacom/fix-1password-window-class
Match 1Password's new window class so it floats again
2026-09-13 17:19:02 +02:00
David Heinemeier HanssonandClaude Fable 5.1 9ca462b153 Match 1Password's new window class so it floats again
1Password 8.12 changed its app id from "1Password" to
"com.onepassword.OnePassword" (its .desktop file now declares that as
StartupWMClass). Our window rule only matched the old name, so the main
window came up tiled and lost the no_screen_share protection.

Match both forms so older installs keep working.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LSFKDatumRZHB8zqk5CP5C
2026-09-13 17:05:40 +02:00
David Heinemeier Hansson a389bd1852 Add Cloudflare CLI lazy wrapper 2026-09-13 16:59:33 +02:00
David Heinemeier Hansson 6a89a39967 Merge pull request #11653 from omacom/io-scheduler-kyber
Use the kyber I/O scheduler on real disks
2026-09-13 16:45:15 +02:00
David Heinemeier Hansson cf17182bf5 Merge pull request #11698 from antonkesy/quattro
Fix typo in manual
2026-09-13 16:44:42 +02:00
Anton Kesy 69c67c63d6 Fix typo in manual 2026-09-13 16:28:44 +02:00
vatsal nadkarni 6ecf4bf200 Only force NVIDIA VA-API/GLX env when NVIDIA drives the display 2026-09-13 19:34:17 +05:30
David Heinemeier Hansson 692c02cad5 Merge pull request #9140 from ogarza/center-qconsole
Center the Quake console in a 2:1 panel instead of spanning the screen.
2026-09-13 12:38:41 +02:00
David Heinemeier HanssonandClaude Opus 5 d377569edb Count only tiled windows and refit when apps move or float
A floating window on the console is not laid out by the gaps, so it no
longer stretches the panel to full width. Moving an app onto or off the
scratchpad and toggling floating now refit too, via
window.move_to_workspace and window.update_rules; both were measured on
Hyprland 0.56.2 to carry the settled count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012UyVoFTM98Tduoxg7qZax7
2026-09-13 12:37:08 +02:00
David Heinemeier HanssonandClaude Fable 5.1 9246647071 Switch TCP congestion control to BBR with fq pacing
Cubic keeps pushing until packets drop, which stands queues up in the path
on fast links. BBR paces to its estimate of bottleneck bandwidth and minimum
RTT instead, cutting queueing latency while keeping throughput. fq is the
qdisc BBR is built to pace through.

tcp_bbr and sch_fq are modules in every kernel Omarchy ships and autoload
when the sysctls are set. The migration re-applies the shipped file so new
connections switch without a reboot, no-ops once the live values match, and
flags a reboot if applying fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 12:15:27 +02:00
David Heinemeier HanssonandClaude Fable 5 5db4a40195 Loosen the offline Node pin so mise up tracks new releases
Offline installs unpack the bundled tarball and pin Node to its exact
version, since latest can't be resolved without network. But nothing ever
loosened that pin, so Node stayed frozen at the ISO's version and mup
skipped it forever, while online installs tracked latest.

Rewrite the pin to latest right after registering the bundled version:
mise resolves latest to the installed version while offline (verified
with no network and an empty cache), and the first mise up with network
picks up new releases just like an online install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017LseZ1jcLaFBnndRnW4yb5
2026-09-13 12:11:00 +02:00
David Heinemeier HanssonandClaude Fable 5.1 795ac57a6c Match only whole disks in the kyber udev rule
udev patterns are shell globs, so nvme[0-9]*n[0-9]* also matched every
partition. Partitions have no queue/scheduler, and udev logged a "Could not
chase sysfs attribute" for each one at boot. Restrict the match to
SUBSYSTEM block with DEVTYPE disk, which also keeps mmcblk boot areas and
NVMe multipath nodes out.

Reword the comment: kyber targets a read latency rather than bounding it,
and the kernel's default choice depends on the device rather than being a
fixed NVMe versus everything-else split.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 12:09:50 +02:00
David Heinemeier HanssonandClaude Fable 5.1 dae1f4bf0e Use the kyber I/O scheduler on real disks
The kernel leaves NVMe on none and everything else on mq-deadline. Neither
bounds latency once the device queue fills, so a large build, copy, or
package upgrade can make the desktop sluggish while reads wait behind a
wall of writes.

Kyber keeps separate read and sync-write queues and throttles the depth it
submits to hit a 2ms read target, which keeps interactive reads flowing
under heavy writes at negligible CPU cost. The trade is a small ceiling on
peak throughput on very fast devices, which matters for a storage server
chasing IOPS but not for a desktop.

Ships as a package-owned udev rule in /etc so it applies at boot and on
hot-plug. Existing installs pick it up at the next boot. zram is left alone
since it has nothing to schedule.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 11:53:27 +02:00
David Heinemeier Hansson 93d9d0c859 Merge pull request #11651 from omacom/shield-pacman-from-session-teardown
Shield pacman transactions from desktop session teardown
2026-09-13 02:17:24 -07:00
David Heinemeier HanssonandClaude Fable 5 83c6dd230c Keep the conflict tests inside their fixture despite the scope wrapper
The update conflict tests stub sudo and pacman, but omarchy-update-pacman
now puts systemd-run between them, so on a systemd-booted host the tests
would reach for the real system manager. Stub systemd-run to drop the
wrapper's options and run the command, and cover the helper's own
invocation composition in a new test.

Raised by codex review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015zcqENR1UbhuwC1v5u3Wop
2026-09-13 10:35:32 +02:00
David Heinemeier HanssonandClaude Fable 5 24841ac5d2 Tell how to resume a channel switch that failed partway
omarchy-channel-set runs under set -e, so a failure after it has begun
mutating the system (dev link, pacman channel, packages) died silently
with the switch half-applied. Trap ERR once the mutation phase starts
and say how to pick the switch back up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 10:21:46 +02:00
David Heinemeier HanssonandClaude Fable 5 bc0753dfcb Show a failure state when a presented command exits non-zero
The floating-terminal presentation wrapper showed the green "Done!"
prompt for every exit code except Ctrl-C, so a failed update or channel
switch closed looking like a success. Pass the command's exit code
through to omarchy-show-done and render a red "Failed (exit code N)!"
prompt when it is non-zero. The pkg install/remove pickers get the same
treatment.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 10:21:46 +02:00
David Heinemeier HanssonandClaude Fable 5 f5194e3ff5 Shield Omarchy pacman transactions from desktop session teardown
Upgrading systemd runs its post_upgrade scriptlet mid-transaction, which
reexecs both the system manager and every user manager. When pacman runs
inside a user-session scope (the floating update terminal), that reexec
can SIGKILL it and abandon the transaction halfway, with packages
upgraded but none of the post-transaction hooks run.

Route every Omarchy-owned system mutation through a new hidden
omarchy-update-pacman helper that registers the transaction as a PID 1
scope via systemd-run, keeping it out of the user manager's cgroups.
System scopes survive the system manager's own reexec, and as a bonus the
transaction now also survives its terminal window closing. On unbooted
systems (the installer chroot) the helper runs pacman directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-13 10:21:38 +02:00
David Heinemeier HanssonandClaude Fable 5.1 1b001a69d3 Re-center the Plymouth prompt when a display appears late (#11641)
The theme laid out the logo, password entry and progress bar once, when
the script first ran. Plymouth sizes its window to the largest display
present, so a monitor that comes up after plymouthd starts (a Thunderbolt
display whose DisplayPort tunnel is established about a second after the
kernel's display driver loads, a dock, an MST hub) got the prompt drawn at
the internal panel's coordinates: off-center on the external display and
partly off-screen on the panel. With the lid closed the passphrase box was
effectively invisible.

Move the layout into a function and re-run it from the refresh callback
whenever Window.GetWidth/GetHeight change, which is how Plymouth's script
plugin exposes a display being added or removed. Existing bullets are
repositioned too.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 00:56:44 -07:00
David Heinemeier HanssonandClaude Fable 5.1 55799c4ab0 Poll the active power profile over D-Bus instead of powerprofilesctl
The battery service ran `powerprofilesctl get` every two seconds to keep the
active profile visible to the wallpaper and lock services. That command is a
PyGObject script, so the shell spawned a Python interpreter for it tens of
thousands of times a day. Roughly once a day one of those exits into a CPython
3.14 finalization race (python/cpython#124619): the GLib D-Bus worker thread
calls PyGILState_Ensure after the interpreter is torn down and the process
dies with SIGSEGV, leaving a core dump and a crash notification behind.

Read the ActiveProfile property straight from power-profiles-daemon with
busctl, the same way omarchy-powerprofiles-set already reads UPower. The
output is JSON, so an empty or malformed reply when the daemon is not running
still reads as no active profile, matching the previous behaviour.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011gbfh4Mi9dK6SAd1P2xMTi
2026-09-13 08:56:39 +02:00
LM 21cf0db6e9 Update shell tests for operand delimiters 2026-09-12 20:10:41 +02:00
LM b72d7e5768 Treat command operands as literal values 2026-09-12 17:57:33 +02:00
David Heinemeier Hansson 7270ee5021 Fix automatic Neovim yanks in remote sessions 2026-09-12 16:48:59 +02:00
David Heinemeier HanssonandClaude Fable 5.1 31bd80daa4 Keep the KEF LSX II LT USB sink from suspending (#11318)
The speaker's USB firmware stops answering control requests when the host
stops the audio stream after WirePlumber's 5 s idle suspend. The kernel
then logs usb_set_interface failed (-110), clock source 1 is not valid,
and cannot set freq 48000 err -110; PipeWire fails to start the sink and
only a replug recovers it. On one machine this happened on six days over
three weeks, up to hundreds of timeouts a day.

A WirePlumber rule sets session.suspend-timeout-seconds = 0 for the KEF
node only, so the stream is never stopped and the trigger never fires.
Other sinks keep the default. The migration seeds the file for existing
installs and restarts WirePlumber if it is running, since conf.d is only
read at startup.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 21:51:13 +02:00
Ryan Hughes b5589faaf8 Merge pull request #11263 from omacom/fix/t3-theme-migration
Activate T3 theme on existing installs
2026-09-10 22:57:36 -04:00
Ryan Hughes 262a779246 Activate T3 theme on existing installs 2026-09-10 22:57:04 -04:00
Spencer Bull 9f066b649e Merge pull request #10755 from spencerbull/add-claude-desktop-ai-menu
Add the Claude desktop app to Install > AI
2026-09-10 20:47:30 -05:00
e3392ef76f Quit the running Claude app before removing it
Electron rewrites ~/.config/Claude for as long as the app runs, so removing the package and deleting the directory while it is open leaves the app running and the directory back within the same second, holding fresh Preferences and crash-reporter state. Driven end to end on an edge worker: remove via the menu with the app open left 11 processes and a recreated ~/.config/Claude; with the app quit first the directory stayed gone. The test stubs pkill so the suite cannot take a developer's own Claude with it, and asserts the remover reached for it.

Co-Authored-By: Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-10 19:26:34 -05:00
Afonso Oliveira c46f321680 Simplify passwordless sudo grant lifecycle 2026-09-10 22:01:09 +01:00
Afonso Oliveira 212a454039 Clarify update hook timing in the user manual 2026-09-10 15:12:11 +01:00
9d0849f717 Add the Claude desktop app to Install > AI
Follows the ChatGPT flow: the Install > AI entry runs
omarchy-install-ai-claude in a floating terminal, which installs the
claude-desktop package (Anthropic's Linux desktop beta, repacked from
their Debian repo in omarchy-pkgs) and opens the app. Remove > AI
drops the package along with ~/.config/Claude and ~/.cache/Claude,
the Electron directories the desktop app owns, while keeping
~/.claude, ~/.claude.json, and ~/.cache/claude-cli-nodejs: those
belong to the Claude Code CLI, which ships in its own package and
survives this removal, just as the ChatGPT remover keeps the Codex
CLI.

The menu mark is a new U+E90E glyph in the Omarchy icon font, from
Simple Icons' Claude mark, so it reaches desktops through the next
omarchy-settings release.

Co-Authored-By: Fable 5 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-09 22:01:35 -05:00
Spencer Bull 8ea5151639 Merge pull request #10846 from omacom/fix-hermes-desktop-stamp-import
Fix Hermes desktop installation and align menu icons
2026-09-09 18:24:44 -05:00
David Heinemeier HanssonandClaude Opus 5 5ead870507 Add basecamp (basecamp-cli) as a lazy-installed mise tool (#10943)
Mirrors the hey-cli stub: the wrapper in ~/.local/bin installs and
upgrades through mise on first run, so the CLI tracks releases instead
of going stale as a manually dropped binary.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 15:13:43 +02:00
David Heinemeier Hansson f38eb42041 Premature to have this included like this
Lots of great discussions, but it's for Omarchy Cinque, if it happens.
This repo is for Quattro for now.
2026-09-09 11:38:23 +02:00
Spencer Bull 1cb960ac43 Use the Hermes desktop portrait for menu icons 2026-09-08 15:02:17 -05:00
Spencer Bull 27f10ffd97 Fix Hermes desktop installation after module split 2026-09-08 12:50:04 -05:00
Erik MeltonandCopilot Autofix powered by AI 5bfc8b5cc3 Update packaging companion instructions for clarity
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-08 19:47:11 +02:00
Ryan Hughes 5b91db503c Credit @Chainfire for the asdcontrol security report
Chainfire (@Chainfire) privately reported the passwordless asdcontrol watchdog reboot on August 25, 2026.

The fix landed in the 4.0.2 security work: #9200 (df819a6f) removed Omarchy's unrestricted sudo grant, while omacom/omarchy-pkgs#242 and omacom/omarchy-pkgs#243 restricted the package's grant. This records the reporter credit omitted from those changes.

Reported-by: Chainfire (https://github.com/Chainfire)
2026-09-08 12:49:55 -04:00
Ryan HughesandAdolanium 131bda36b5 Credit _SiCk // afflicted.sh for sudo expiry validation
Thanks to _SiCk // afflicted.sh for independently reproducing the passwordless sudo persistence issue and reviewing the proposed fix in #7990. The follow-up fix landed in #9387 (945af75a), with the 4.0.3 backport in cf459ba7.

The original boot-cleanup patch was contributed by @Adolanium. Preserve that co-author credit alongside this acknowledgment.

Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
2026-09-08 12:45:31 -04:00
Afonso Oliveira 60ac419f8c Merge current Quattro into sudo expiry fix 2026-09-08 15:28:24 +01:00
David Heinemeier HanssonandClaude Fable 5.1 7e8feb047d Relay the Elgato Cam Link 4K as a 16:9 virtual camera (#10809)
* Relay the Elgato Cam Link 4K as a 16:9 virtual camera

Browser meeting apps such as Zoom's web client ask the Cam Link for a
standard-definition stream, and Chromium settles on the smallest mode it
offers, 640x480. The Cam Link fills that 4:3 frame by cropping its 16:9
input, and the app then paints the frame into a 16:9 tile, so everyone
comes out stretched wide. The web client has no HD switch to avoid it.

Hide the raw capture node from users and re-expose it through v4l2-relayd
as a 1280x720 virtual camera with the same name, so there is still just
one "Cam Link 4K" to pick and no way to negotiate 4:3 from it. udev
starts the relay whenever the Cam Link enumerates and stops it on unplug,
and the relay only pulls frames while something is watching. The sink
runs unsynced because v4l2src stamps each frame with its capture time,
which a synced sink treats as already late and drops.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Take the review fixes for the Cam Link 4K relay

Tie only the device's stop into the relay instance. A start dependency on
it left a job waiting on a device that never comes whenever the base
v4l2-relayd.service is started without a Cam Link attached, since the
package generator wants every configured instance.

Let the loopback unit rerun on each relay start, so a deleted or unloaded
device is recreated on replug instead of the oneshot staying satisfied.

Start the relay outright at the end of the migration. The udev trigger
only starts it when the rule is new to the device, and a failed module
build would otherwise pass silently with the raw camera already hidden.

Run the hardware fix after the Panther Lake kernel swap, as it pulls in a
DKMS module that would otherwise build twice.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 14:09:51 +02:00
Taksh bb13b55698 fix: keep Sublime on the editor menu and persist subl
A when: subl guard hid the row before install, which is how the editor
gets packaged. Map stored sublime_text to subl so an existing default
stays ticked, and update the default-apps tests for the PATH name.
2026-09-08 14:59:34 +05:30
Ryan Hughes 148945000f Merge pull request #10774 from omacom/fix/legacy-omarchy-font
Retire the stock user icon font missed by Quattro upgrades
2026-09-08 02:38:26 -04:00
Ryan Hughes f5cd244ed0 Retire the stock user icon font missed by Quattro upgrades 2026-09-08 02:36:58 -04:00
Ryan Hughes 726a56ba03 Merge pull request #10772 from omacom/fix/cursor-mise-node
Keep Cursor bundled Node off the mise PATH
2026-09-08 02:25:26 -04:00
Ryan Hughes 138d839b37 Keep Cursor bundled Node off the mise PATH 2026-09-08 02:22:55 -04:00
Ryan Hughes d504061767 Merge pull request #10759 from omacom/fix/broadcom-wl-dkms
Install broadcom-wl-dkms now that Arch dropped the prebuilt module
2026-09-07 23:03:50 -04:00
Ryan Hughes 649c0b7433 Install broadcom-wl-dkms now that Arch dropped the prebuilt module
Arch removed the prebuilt broadcom-wl package on 2026-09-02 and rebuilt
broadcom-wl-dkms with replaces=(broadcom-wl). A replaces entry only helps
upgrades of an already-installed package, so the name is now unresolvable
as an explicit pacman target and the ISO offline mirror download fails
with "target not found: broadcom-wl".

broadcom-wl-dkms depends on dkms, so the explicit dkms in the hardware fix
is redundant; linux-headers stays because the module has to build against
the running kernel.
2026-09-07 22:56:48 -04:00
Afonso Oliveira 75e58f034f Integrate the shared sudo lifecycle foundation 2026-09-07 23:10:29 +01:00
Afonso Oliveira a6385e60b8 Bound sudo policy natively and guard expiry package transactions 2026-09-07 22:17:32 +01:00
Afonso Oliveira 4ae25cd4d2 Keep temporary sudo grants bounded through lifecycle failures 2026-09-07 21:50:47 +01:00
Afonso Oliveira bc235d2807 Merge remote-tracking branch 'refs/remotes/portfolio/quattro' into codex/portfolio-9457-20260907 2026-09-07 21:27:40 +01:00
bjarneo e848f1df97 Merge pull request #10670 from bjarneo/fix/keyboard-layout-switches-wrong-device
Stop the layout widget switching a device nobody types on
2026-09-07 20:59:54 +02:00
Adolanium e522a18ef0 Refuse path-like names in omarchy-hook-install too
The runner already rejects a slash, a bare . or .. The installer still
joined the type into hooks/<type>.d before mkdir/cp, so a name nothing
can run could still land on disk.
2026-09-07 20:59:46 +03:00
David Heinemeier HanssonandClaude Opus 5 446fbc28b1 Start 1Password at a fixed device scale factor (#10673)
1Password reads the display scale itself, the way Electron apps do, so
on a scaled monitor it comes up oversized next to every other window.
Pin it with --force-device-scale-factor=1 and let the compositor scale
it.

The app menu is covered by the packaged .desktop, which we build
ourselves in omarchy-pkgs. This is the other route in: the hotkey runs
the binary directly and never reads that file.


Claude-Session: https://claude.ai/code/session_01JB9phxP56gnP7qSidkkUJE

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 19:56:18 +02:00
Bjarne OeverliandClaude Opus 5 6ee7479879 Stop the layout widget switching a device nobody types on
Clicking the keyboard layout widget switches one device, chosen by
filtering the seat through UNTYPED_KEYBOARDS and then taking whichever
survivor sits furthest through the layout list. That filter has to
recognise every non-keyboard by name, and a laptop registers far more as
a keyboard than it lists.

On a Dell XPS 14, Hyprland reports ten keyboards and one of them is a
keyboard. The filter catches three of the other nine, leaving vendor
hotkey blocks (intel-hid-events, intel-hid-5-button-array,
dell-privacy-driver, dell-wmi-hotkeys) and two HID endpoints ahead of
at-translated-set-2-keyboard, which sorts last. Every click switches
hid-sdw:...-consumer-control instead, so the label cycles convincingly
while typing never changes. Device order is stable across polls, so it
is deterministic rather than a race, and needs no pre-existing bad state.

Switch every keyboard holding the same layout list instead, naming an
absolute index. "next" advances each device from wherever it sits, so a
seat that has already drifted apart stays drifted and merely inverts;
one index converges it in a single click, and a seat in lockstep leaves
the reading nothing to disagree about. Keyboards given their own
kb_layout hold a different list and are left alone, since an index into
this list would not mean the same layout to them.

The evdev KEY bitmap would separate these cleanly - the real keyboard
emits 167 keys, the pseudo-devices at most 19 - but hyprctl devices
reports no capability information, so the switch is taken out from
behind the name filter rather than the filter being lengthened.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 19:47:24 +02:00
David Heinemeier HanssonandClaude Opus 5 a7486beb60 Add Super + Ctrl + Alt + F to toggle a full screen desktop (#10672)
Hiding the top bar and removing the window gaps are the two things you
do to give the screen entirely to your windows, and doing both took two
hands and two hotkeys. `omarchy toggle fullscreen desktop` does them
together.

It only leaves full screen when both halves are in it, so hitting the
hotkey with just the bar hidden (or just the gaps gone) pulls the other
half into line instead of flipping the one you already set.


Claude-Session: https://claude.ai/code/session_01JB9phxP56gnP7qSidkkUJE

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 19:39:27 +02:00
Ryan Hughes 5fb2cc7967 Merge pull request #8784 from omacom/t3-theme-support
Add theme support for T3 Code
2026-09-07 13:32:50 -04:00
Ryan Hughes 0973169098 Merge pull request #7591 from omacom/mise-shim-preserve-argv0
Keep mise upgrades from pruning versions still in use
2026-09-07 12:57:43 -04:00
Ryan Hughes 2ea76f8fe2 Refresh the current palette before installing T3 Code 2026-09-07 12:57:08 -04:00
Ryan Hughes 72ec724c3c Use omarchy-cmd-present 2026-09-07 12:53:10 -04:00
Ryan Hughes 79138c0e70 Follow the active theme in T3 Code
T3 Code reads themes an environment publishes into its state directory, so
a generated t3code.json carries the palette the same way claude.json and
vscode-theme.json already do, and omarchy-theme-set-t3code republishes it
on every theme change for the app to pick up live.

The template overrides only the roles a theme actually designs -- terminal
and code surfaces, text on canvas, borders, selection -- and leaves muted
text, placeholders, and status foregrounds to T3 Code, which contrast-solves
them against whatever canvas it is given. Overriding those directly cost
readability on light themes: White fell to 1.0:1 on placeholder text.

Installing from the menu now routes through omarchy-install-ai-t3-code so a
fresh install publishes the current palette and opens wearing it.
2026-09-07 12:53:10 -04:00
Afonso Oliveira 0add43f9b7 Check dev update support before changing the system 2026-09-07 17:38:36 +01:00
Afonso Oliveira c8697407cb Keep channel transitions inside command-scoped sudo 2026-09-07 17:33:33 +01:00
Afonso Oliveira 5bc41b2585 Resolve security libraries beside canonical entrypoints 2026-09-07 17:31:54 +01:00
Afonso Oliveira f37c73fa20 Bind protected update commands to their source root 2026-09-07 17:27:04 +01:00
Afonso Oliveira 5b692c5b30 Preserve user PATH across updater relaunches 2026-09-07 14:31:07 +01:00
Afonso Oliveira aa422fcd4e Merge current Quattro while preserving command checks 2026-09-07 11:50:18 +01:00
Spencer Bull a703092631 Merge pull request #10613 from spencerbull/hermes-desktop-in-app-updates
Enable Hermes in-app updates and guard removal
2026-09-07 04:26:22 -05:00
Spencer Bull 9fb73b0051 Hide the upstream Hermes launcher in Omarchy
Keep the package launcher visible when Hermes builds register a second desktop entry. Use the existing launcher hide list so in-app updates cannot restore the duplicate menu row.
2026-09-07 04:20:13 -05:00
Spencer BullandGPT-6 Codex 1b4ac8380b Refuse Hermes removal while its files are in use
An open terminal can retain deleted SQLite WAL files across removal and reinstall, causing the updated desktop to refuse session writes. Check user processes before package/runtime removal and again after data confirmation, without killing sessions. Cover the failure with real SQLite writers in isolated fixtures.

Co-Authored-By: GPT-6 Codex (xhigh) <noreply@openai.com>
2026-09-07 03:56:51 -05:00
Spencer BullandGPT-6 Codex 827266fbed Prepare a consistent Hermes release for its first update
Align main with the packaged release and fetch connected history so the updater detects and rebuilds the first update. Guard local branch work before upstream installation, force only the admitted incomplete release pin, preserve existing command files, and explain incompatible packages. Exercise empty-directory publication races and delayed launches.

Co-Authored-By: GPT-6 Codex (xhigh) <noreply@openai.com>
2026-09-07 03:39:05 -05:00
Spencer BullandGPT-6 Codex aa3868b345 Prepare Hermes native desktop for in-app updates
Use the packaged upstream installer and matching prebuilt app to prepare the writable user installation before launch. Preserve existing builds and modified sources; record the upstream build stamp only for a matching prebuilt app.

Co-Authored-By: GPT-6 Codex (xhigh) <noreply@openai.com>
2026-09-07 02:55:06 -05:00
Ryan Hughes e989b5b3ee Merge pull request #7994 from Adolanium/mise-install-name-guard
Quote mise-install arguments and refuse unusable command names
2026-09-07 03:15:39 -04:00
Ryan Hughes e78d89ee2a Merge pull request #9618 from acrogenesis/security/plugin-auth-boundary
Restrict third-party plugin access to authentication services
2026-09-07 03:14:48 -04:00
Ryan Hughes a87d396f01 Merge pull request #9387 from omacom/security/nopasswd-expiry-fail-closed
Fail closed when passwordless sudo expiry cannot arm
2026-09-07 03:10:09 -04:00
Ryan HughesandGPT-5.6 Sol a7a4a682e5 Keep mise cleanup compatible with escaped wrappers
Co-Authored-By: GPT-5.6 Sol <noreply@openai.com>
2026-09-07 02:30:56 -04:00
Ryan HughesandGPT-5.6 Sol 572a34d3e3 Refresh cached plugin bar shell facades
Co-Authored-By: GPT-5.6 Sol <noreply@openai.com>
2026-09-07 02:28:12 -04:00
Spencer Bull 18410a0e48 Merge pull request #10460 from codemonkey76/fix-openclaw-cmd-present
Use the command helper in the AI app removers
2026-09-07 01:15:08 -05:00
Ryan Hughes f04366de79 Merge pull request #10605 from omacom/fix/kitty-system-defaults
Move Kitty defaults to the system config and restrict remote control
2026-09-07 02:02:10 -04:00
Ryan HughesandErik Melton eaed240bea Credit Erik Melton for the Kitty remote-control fix
Co-authored-by: Erik Melton <meltonaerik@gmail.com>
2026-09-07 01:59:12 -04:00
Ryan Hughes 37bb46e037 Make Kitty migration guidance easier to read 2026-09-07 01:49:49 -04:00
Ryan Hughes 04a9cda98d Explain how to opt out of Kitty theming 2026-09-07 01:46:55 -04:00
Ryan Hughes 4cb9c75a96 Move Kitty defaults into the system config 2026-09-07 01:24:12 -04:00
Ryan HughesandGPT-5.6-Sol 23aeb4c670 Preserve clone summons in replacement bars
Co-Authored-By: GPT-5.6-Sol <noreply@openai.com>
2026-09-06 22:18:52 -04:00
Ryan HughesandGPT-5.6-Sol 3292c19fef Preserve built-in clone integrations
Co-Authored-By: GPT-5.6-Sol <noreply@openai.com>
2026-09-06 22:04:48 -04:00
Ryan Hughes d7c783ecf5 Merge pull request #10579 from omacom/fix/locate-service-defaults
Replace locate configuration script with packaged service defaults
2026-09-06 21:52:21 -04:00
Ryan Hughes 04b0a47c9b Configure locate through the packaged service
Reported-by: uiop / @wasdhjklxyz <uiop@wasdhjkl.xyz>
2026-09-06 21:42:31 -04:00
Ryan HughesandGPT-5.6-Sol f2b4731bb3 Prevent authentication service capability downgrades
Co-Authored-By: GPT-5.6-Sol <noreply@openai.com>
2026-09-06 20:20:30 -04:00
Ryan Hughes c82a0837b0 Merge pull request #10425 from acrogenesis/security/root-owned-sleep-hooks
Harden ownership of installed sleep hooks
2026-09-06 19:36:07 -04:00
Ryan Hughes 0c20243214 Bound GPU mode requests during sleep 2026-09-06 19:34:36 -04:00
Afonso Oliveira 35551b04da Disable the Yay sudo loop with its supported option 2026-09-07 00:24:43 +01:00
Afonso Oliveira 177d5729c9 Wait for notifications when restarting the shell 2026-09-07 00:08:07 +01:00
Afonso Oliveira d41ca88f76 Wait for the actual Stay Awake launcher 2026-09-07 00:03:53 +01:00
Afonso Oliveira 978dffaa3e Use required gum directly in AI removal prompts 2026-09-06 23:32:28 +01:00
Afonso Oliveira a9e9954e3e Clarify unattended updates still require sudo authorization 2026-09-06 22:59:08 +01:00
Afonso Oliveira 1f8f5bacac Integrate current quattro before security review 2026-09-06 22:56:04 +01:00
Afonso Oliveira f2c3925431 Revoke before session cleanup and protect standalone inhibition 2026-09-06 22:49:43 +01:00
Afonso Oliveira c52381f092 Keep update regressions isolated from host authentication 2026-09-06 22:32:34 +01:00
Ryan Hughes 3f91f06a62 Merge pull request #10225 from mdisec/security/harden-quattro-fprintd-path
Harden lock authentication command lookup
2026-09-06 17:26:28 -04:00
Afonso Oliveira 35b318ed09 Complete command-scoped authentication across update phases 2026-09-06 22:26:06 +01:00
Afonso Oliveira 7f9a401bb1 Merge commit 'b71dcad96e9d0b2962b7d225828a5cb6000ad720' into codex/review-9469-20260906 2026-09-06 22:11:06 +01:00
acrogenesis feb0557e1d Harden ownership of installed sleep hooks
Publish privileged sleep-hook and hybrid-GPU files through root-owned replacement inodes, repair unsafe existing copies while preserving administrator customizations, and keep partial hibernation setup retryable.

Reported-by: Roger Piñol <rogerpicar@gmail.com>
2026-09-06 14:36:27 -06:00
David Heinemeier Hansson 97a86af116 Nix ideas 2026-09-06 22:29:30 +02:00
0d223fe820 Add Muse Code as a default coding agent (#9915)
* Add Muse Code as a default coding agent

Meta ships Muse Code only as a binary, so it installs from the AUR
(muse-code-bin) instead of mise, and a fresh install runs the muse login
browser flow in the install terminal before the agent opens.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148qKzr366p2Ubu2igCLvPg

* Refine Muse Code menu and prompt forwarding

* Install Muse Code from OPR

* Install Muse Code through mise's HTTP backend

* Preinstall the Muse mise stub

* Use the shared Muse installation flow

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
2026-09-06 22:29:16 +02:00
Ryan Hughes f4a462ef20 Merge pull request #10537 from omacom/add-hyprland-rebind
Add o.rebind for replacing Hyprland keybindings
2026-09-06 16:04:51 -04:00
Ryan Hughes 1d466c4003 Add o.rebind for replacing Hyprland keybindings 2026-09-06 16:01:50 -04:00
steelcityapplianceandClaude Fable 5.1 a9eaf7978e Point the shipped agent skills at omacom/omarchy (#10532)
The repository moved from basecamp/omarchy to omacom/omarchy, but the skills
Omarchy installs into ~/.claude/skills, ~/.codex/skills and ~/.agents/skills
still name the old owner. Most gh calls follow the rename, so nothing looks
wrong in normal use. Search does not: `gh issue list --repo basecamp/omarchy
--search ...` exits 0 with no output, and the raw search API answers
"Validation Failed". An agent following contributing.md or reporting.md runs
its duplicate check before filing, sees zero matches, and files a duplicate.

Replace the owner in contributing.md, reporting.md and the issue-template
contact link, which is the same stale name on the contributor path. The git
clone and release URLs in bin/ and manual/ are left alone: GitHub redirects
those, and channel-test.sh asserts the exact clone URL, so changing them is a
separate change.

Fixes #10118

Written by Claude Fable 5.1 via Claude Code, reviewed by Marc Morriss

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:29:36 +02:00
a62e34ea8e Add Cursor CLI as a coding agent choice (#10465)
* Add Cursor CLI as a coding agent choice

* Launch Cursor CLI through its agent subcommand with --trust

Cursor CLI still dispatches a one-word prompt that names one of its
subcommands (update, login, help) even after a bare --, so name the agent
subcommand outright and pass the prompt behind -- there, where it also
keeps a prompt starting with a dash from being read as an option. --yolo
only auto-allows commands; the workspace trust dialog is skipped only by
--trust, and a launcher that must not stop to ask needs both.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Leave an official Cursor CLI install alone

Cursor's own installer symlinks ~/.local/bin/cursor-agent, the same path the
mise wrapper takes. The migration now installs the wrapper only when no
cursor-agent command exists, and Remove Preinstalls deletes the path only
when it holds the wrapper omarchy-mise-install wrote, the way the Hermes
wrapper is handled. Selecting the agent treats an executable at that path
other than the wrapper as the user's own install and skips mise, since the
mise shims precede ~/.local/bin on PATH and a mise copy would only shadow
it. The wrapper resolves through mise's registry, which lists cursor-agent
from 2026.8.15 on.

The tests write a real cursor-agent stub before Remove Preinstalls runs,
cover the preinstall opt-out for the new migration, and check that a
symlinked official install survives removal and selection alike while a
dead file at the same path still installs.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Give Cursor its brand mark and one name in the menu

Add Cursor's mark to the Omarchy icon font as U+E90D and point the agent
entry and both editor entries at it, so one brand is drawn one way across
the menu. Label the agent entry "Cursor CLI", the name the command and the
manual already use, and spell it the same in the migration and the tests.
Append the manual row after the others at the standard width.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Keep an official Cursor CLI install through a user re-provision

User setup writes every mise wrapper unconditionally, which is fine on a
fresh install but replaces the symlink Cursor's own installer leaves at the
same path when omarchy-provision-user runs again with --force. Guard that
one line the way the migration does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: John Cavanaugh <59479+cavanaug@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 17:07:40 +02:00
Erik Melton a805ef990e Fix OCaml sudo test portability 2026-09-06 15:46:59 +02:00
41b6cc6965 Add native video wallpaper support (#6792)
* Add native video wallpaper support

* Pause video wallpapers while a fullscreen app is focused

* Sample one frame when a video background sets the bar text colour

A video wallpaper made the transparent bar's colour sampling decode the entire file. ImageMagick's video delegate runs ffmpeg with no frame limit, so a twenty-second 1080p background took 11.3s of CPU where one frame takes 0.14s, and it did that on every theme change.

The result was unusable anyway: a multi-frame input emits one value per frame, which the single-value match then rejected, so transparent bars silently fell back to the plain text colour on every video wallpaper. Selecting frame zero fixes the cost and the colour together, and fixes animated GIFs, which had the same bug.

Co-Authored-By: Codex XHigh <noreply@anthropic.com>

* Load wallpaper video lazily, and without an audio output

Three costs the still-image path should never have paid.

BackgroundMedia imported QtMultimedia at file scope and was instantiated on every output, so the module and its audio dependency closure mapped into every shell process whether or not a video was ever shown — measured at +2.72 MiB RSS. Moving the element into its own file behind a Loader that takes a URL defers the whole import: an inactive loader maps none of it, an active one maps all 25 libraries. An inline Component cannot defer that, because the type has to resolve when the file compiles.

Qt's Video convenience type always builds an AudioOutput, and `muted` only aliases that sink's volume, so every monitor decoded an audio stream it would never play and opened an audio client for it. A bare MediaPlayer with no audio output spawns no QFFmpeg::AudioR, QAudioContext or PWDevMon thread, and plays files with no audio track just the same.

The shared image also turned mipmapping on, which the desktop background never had. A full mip chain is about a third more texture memory — 10.6 MiB extra at 4K, per output — for a wallpaper drawn at its own size.

Co-Authored-By: Codex XHigh <noreply@anthropic.com>

* Stop wallpaper playback while the session is locked or screensaved

Playback stopped only for a focused fullscreen window. Locking the session did not stop it, and the lock screen starts a player of its own, so an N-monitor desktop reached 2N decode pipelines the moment it locked — and stayed there, because a display blanked for idle stops being presented but does not stop Qt's FFmpeg engine, which drives its own clock. A laptop locked with the lid shut decoded video until the battery ran out.

The lock and idle services already know both states, so the background service takes the shell reference the loader offers it and reads them. Looking a service up by id needs the registry to be reactive, or a background that loads before the lock service would bind to null and stay there.

Co-Authored-By: Codex XHigh <noreply@anthropic.com>

* Fan out video thumbnails narrower than single-threaded image jobs

The generator fans out one job per core, which was bounded because VIPS_CONCURRENCY=1 made each of them single-threaded. ffmpegthumbnailer leaves FFmpeg's automatic decoder threading on, so a folder of uncached videos put a codec thread pool on every core at once. Queueing video work separately keeps the still-image path at full width and gives the video path a quarter of it.

* Recognize a named video file as a theme preview

The backgrounds fallback beside it already picks videos, so a theme shipping preview.mp4 was the one case that still went unseen.

* Document video backgrounds in the manual

The manual described backgrounds as images only. Worth saying plainly that a video wallpaper costs far more power than a still one and that each monitor decodes its own copy, since neither is visible from the picker.

* Stop the lock screen's own playback once the displays go dark

Pausing the desktop wallpaper on lock only moved the cost. The lock screen builds a player per monitor of its own, so locking an N-monitor session went from N decoders to N rather than to none — and the lock service blanks the displays five seconds later without touching them, which is where a lock spends nearly all of its time. A laptop locked and shut still decoded video into a dark panel.

The service already owns both transitions, so it records whether the displays are dark and the lock view stops playback while they are. The manual said playback stops while the screen is locked, which was the same overstatement; it now says once a locked screen has gone dark.

Co-Authored-By: Codex XHigh <noreply@anthropic.com>

* Keep videos out of the lazy thumbnail path

A lazy row stands in with the media file itself until its thumbnail exists, and the picker draws that with an Image — which shows a picture and shows nothing for a video, with no reload once the real thumbnail lands. So the first open after discovering an uncached video showed a blank tile.

The same branch also spawns one generator per file immediately, before either queue is reached, and the theme switcher always asks for lazy thumbnails. That put the narrower video fan out on the one path that never used it: forty uncached previews meant forty ffmpegthumbnailer processes. Sending videos to the queue instead fixes the blank tile and puts them back under the cap.

Co-Authored-By: Codex XHigh <noreply@anthropic.com>

* Rebuild the theme preview cache after teaching it about video

Preview discovery changed what it recognizes, but its cache keys on theme directory mtimes alone. A theme that already shipped a video preview would keep whatever the old rules cached until something happened to touch the directory. Bumping the version rebuilds it once.

Co-Authored-By: Codex XHigh <noreply@anthropic.com>

* Drop an activeAudioTrack setting that never took effect

Qt's FFmpeg backend ignores setActiveTrack while no source is open, and the literal binding is not reapplied once the media loads and the tracks become known, so the line did nothing. What actually keeps the audio decoder and its client from ever being built is the absent audio output, which a file carrying an audio track confirms on its own: no QFFmpeg::AudioR, QAudioContext or PWDevMon thread appears without it.

Co-Authored-By: Codex XHigh <noreply@anthropic.com>

* Give up the blank state when a display comes back

The lock screen stops its wallpaper while the displays are dark, but it was tracking the blanking it asked for rather than the panels themselves. Opening a docked lid turns the internal panel back on without going through runWake, and so does a resume, which left a visible lock wallpaper frozen on one frame until the next keypress. A frozen wallpaper someone is looking at is worse than the decoding it saves, so a screen change gives the state up.

Co-Authored-By: Codex XHigh <noreply@anthropic.com>

* Time bound the video thumbnail generator

Routing videos through the queue means they are generated before the picker opens rather than behind it, which turned an unreadable or stalled file into a picker that never opens. ffmpegthumbnailer had no bound of its own and the drain waits for every job. A generator that gives up is already handled: the run reports failure, the partial file is removed, and the row drops out of the list.

Co-Authored-By: Codex XHigh <noreply@anthropic.com>

* Pause only the output a fullscreen window covers

The fullscreen test was global, so a game on one monitor stopped the wallpaper on every other one — including the ones still in plain view. That is the failure the lock work was careful to avoid, and it made the manual's claim that playback stops when nothing can see it untrue for the commonest multi-monitor case. A lock or a screensaver does cover every output, so those stay a single decision; fullscreen is now matched against the focused monitor, the way the bar already routes by output.

Co-Authored-By: Codex XHigh <noreply@anthropic.com>

* Kill a video thumbnail generator that ignores the timeout

Plain timeout sends TERM and then waits for a process that may never take it, which leaves the bound it was added for unenforced on exactly the stuck files it was meant to catch.

Co-Authored-By: Codex XHigh <noreply@anthropic.com>

* Pause video wallpapers in battery power-saver

* Fix paused video wallpaper source priming

* Skip snapshots for video background transitions

(cherry picked from commit 6f759538bfa76c2da03634e98ebfc2ebf63ec68e)

* Generate thumbnails for direct-scan videos

(cherry picked from commit 10fcca018a865dca311fb6863e8c8b0057291223)

* Remember a video the thumbnail converter rejected

A permanently unreadable video cost ten seconds of generator time on every
picker open before its row dropped, because nothing recorded the failure.
Both the menu image generator and the direct picker scan now leave a marker
beside the missing thumbnail, keyed like the thumbnail on the file's size
and mtime, so a repaired file starts clean. A timeout is left to retry, as
it may only have been a busy machine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Follow the panels' real DPMS state under a locked video wallpaper

The lock screen stopped video playback when it asked for the displays to
blank, and resumed on input, but never checked what the panels did. A blank
that failed left a lit panel on one frozen frame, and a resume that turned
the same outputs back on played nothing until the next keypress.

Quickshell exposes no DPMS signal, so while a video is the locked wallpaper
the lock polls hyprctl and decides per surface from the answer. A wake or
blank request drops the last answer so its optimistic state applies until
the next poll confirms it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Pause a video wallpaper for the fullscreen window that covers it

The fullscreen check read the globally active window and the focused
monitor, so it only knew about the window that had focus. A fullscreen
window left on one monitor while focus moved to another resumed the
wallpaper decoding behind it, and with fullscreen windows on two outputs
only the focused one paused.

Each output's visible workspace reports whether a fullscreen window covers
it, and Quickshell flips that on the compositor's fullscreen event, so each
panel now decides from its own monitor's active workspace instead.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Reopen a video wallpaper a theme switch replaced behind its path

Two themes that both ship backgrounds/wallpaper.mp4 leave the current
background at the same path after a switch, so the displayed path never
changed and the running player kept decoding the old file from its open
descriptor. Stills go through the snapshot transition and survive this;
a video switch is instant and did not.

A forced switch onto the path already on show now bumps a reload counter,
and BackgroundMedia rebuilds the video player for it. A cache-busting query
is not an option there, since FFmpeg reads it as part of the filename.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Keep picker rows uncached while a rejected video is left out

Skipping a video with a failure marker let the picker cache its rows
without it, and cached rows are trusted on the directory's mtime alone.
A file repaired in place never touches that, so the marker's fresh key
was never consulted and the video stayed missing.

The generator now hands the marker back to the row loop, which drops the
row and leaves the rows uncached, so each open re-stats the file and a
repaired one is converted again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Hand each background loader only its own kind of file

BackgroundMedia fed one URL to both the still loader and the video player.
On a switch from image to video the Image was handed the video's URL in
the moment before its loader unloaded, so Qt tried to decode the mp4 as a
picture and logged an unsupported format on every such switch; the reverse
handed the player a still to demux.

The still URL is now empty whenever the path is a video and the video URL
empty whenever it is a still, so a switch changes only the loader that
stays.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Stop a video wallpaper before tearing its player down

Switching from a video to a still destroys the BackgroundVideo item while
its player is mid-read, which FFmpeg reports as a failed open in the shell
journal on every such switch. Stopping the player on destruction lets the
demuxer wind down first, and the switch is quiet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Play a video wallpaper's sound track from the first monitor

Video wallpapers were always silent: the player was built without an
audio output, since a muted output still decodes the track and opens an
audio client on every monitor. A video with music should be able to play
it.

The player now builds its AudioOutput only once the media reports a sound
track, so a silent file still opens no audio client, and only the first
screen's panel opts in, so a multi-monitor desktop does not layer copies of
the track. The output is muted while a paused player primes its first
frame, and the lock screen stays silent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Keep a departing video player off the still's file

The switch away from a video still logged a cancelled open, and stopping
the player on destruction only hid it: stopping reports the media as
loaded, which the loaded handler answered by playing again. The real cause
was one evaluation pass. Both URLs derived from the `video` flag, which is
itself bound to the path, and QML updates the two in no fixed order, so
the video URL could evaluate against the stale flag and hand the player
the still for a moment. Its destructor then cancelled that open.

Each URL now tests the path directly, the source binding only applies
while the path is a video and restores nothing when it stops, and the
destruction stop goes away with the hazard it introduced.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Pin the audio wiring in the test and name the output in the manual

The audio assertion passed with the BackgroundMedia forwarding binding
removed, which would have left every wallpaper silent, and did not pin the
silent default or the first-screen selection. It covers all three now.

The manual said the sound track plays "from your first monitor", which
reads as routing to that monitor's audio device. It is the first monitor's
wallpaper that plays, through the default output.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Omabot <omabot@omarchy.org>
Co-authored-by: Codex XHigh <noreply@anthropic.com>
Co-authored-by: z8 <yam@kernelius.com>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
2026-09-06 15:12:07 +02:00
omarchybotandClaude Opus 5 a00be8fa16 Remove the subuid and subgid backups during the factory scrub
userdel rewrites /etc/subuid and /etc/subgid, and like every shadow-utils database write it leaves the previous contents behind in a dash-suffixed backup. The scrub removed four of the six backups those tools produce, so the retained @factory baseline still named the previous owner in /etc/subuid- and /etc/subgid- along with their subordinate ID range.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 05:37:10 -07:00
Shane PoppletonandClaude Opus 5 f917ffe37d Use the command helper in the AI app removers
test/shell.d/bin-style-test.sh has been failing on quattro: the OpenClaw
remover checks for gum with a raw `command -v`, which the style rule
reserves for the helpers themselves.

The Hermes remover does the same thing. It was invisible because the test
fails on the first offending file, so fixing OpenClaw alone just moves the
failure along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013p9Qh6dX4FuwBJAWhNPVbn
2026-09-06 20:26:55 +10:00
adcc96a782 Install libfprint-git for every fingerprint reader (#10442)
* Use updated libfprint-git for fingerprint setup on edge

* Pick the fingerprint driver from the reader, not the release channel

The channel gate blocked every edge and dev user until the newer
libfprint-git pin is published, misrouted dev checkouts on the stable
mirror, and left the stock-libfprint migration reverting the driver on
accounts without its marker. Key both the setup and the migration on
omarchy-hw-fingerprint-git, a USB ID table of readers stock libfprint
cannot drive, so the git snapshot only goes where it is needed on any
channel. Qualify the package with the omarchy repo, and skip pacman
entirely when the packages are already current so a rerun cannot become
a partial upgrade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Install libfprint-git for every fingerprint reader

Stock libfprint lags upstream on new readers, and gating the git
snapshot per reader or per channel only added machinery to keep in sync
with the package repo. Install libfprint-git unconditionally instead:
the omarchy-pkgs pin is the single place a new reader gets enabled. The
migration that swapped it back to stock goes away with the policy it
enforced; late updaters keep the driver they have and pick up the new
pin as a normal package upgrade.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: powderluv <powderluv@powderluv.org>
Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 11:58:30 +02:00
Spencer Bull 988da12cb0 Merge pull request #10381 from spencerbull/hermes-system-theme
Make Hermes follow the Omarchy theme as a skin
2026-09-06 00:39:19 -05:00
Spencer BullandCodex XHigh 8569d1cadc Harden the Hermes skin hand-over
Hermes' YAML reader breaks lines on carriage return, NEL and the Unicode line and paragraph separators, and stops at NUL, none of which grep treats as a line end, so a comment line carrying one could put a root-level key such as banner_logo past the validator and into Rich markup on Hermes' terminal surfaces. The lines grep accepted also did not add up to the YAML Hermes needs: a colour before colors:, a second colors:, or a key over YAML's simple-key limit all passed and loaded as no palette at all, which Hermes shows as its default. The validator now counts every byte outside printable ASCII first, then walks the file in order: the name, at most one plain description, colors:, and only #rrggbb colour lines after it.

omarchy-theme-set releases its lock before the hooks run, so the rendered skin can change under this one between the check and the copy. The check is made on a private copy and that copy is what gets published, both on the first pass and on the republish a minute after activation, which used to copy whatever the theme had become by then, unchecked.

A theme switch reads the config of the profile named in active_profile, which is the one Hermes reads, and a profile exists to Hermes once its directory does, with or without a config; it ends early only for a config plainly naming another skin, since only the default is ever replaced, and leaves anything Hermes might read as the default for Hermes to answer. Hermes is run by the path the readiness probe vets, ~/.local/bin/hermes, bounded the way the probe bounds it; an answer that did not come is not taken for the default, and a write Hermes refuses is reported rather than failed, being cosmetic.

A profile that cannot take the skin no longer costs the others or the activation; a directory at the skin's path is an error rather than a place mv puts the temp file; a temp file the copy could not fill is removed. Remove stops the unit the installer left waiting, so a removal within the waiter's half hour does not hand the theme to a Hermes installed some other way or recreate the skin under a home the user asked to delete. The migration no longer swallows the hook's exit: what is not ready or refused is reported and done with inside the hook, so only Omarchy's own failures return, and those keep the migration pending as the guide requires.

Comments are cut to what the code cannot say; the reasoning is here.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-05 23:28:46 -05:00
Spencer Bull 6baae0f556 Make Hermes follow the Omarchy theme as a skin
Hermes Desktop installed under Install > AI kept its own palette while every other agent app retinted with the theme. Hermes' skin is its one theme unit for the desktop app, the TUI and the CLI, and its gateway watches the active skin file and broadcasts changes to every surface, so Omarchy publishes a skin named omarchy from a template on every theme switch and nothing Omarchy-specific goes upstream.

Activation goes through hermes config set, which writes the active profile's config and touches the skin so a running gateway repaints at once, and it only replaces Hermes' default skin so a choice made in Hermes stays. A theme switch runs that activation too when the desktop package is present and Hermes is still on its default, so a hand-over the installer missed is finished by the next switch; once the config names the skin a switch never starts Hermes. The desktop adopts a skin from a change broadcast rather than from the config it finds at connect time, and its first launch builds the runtime over minutes, so the installer starts --wait as a transient user unit that outlives the install terminal, activates once the runtime marker appears, republishes after the gateway is up, and reports to the journal. A migration hands the skin to existing Hermes Desktop installs through --activate, which also renders the skin for a theme applied before the template existed.

The generated file is validated before it is published, because Hermes parses it as YAML: only the name, a plain description and #rrggbb colours pass, so an unresolved palette key or a cloned theme's own hermes.yaml leaves the previous skin in place.

🤖 Generated by Fable 5.1 in Claude Code. Reviewed by Fable 5.1 code-review at high.
2026-09-05 23:05:10 -05:00
Spencer Bull f1b065c292 Merge pull request #9625 from spencerbull/t3code/add-perplexity-ai-app
Add the Perplexity desktop app to Install > AI
2026-09-05 21:39:10 -05:00
Spencer Bull 959e49dc52 Merge pull request #10271 from spencerbull/hermes-remove-ask-user-data
Ask, default no, before Remove Hermes deletes the user's data
2026-09-05 20:00:19 -05:00
Spencer Bull a1095af075 Ask about the user's data whenever it exists, not only behind the bootstrap marker 2026-09-05 18:30:49 -05:00
Akshar Patel a75924aca4 Erase old password hashes during factory reset 2026-09-05 17:57:11 -04:00
eea678b480 Ask, default no, before Remove Perplexity deletes the user's data
Perplexity 26.9.1 keeps the secret vault and device identity in
~/.local/state/perplexity and its runtime downloads in
~/.local/share/perplexity-rpc-server; the remover knew neither path, so
it deleted the logins unconditionally while leaving the credentials
behind. Now the runtime and Electron caches always go, and the logins,
vault, and launcher flags go only on an explicit yes at a terminal,
default no, the same choice Remove OpenClaw puts in front of the user.
The prompt needs stderr on the terminal too: gum draws it there, so a
redirected stderr means keeping the data, not blocking on a question
nobody can see.

Co-Authored-By: Codex XHigh <noreply@openai.com>
Co-Authored-By: Sonnet 5 <noreply@anthropic.com>
2026-09-05 16:49:53 -05:00
David Heinemeier Hansson 36e56f4fb4 Drop tests for shipped one-shot migrations (#10318)
Keep the migrations themselves for late-updaters. Drop the tests that only
exercised frozen file rewrites from 4.0.0, and keep live invariants,
privileged repairs, and the migrator.
2026-09-05 20:05:04 +02:00
fa60a8b9bf Add the Perplexity desktop app to Install > AI
Follows the T3 Code / Grok Bot flow: the Install > AI entry runs
omarchy-install-and-launch, so picking it installs the perplexity
package on demand and launches the app when the install finishes.
Remove > AI drops the package along with the app's own config, flags
file, and rpc-server runtime cache, keeping the perplexity-* caches
that belong to Perplexity's other products. Like the Hermes remover,
it sets -u so an unset HOME is a refusal rather than rm -rf paths
rooted at /.

The menu mark is a new U+E90B glyph in the Omarchy icon font, so it
reaches desktops through the next omarchy-settings release.

Co-Authored-By: Fable 5 <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-09-05 09:48:03 -05:00
Spencer Bull eb56446c42 Merge pull request #10246 from omacom/add-openclaw-ai-app
Add OpenClaw as a desktop app and a coding agent
2026-09-05 09:44:22 -05:00
Spencer Bull 5345673988 Add OpenClaw to Install > AI as a web app on its own gateway
OpenClaw's desktop experience on Linux is its Control UI, served by the
gateway the openclaw package runs, so the Install > AI entry installs
the package and a web app launcher that routes through the new
omarchy-launch-openclaw: first launch hands off to OpenClaw's own
onboarding wizard, later launches start the gateway when needed and open
the dashboard's single-use browser handoff URL as an app window.
Remove > AI tears the gateway service down through OpenClaw's own
gateway uninstall (falling back to systemctl by hand), aborts rather
than dropping the package under a gateway that will not stop, and keeps
the user's agent in ~/.openclaw.

OpenClaw also joins Setup > Defaults > Agent through the same
agent_installer seam Hermes carries: its CLI is the pacman package
rather than a mise tool, so omarchy-install-openclaw-cli answers
--check/--now with pacman, and omarchy-agent runs `openclaw chat`,
seeding prompts through --message.

The menu mark is a new U+E90C glyph traced from the package's lobster
favicon; E90B stays free for the Perplexity mark still in flight on its
own branch.
The launcher recovers the gateway through `openclaw gateway install --force`
(unit not enabled: missing, or an install that died after writing it) or
`openclaw gateway start` (enabled but stopped), never `openclaw dashboard
--yes`: as of OpenClaw 2026.9.1 that defers to "the owning supervisor" in
both cases, and once the gateway is up it copies a one-time browser pairing
URL into the clipboard. The dashboard probe is bounded so an app-grid launch
cannot hang without a terminal to interrupt it. Removal treats only
systemd's own "inactive"/"failed" as a stopped gateway, so an unreachable
user manager aborts instead of dropping the package under a live process.
All of it verified against a real 2026.9.1 install.
Removal also takes down the node-host unit if OpenClaw ever installed one, and
asks (default no, only on a terminal) whether ~/.openclaw should go too, with
its size: the chats and credentials live there next to hundreds of megabytes
of plugin runtimes and cache OpenClaw downloads for itself.
Onboarding goes through omarchy-openclaw-onboard rather than bare `openclaw
onboard`: as of 2026.9.1 the bare command is the guided flow, which ends by
running a foreground gateway and handing off to a browser tab without
returning, so the install script never reached the app launch and no service
was installed. The helper runs the classic wizard (--flow quickstart
--install-daemon --skip-ui) as a background job that keeps the terminal as its
stdin, so its prompts render and take input as upstream draws them, and stops
it once the gateway answers: upstream leaves the wizard running after its
outro (only the TUI branch exits, and the model sign-in holds a socket open).
Every quickstart prompt precedes the service install, so that point is safe.
A gateway that never comes up after this run applies setup ends the wait as a
failure instead
of hanging, an already-running OpenClaw is left alone rather than mistaken for
this run's success, a gateway answering on the port is only this run's once its
process is the unit's own MainPID (an orphan from an
earlier run) is not mistaken for the service this run installs, and a signal at
the helper takes the wizard down with it.
2026-09-05 09:18:27 -05:00
David Heinemeier Hansson 110cb8f5b4 Add original vi as a standard terminal editor (#10307)
* Add vi as a standard terminal editor

* Use the original vi package
2026-09-05 16:16:23 +02:00
David Heinemeier HanssonandClaude Fable 5.1 e8e92c5092 Register the Chromium native messaging hosts for Brave Origin (#10292)
Brave Origin keeps its profile under ~/.config/BraveSoftware/Brave-Origin
rather than Brave-Browser, so the Copy URL and Download Video installers
never wrote their host manifests there. The extensions loaded but the
shortcuts did nothing. Add the Origin profile roots and rerun both
installers through a migration.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-05 10:17:35 +02:00
Akiko 7ea464988e Remove link to Omarchy 3 manual 2026-09-05 08:04:22 +02:00
Spencer Bull 8f15549380 Ask, default no, before Remove Hermes deletes the user's data 2026-09-04 21:50:41 -05:00
Mehmet Ince 70047956fa Harden lock authentication command lookup
Keep the target user's local bin on user-scoped upgrade paths while giving privileged lock and firewall helpers only root-owned search directories. Pin the lock helper's root PATH and fprintd-list executable, with regression coverage for each defense independently.

Reported-by: Rooke Poole <rookpool97@gmail.com>
2026-09-04 19:43:03 +01:00
Erik Melton 73a09695f9 Strengthen OCaml sudo security test 2026-09-04 20:34:13 +02:00
Erik Melton 1b7301916b Secure OCaml removal sudo boundary 2026-09-04 20:18:48 +02:00
David Heinemeier HanssonandClaude Fable 5 493067741e Give foot its own touchpad scroll factor (#9793)
foot only applies scrollback.multiplier to discrete wheel clicks, so
precise touchpad scrolling ignores it and crawls at the group's 1.5
factor. Split foot out at 2.0 to match how the other terminals feel.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-04 17:59:32 +02:00
Macho0x 977e0fcdc2 Make VS Code-family editors reload the Omarchy theme when it changes 2026-09-04 14:32:41 +07:00
Ivan Skodje 6d892d4315 Give invisible hover and active states a visible value 2026-09-03 16:26:52 +02:00
Ivan Skodje eb8fcdd0b9 Give the current line a visible highlight in every theme 2026-09-03 12:49:50 +02:00
Ivan Skodje 03e697b364 Make status bar badges readable at rest and on hover 2026-09-03 12:43:33 +02:00
rattatuiandClaude Opus 4.8 21e7975352 Harden the polkit migration: exact-layout match, retry on failure, and tests
Address review feedback on the polkit faillock migration:

- Match only the exact stack the setup commands wrote and replace just the bare
  pam_unix lines, preserving comments and the hardware-auth lines. An
  administrator-authored polkit-1 carrying any other directive is left
  untouched, rather than rebuilt from scratch.

- Also repair the markerless post-removal layout: both remove commands strip
  their own marker lines but leave the bare pam_unix stack behind, so keying on
  a hardware-auth marker skipped those machines permanently.

- Exit non-zero when the backup cannot be created or the rewrite cannot be
  verified (after restoring). omarchy-migrate runs under set -e and records a
  migration complete unconditionally after it returns, so a failed repair must
  fail loudly to be retried instead of silently marked done.

- Add test/shell.d/security-polkit-migration-test.sh covering the fingerprint,
  FIDO2, combined, and markerless layouts, comment preservation, idempotence, an
  untouched administrator stack, and the refused-sudo and unverifiable-write
  failure paths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-03 09:18:18 +03:00
Spencer Bull f99d33a8dd Merge pull request #9663 from spencerbull/fix/hermes-prompt-probe
Fix the Hermes CLI readiness probe, and tear the CLI down on uninstall
2026-09-02 15:21:45 -05:00
rattatuiandClaude Opus 4.8 8a13eac872 Defer to system-auth in the polkit stack written by fingerprint/FIDO2 setup
The fingerprint and FIDO2 setup commands create /etc/pam.d/polkit-1 from
scratch on Arch, where the polkit package ships its stack in
/usr/lib/pam.d/polkit-1 and /etc/pam.d/polkit-1 does not exist. The
hand-rolled stack listed pam_unix directly instead of including system-auth,
which dropped pam_faillock from the polkit path: polkit prompts had no
brute-force lockout, their failures were not recorded, and they did not count
toward the lockout protecting login and sudo. Defer to system-auth, matching
the vendor file and the sudo stack, keeping the clamshell gate and the
pam_fprintd / pam_u2f sufficient lines in front.

Add a migration to repair installs the old setup already configured, since the
forward fix does not rewrite an existing polkit-1. It acts only on an
Omarchy-created polkit-1 that lacks the system-auth include and carries a
hardware-auth marker, preserves the configured auth lines, backs up the
original, and is idempotent.

Add a test asserting the stack each setup creates defers to system-auth; the
created polkit content was previously untested.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-02 21:41:53 +03:00
acrogenesis 203e1639c3 Revoke stale plugin facade capabilities
Reported-by: Roger Piñol <rogerpicar@gmail.com>
2026-09-02 12:11:15 -06:00
acrogenesis 094c913065 Tighten replacement bar plugin boundaries
Reported-by: Roger Piñol <rogerpicar@gmail.com>
2026-09-02 11:03:03 -06:00
acrogenesis 19985314c2 Merge quattro into plugin auth boundary 2026-09-02 10:12:15 -06:00
Spencer BullandClaude Opus 5 21470fd1ea Say how to finish a failed teardown, and record the probe's real history
A failed --remove told the user to run it again, but that advice could
never work: rm -f has usually taken the marked stub by the time the
failure is judged, and a rerun that finds nothing it owns succeeds
without touching the mise environment it was asked to finish removing.
Spell out the three commands that complete the job by hand instead.

Also correct the story the probe comment told: chat never lost
--oneshot in v0.20 -- no released Hermes defined it there. It lived at
the top level until v0.21 added chat's own, so the old probe was keyed
to a flag no release ever carried under chat, and every install read
as not ready. Recorded straight so a future hermes-desktop bump to
v0.21+, which would make the old probe pass on the desktop path alone,
cannot read as the fix.

Findings from omarchybot's review (Opus 5, with Codex at xhigh).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-02 09:40:44 -05:00
d3d23fddde Honor keepLoaded for services during plugin hot-reload (#9485)
* Honor keepLoaded for services during plugin hot-reload

Plugin reload destroyed every service, including omarchy.lock, which drops the ext-session-lock client while Hyprland still holds the lock and surfaces the crashed-lockscreen fallback.

* Prove keepLoaded service survival with a fixture service

A fresh lock service also reports an empty lastEventAt, so comparing it
across the rescan passed whether or not the instance survived. A fixture
keepLoaded service whose in-memory marker is set before the rescan and
read back after can only pass when the same instance is still mounted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Drop kept services whose plugin no longer declares a service

The _syncServices cleanup only asked whether the plugin was still
installed and enabled, so a kept service whose plugin dropped its
service kind or entry point kept running as a zombie until shell
restart. Apply the same eligibility checks used at creation, and hand
kept instances the refreshed manifest after a rescan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Cover omarchy.media in keepLoaded expectations; note kept services reload on restart

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: David Heinemeier Hansson <david@hey.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-09-02 12:33:44 +02:00
46cfc4ada5 Judge the CLI teardown by what is left, and match flags as definitions
Two review follow-ups. The probe counted any mention of --tui/--query in
the help as support -- Hermes already writes "With --tui:" into --dev's
description, so a release that dropped the option while keeping the
prose would still read as ready. A flag now counts only when followed by
a shape argparse prints after a definition: the usage bracket, the gap
before same-line help text, an uppercase metavar, or the line end. Not
probed by parsing a real invocation on purpose -- a release that ignores
unknown arguments would turn the probe into a live session.

And the teardown trusted its commands: a stub rm that failed aborted
Remove Hermes under set -e before any ~/.hermes handling, while mise
failures vanished into || true. --remove now attempts every step, then
judges by what is left -- the marked stub still present, or mise still
resolving the tool -- and Remove Hermes tolerates the failure until the
runtime is handled, then carries it in its exit code.

Findings from the same codex review at xhigh, verified and proven by
mutation before landing.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-02 01:01:13 -05:00
c462aad9ee Prove ownership before --remove touches mise
The teardown removed the Omarchy tool spec from mise unconditionally,
so removing Hermes Desktop could destroy a mise environment the user
had built against the same spec while sparing their wrapper -- the very
command the removal claims to preserve, broken behind its back. The
whole teardown now turns on the marked stub, as replacement already
does; the desktop takeover keeps its own bargain, where a second Hermes
goes whoever built it and the app still provides the command after.

Also close the probe over underscores -- _ continues a flag name just
as - does, so --tui_mode no longer answers for --tui -- and pin the
gaps review found in the tests: each flag must match on its own (either
grep could be deleted before without a failure), a foreign wrapper's
mise environment must survive --remove, and Remove Hermes must tear
down the CLI in the interrupted-install case, not only after the app's
runtime landed.

Findings from an independent codex review at xhigh, each verified
against the source and proven by mutation before landing.

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 23:32:35 -05:00
Spencer BullandClaude 36d52254a7 Tear down the mise Hermes CLI on Remove Hermes
Remove Hermes dropped the desktop package and its ~/.hermes runtime but
never touched the mise CLI, on the assumption the install-time handoff
had already removed it. A CLI the app never superseded -- an interrupted
install, or the terminal CLI from before the app existed -- was left
stranded on PATH after uninstall.

Add a --remove mode to omarchy-install-hermes-cli that performs the same
teardown the desktop takeover already does (mise rm -g + mise uninstall,
and the marked stub), and call it from omarchy-remove-ai-hermes. The tool
spec and ownership marker stay defined in one place, so the takeover and
teardown paths cannot drift. Scoped to what Omarchy owns: a Hermes the
user installed themselves is left alone.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 22:51:03 -05:00
Ryan Hughes 7eca64e268 Merge pull request #8859 from tobi/feat/network-captive-portal
Show captive portal status and sign-in action in network panel
2026-09-01 23:39:43 -04:00
Spencer BullandClaude f7078b9136 Match Hermes flag probe at a boundary, cover the mise path
Review follow-up on the readiness probe. The two greps were fixed-string
substring matches, so a future release listing only --tui-theme or
--query-log while dropping the bare --tui/--query omarchy-agent passes
would read as ready -- the same false verdict inverted. Anchor both to a
flag boundary.

Add a regression case pinning that a substring-only help is rejected,
and one exercising --check through a mise-installed hermes in both
capability directions: the desktop and foreign cases only covered their
own wrappers, and the mise path is what a machine without the app runs.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-01 15:53:52 -05:00
Afonso Oliveira 2f816a83af Document privileged Bash startup exception 2026-09-01 21:35:15 +01:00
Adolanium 0a65b45ab1 Refuse hook and state names that are paths
omarchy-hook and omarchy-state set join a name straight into a path. A name
with a slash, or a bare . or .., points outside the hooks or state directory.
Every caller in the repo passes a fixed label, so this is a footgun guard for
future callers, not a fix for anything that ships today.

Names with dots in the middle (a..b) stay allowed. omarchy-state clear is
untouched: find -name matches basenames only.
2026-09-01 23:21:37 +03:00
Spencer Bull f08840d6cc Probe Hermes readiness by the flags omarchy-agent passes
Hermes v0.20 removed chat's --oneshot flag, which hermes_prompt_ready
used as its capability marker. A fully bootstrapped Hermes Desktop
install then read as not ready: --check failed forever, the default
agent flow looped back into the installer, and --now dead-ended with
"Launch Hermes Desktop once to finish installing it" on a machine
where it already had.

Probe for --tui and --query instead: the flags omarchy-agent actually
passes to seed an interactive session, rather than one that merely
shipped alongside them.
2026-09-01 14:25:37 -05:00
Taksh 8c303495ac fix: use subl for the Sublime Text default editor
Signed-off-by: Taksh <takshkothari09@gmail.com>
2026-09-01 23:55:53 +05:30
acrogenesis 1702cf0bee Restrict third-party shell plugin capabilities
Reported-by: Roger Piñol <rogerpicar@gmail.com>
2026-09-01 10:55:35 -06:00
Spencer Bull b71dcad96e Merge pull request #7469 from omacom/hermes-agent
Add Hermes as a desktop app and a coding agent
2026-09-01 11:11:57 -05:00
David Heinemeier Hansson 4d017913d0 Update the tagline to Beautiful, Fun & Agentic (#9584)
Matches omarchy.org, the X header, and the ISO. The README and the agent skill carry their lowercase variants.
2026-09-01 11:54:15 +02:00
Afonso Oliveira 87625c2ad8 OM-SEC-01: Make passwordless sudo expiry fail closed 2026-09-01 10:40:46 +01:00
Afonso Oliveira 1136a715c0 OM-SEC-14: Run update hooks without reusable sudo authority 2026-08-31 21:32:42 +01:00
Erik MeltonandAdolanium 945af75aa2 Fail closed when passwordless sudo expiry cannot arm
Remove stale passwordless sudo grants during boot and revoke a live grant immediately if its transient expiry timer cannot be created. Exercise both failure paths and the shipped tmpfiles rule against a disposable root.

Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com>
2026-08-31 17:24:59 +02:00
Ryan Hughes b686ed892d Merge pull request #9267 from omacom/fix/close-password-only-sshd
Disable sshd entirely when no usable key is authorized
2026-08-30 18:46:35 -04:00
Ryan HughesandClaude Fable 5 5c03dc8c09 Disable sshd entirely when no usable key is authorized
The old setup command enabled sshd before importing a key, so an aborted
run left a password-only server exposed. Skipping that machine kept the
hole Omarchy opened; close it instead by disabling sshd. Omarchy is a
desktop distro, so the console remains, and the warning explains how to
set up key-based access or deliberately re-enable password logins.

With the stakes flipped from skip to disable, "no usable key" must not
false-positive: follow an authorized_keys symlink to its key (dotfiles
setups have working key auth), and treat an unreadable file as
unverifiable rather than keyless.

Amends the unreleased 1788124236 migration in place; no released install
has run it, so every machine still gets the new behavior in one pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:45:05 -04:00
Ryan Hughes 3c2a24b248 Merge pull request #9255 from omacom/security/migrate-existing-sshd-hardening-v4-0-2
Harden existing key-based SSH setups
2026-08-30 18:38:00 -04:00
Ryan HughesandClaude Fable 5 986962bb64 Keep the sshd hardening migration from locking users out
Validate authorized_keys line by line with the question sshd actually
asks: ssh-keygen -lf on the whole file also fingerprints a private key
copied there by mistake, which sshd cannot use, so the migration would
have disabled the only working login path.

Tighten ~/.ssh and authorized_keys the way omarchy-setup-security-sshd
does, and back off from a group-writable home directory: StrictModes
makes sshd ignore the key either way, with the same lockout.

Complete with a notice instead of failing on conditions the migration
cannot repair (a broken or pre-Include sshd_config, an overriding admin
rule, a failed reload of a valid config), so those machines keep passwords
as they were without blocking every migration queued behind this one.
Only missing privileges stay pending, since a terminal rerun fixes that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 18:36:26 -04:00
Ryan Hughes a93ee6a433 Merge pull request #9263 from omacom/fix/shell-test-host-assumptions
Fix test failures caused by host assumptions
2026-08-30 18:27:24 -04:00
acrogenesis ca4f596a14 Harden existing key-based SSH setups 2026-08-30 18:07:59 -04:00
Ryan Hughes 279f4d6b95 Give the sshd acceptance exercise a terminal for sudo
Without a terminal sudo keys its cached credential on the parent
process of each call, so the timestamp validated by sudo -S -v in the
test shell never reached omarchy-setup-security-sshd's own sudo calls
when omarchy-iso-test drove the suite over ssh with no pty, and the
exercise died with 'a terminal is required'. Run it under script(1)
and validate the password on that pseudo-terminal first, so every sudo
underneath shares the terminal-keyed credential.
2026-08-30 18:06:33 -04:00
Ryan Hughes 243fe1c9d9 Shadow the git URL checker instead of thinning PATH
The missing-checker case dropped $ROOT/bin from PATH to make
omarchy-git-url-check unfindable, but installed machines carry the
packaged checker in /usr/bin, so it was always found and the test
failed on every 4.x machine. Shadow it with a stub that reports
command-not-found so the scenario holds regardless of the host.
2026-08-30 17:48:34 -04:00
Ryan Hughes bae189861f Copy the Windows VM helper before the test hides /home
The mount-boundary test tmpfs-mounts over /home before sourcing
$ROOT/bin/omarchy-windows-vm, so a checkout living under /home vanished
mid-test and set -e aborted with no output. Take a mount-safe copy of
the helper into the test tmpdir before the mounts land.
2026-08-30 17:48:34 -04:00
Ryan Hughes 981274b20a Merge pull request #9249 from omacom/fix/acceptance-installed-tree-default
Default the acceptance suite's OMARCHY_PATH to the installed tree alone
2026-08-30 16:58:09 -04:00
Ryan Hughes fe1325202b Default the acceptance suite's OMARCHY_PATH to the installed tree alone
The suite verifies the finished product: VM runs never use a dev-linked
tree, so the session-environment lookup and own-checkout fallback were
needless indirection. /usr/share/omarchy is the default; a caller testing
a different tree passes OMARCHY_PATH itself.
2026-08-30 16:58:04 -04:00
Ryan Hughes 432b5e3e24 Merge pull request #9240 from omacom/fix/acceptance-security-coverage
Fix the bar visibility toggle and make the acceptance suite cover 4.0.2 security hardening
2026-08-30 16:40:42 -04:00
Ryan Hughes 99ec17acfa Cover the 4.0.2 security hardening in the acceptance suite
Assert the closed session-to-root paths on an installed system — no blanket
input-group membership, no shipped asdcontrol sudoers grant — and exercise
omarchy-setup-security-sshd unattended end to end: sshd up, key authorized,
password and keyboard-interactive authentication off in the effective
config, SSH port rate limited in the firewall.

The sshd section mutates the machine, so it requires the explicit
OMARCHY_ACCEPTANCE_SUDO_PASSWORD opt-in that omarchy-iso-test passes for
its throwaway VMs; elsewhere it skips.
2026-08-30 16:30:51 -04:00
Ryan Hughes 9ca8f90e91 Capture OCR screenshots at 2x scale
Tesseract routinely drops small caption text at native resolution — the
weather panel's detail labels fail the WIND assertion with the text plainly
on screen. Let the compositor upscale the capture instead.
2026-08-30 16:30:51 -04:00
Ryan Hughes d3a5e69162 Fail the package audit when the manifest is missing
Reading a nonexistent manifest produced an empty package list, so the audit
reported every package installed after checking none of them.
2026-08-30 16:30:51 -04:00
Ryan Hughes d6130394fa Default the suite's OMARCHY_PATH to the running session's tree
Run over SSH with no OMARCHY_PATH, the acceptance runner defaulted it to
its own root — wrong in both sync modes omarchy-iso-test uses. With only
test/ synced, the root has no shell or install manifests: omarchy-shell
refuses every call and the package audit passes vacuously against an empty
manifest. With a full tree synced, the path disagrees with the config path
the session shell was started from, and since qs matches instances by that
path, every omarchy-shell call reads as "not running".

The suite acts on the running session, so ask the user manager for the
session's own OMARCHY_PATH first, then fall back to this checkout, then to
the installed tree.
2026-08-30 16:30:45 -04:00
Ryan Hughes 7aceb388e7 Merge pull request #9226 from omacom/security/add-security-policy
Add security policy
2026-08-30 16:15:41 -04:00
Ryan Hughes e1fc502286 Nudge the bar over IPC when toggling visibility
The shell notices the bar-off flag through a FileView watch on the toggles
directory, and that watch can permanently stop delivering events after flag
changes land in quick succession — the bar then stays parked off screen
until the shell restarts. Have omarchy-toggle-bar nudge the bar's probe
over IPC after flipping the flag, so the toggle no longer depends on the
watch staying alive. The watch remains for other writers of the flag.
2026-08-30 16:10:57 -04:00
Ryan Hughes e68994680a Merge pull request #9232 from omacom/fix/menu-acceptance-style-navigation
Fix Style submenu navigation in the menu acceptance test
2026-08-30 15:32:49 -04:00
Ryan Hughes 55a3906f4c Step past the restored Unlock entry to reach Menu Bar in the menu test
The Style submenu grew its Unlock entry back (d411c90a) the same day the
menu acceptance test was written, so the blind Down-key walk landed on
Font and picked a font instead of opening the Menu Bar submenu — the bar
position assertion then timed out on every run.
2026-08-30 15:31:13 -04:00
acrogenesis f8d7fae7a8 Match website security guidance 2026-08-30 13:26:18 -06:00
acrogenesis 3def390764 Add security policy 2026-08-30 13:19:11 -06:00
Ryan Hughes a24064c720 Merge pull request #9225 from omacom/fix/sshd-hardening-verification-case
Match sshd -T keywords case-insensitively when verifying SSH hardening
2026-08-30 15:13:52 -04:00
Ryan Hughes 71d7ac81ae Match sshd -T keywords case-insensitively when verifying hardening
OpenSSH 10.x prints configuration keywords in CamelCase in its sshd -T
dump, where 9.x printed them lowercase. The case-sensitive grep in
omarchy-setup-security-sshd therefore never matched on OpenSSH 10.x, so
the hardening drop-in was always judged ineffective and removed, leaving
password authentication enabled.
2026-08-30 15:03:06 -04:00
Ryan Hughes 4271b880c3 Merge pull request #9214 from omacom/rc-channel-pacman
Point the rc channel at the rc package repository
2026-08-30 13:54:07 -04:00
Ryan Hughes 884ca49340 Point the rc channel at the rc package repository
pacman-rc.conf shipped with [omarchy] on pkgs.omarchy.org/edge — a
leftover from when release candidates published there. Candidates now
publish to a dedicated rc channel, so a machine switched to rc with
omarchy-refresh-pacman was pairing the rc Arch mirror with edge omarchy
packages, and omarchy-version-channel could not name the rc repository
at all (an rc install reported 'rc / unknown').

Point the conf at pkgs.omarchy.org/rc, teach omarchy-version-channel
the rc repository, and repoint existing rc-channel machines with a
migration. The migration only rewrites the shipped pairing (rc mirror +
edge [omarchy]); an administrator's deliberate combination is kept.
2026-08-30 13:52:06 -04:00
Ryan Hughes 21b27c5aed Merge pull request #9200 from omacom/security/v4-0-2-input-asdcontrol-sshd
[4.0.2] Close unprivileged input and SSH escalation paths
2026-08-30 13:03:09 -04:00
Ryan HughesandDavid Heinemeier Hansson df819a6f98 Close three paths from an unprivileged session to root
Apply the Omabot patch on Quattro, verify effective SSH hardening, prevent stored provisioning state from restoring the blanket input-group grant, and stop Omarchy from shipping asdcontrol authorization that belongs to the package.

Co-authored-by: David Heinemeier Hansson <david@hey.com>
2026-08-30 12:54:08 -04:00
a59afb9659 Give the width back when a second app joins the console
The scratchpad spanned the whole screen whatever was on it, which reads as a band rather than a console when it holds the single agent it is usually opened for. It now sizes to a centered panel twice as wide as it is tall, and hands the full width back as soon as a second app is on the scratchpad: two windows splitting a half-width column is worse than the band this replaced.

The panel is still drawn with workspace gaps rather than a window rule, because Hyprland resolves a rule's size once as the window maps and the console has to survive a rescale. It is always flush with the top and always centered, so two numbers describe it, and the memo that suppresses no-op rewrites compares those rather than four edges.

The window count is read on window.open and window.destroy, the two events that run after the workspace's count has settled; window.close and window.move_to_workspace both still count the window on its way out, so refitting from either reads one too many and strands the console at full width. The count is only chased while the console is on screen, since a hidden one is refitted on its way in by workspace.special_active, and every window opened anywhere on the desktop would otherwise rewrite the rule.

Monitor width and height are the panel's own pixels, so an output turned on its side still reports them the way the panel is built rather than the way it hangs. The odd transforms are the quarter turns and those swap the work area: without that a rotated 1920x1080 sized its console from 1920x1080 instead of 1080x1920 and left a 214x1367 sliver.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-30 14:50:40 +02:00
David Heinemeier Hansson a041e9a7f3 Merge pull request #8611 from smfworks/feat/hermes-skill-symlinks
Link Omarchy agent skills into Hermes
2026-08-30 12:53:21 +02:00
David Heinemeier Hansson 2541eeee3d Merge quattro into hermes-agent
Catches the branch up on 94 commits so what lands here is reviewed against
current quattro, and so #8611 contributes its own five files rather than
dragging a partial catch-up in behind it.
2026-08-30 11:58:37 +02:00
ogarza 0bec60f6e4 Center the Quake console in a 2:1 panel instead of spanning the screen.
Size it from leftover workspace gaps so it refits per monitor, and let
omarchy_qconsole_ratio change the width.
2026-08-30 05:26:46 -04:00
Miguel Mendez f6a8c6c02f Mark omarchy-install-service-dropbox as requires-sudo
bin/omarchy scans the first 80 lines of every command for omarchy:* metadata. The requires-sudo flag is surfaced via 'omarchy commands --json', and the interactive launcher and menu use it to flag entries that need elevation before the user clicks them.

Every install-service script in bin/ has the directive: spotify, signal, 1password, sunshine, dropbox-remove, nordvpn, once, tailscale, plus the full omarchy-install-* and omarchy-apply-* trees. omarchy-remove-service-dropbox has it.

omarchy-install-service-dropbox is the outlier. The menu hints 'needs sudo' for removing Dropbox but not for installing it. The install path runs pacman via omarchy-pkg-add, which needs elevation. The missing directive is an oversight, not a deliberate choice.
2026-08-30 10:44:02 +02:00
7fec55e0ed Leave Hermes Desktop's HUD the transparency it draws itself
The HUD is a frameless Electron window that paints its own per-pixel
transparency. Under the default rules it gets a compositor border and Omarchy's
window opacity on top, which turns the compact prompt into an outlined, muddy
canvas.

Scoped to the HUD by title, so the main Hermes window keeps the ordinary
treatment.

Co-authored-by: Luiz Filipe <moresco.luiz@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:30:34 +02:00
David Heinemeier Hansson 512960e7da Merge pull request #8705 from spencerbull/hermes-prompted-tui
Use Hermes native prompted sessions
2026-08-30 10:27:11 +02:00
Michael Gannotti e482977f09 Add a Hermes skills migration test and list Antigravity in file-layout
The provision-user suite never ran the one-shot migration. Cover default-home
links, a pre-existing profile, idempotency, and a missing skill source.
Document ~/.gemini/config/skills and stop wrapping that bullet.
2026-08-29 15:31:29 -04:00
James (SMF Works) c64e03d9c5 Link Omarchy agent skills into Hermes skill directories
Hermes was missing from the provision-user symlink list that already
covers Claude, Codex, Pi, Antigravity, and ~/.agents. Add ~/.hermes/skills
plus existing ~/.hermes/profiles/*/skills. Migration for current installs.
2026-08-29 15:26:33 -04:00
Tobi Lutke 41a40ccc78 Show captive portal status and sign-in action in network panel 2026-08-28 20:08:26 -05:00
Spencer BullandCodex XHigh 5284be6582 Use Hermes native prompted sessions
Hermes now keeps chat queries interactive and literal to TUI control syntax, so launch it directly and let its own session flow replace the local one-shot, usage-file, and resume bridge.

Gate installation on the capability added with native interactive queries, preserve unowned mise environments, and keep the unprompted launch path unchanged.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-28 16:32:19 -05:00
Spencer BullandCodex XHigh 64203cc208 Keep prompted Hermes sessions local
Clear inherited session-source tags for Omarchy's local one-shot process so Hermes records the launch directory before the exact session is resumed in the TUI.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-27 23:07:42 -05:00
Spencer BullandCodex XHigh 36353296aa Harden prompted Hermes session handoff
Bind option-looking prompts to one-shot mode, require a successful completed usage report before resuming, replay the prompt after first-run setup, and reject Hermes runtimes that lack the session-report capability.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-27 23:01:30 -05:00
Spencer BullandCodex XHigh 288e387a22 Preserve Hermes session workspace metadata
Keep the one-shot session on Hermes' native CLI source so it records the launch directory before the exact session is resumed in the TUI.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-27 22:50:28 -05:00
Spencer BullandCodex XHigh 750dde5ed2 Resume prompted Hermes sessions literally
Hermes TUI startup queries execute slash, shell, interpolation, and multiline syntax before reaching the model. Run the prompt through literal one-shot mode, read its exact session ID from a private usage report, and resume that session in the TUI so arbitrary prompt text stays data while the conversation remains interactive.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-27 22:37:03 -05:00
Spencer BullandCodex XHigh b609ae2355 Keep prompted Hermes sessions interactive
Hermes oneshot deliberately exits after answering, which closes the agent terminal. Seed the TUI chat session instead, keep inherited flags after the subcommand for older Hermes parsers, and bind the query as one argument so dash-prefixed prompts remain data.

Co-Authored-By: Codex XHigh <noreply@openai.com>
2026-08-27 21:31:17 -05:00
David Heinemeier HanssonandClaude Opus 5 fdb3755c7d Document Hermes in the manual
The agent table lists every CLI Omarchy pre-wires, and Hermes was missing from
it. Hermes Desktop earns a paragraph of its own under the graphical apps,
because the one-Hermes-per-machine arrangement is something a user meets rather
than reads about: the app installs its own runtime on first launch, the terminal
command and the default agent then use that same one, and removing the app takes
the runtime but keeps their chats, memories and skills.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:45:33 +02:00
David Heinemeier HanssonandClaude Opus 5 f70c55d813 Give Hermes the mark that reads at menu size
U+E90A carried a trace of the Hermes app icon: a portrait whose detail collapses
into a grey smudge beside the ten flat silhouettes the rest of the font is made
of, which is what icon-font.md warns against when it says to pick a source whose
silhouette alone reads.

It is Font Awesome's staff-snake now, under CC BY 4.0 -- the mark Hermes serves
as its favicon and titles its README with. The README records that, along with
the licence the artwork carries, since it is the only note of where these come
from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:45:33 +02:00
David Heinemeier HanssonandClaude Opus 5 cda02f0a88 Ask the installer who owns the Hermes wrapper
Three files spelled out the line that marks ~/.local/bin/hermes as Omarchy's:
the installer that writes it, Remove Preinstalls, and the migration. Two of
them were copies, and a change to what ownership means would have left them
matching a line nobody writes any more -- Remove Preinstalls quietly sweeping
nothing, the migration mistaking Omarchy's own wrapper for a stranger's.

omarchy-install-hermes-cli --owns answers it now, and the other two ask. The
installer's own metadata was also a flag behind: --check has been there since
this landed and was never listed.

A test pins the marker to one file, so a second copy fails rather than drifts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 11:45:23 +02:00
12646eb5a1 Run the app's Hermes before calling it ready
desktop_hermes_ready decided from a marker file and a text match, while a
hermes the user installed themselves had to answer --version before it counted.
The marker says the app's install once landed, not that it is still there, so a
runtime deleted afterwards left --check reporting success: the default agent
records Hermes, skips the install terminal, and the launch fails.

It now runs the command, on the same 15 second budget the app itself uses. The
path match is a plain string for the same reason it is in the remover -- the
dot in ~/.hermes would otherwise claim a wrapper pointing at ~/xhermes.

foreign_hermes_runs never tested foreignness, only that the command runs, so it
is hermes_runs now and both callers share it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-27 11:45:15 +02:00
ba78e7df09 Leave a Hermes the app never installed alone
Remove > AI > Hermes deleted ~/.hermes/hermes-agent, bootstrap-cache, bin and
node unconditionally, plus any wrapper on PATH pointing into ~/.hermes. The
official Hermes installer uses those same paths, so a user who installed the
CLI themselves, then installed the app and never launched it, lost their
checkout, venv and any local changes -- while being told their chats, memories
and skills were safe.

The app provisions its runtime on first launch and writes
.hermes-bootstrap-complete when it lands. Without that marker the app never got
that far and everything under ~/.hermes predates it, so dropping the package is
the whole job.

Two smaller things in the same path. The wrapper test matched ~/.hermes as a
pattern, and the dot made it claim a wrapper pointing at a sibling like
~/xhermes; it is a plain string now, and a symlink there is the user's
arrangement rather than something to delete. And -u, so an unset HOME is an
error instead of a set of rm -rf paths rooted at /.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-27 11:44:39 +02:00
2f918a75ad Stop the Hermes interpreter pin following the agent into the user's projects
The stub exports UV_PYTHON so mise builds Hermes against 3.13, which Hermes
requires and Arch's Python is past. Exported, it survived the exec into Hermes
itself and reached every command the agent shells out to. Hermes is a coding
agent that runs commands in the user's own repositories, so a `uv venv` or
`uv sync` there resolved 3.13 as well: on a project declaring
requires-python >=3.14, uv warns that the interpreter contradicts it and builds
the venv anyway.

Dropping it at the handover keeps the pin over the install, where it belongs.
mise x resolves the tool it already installed without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Codex XHigh <noreply@openai.com>
2026-08-27 10:48:06 +02:00
David Heinemeier HanssonandClaude Opus 5 43d2fffaf0 Keep user setup running when Hermes cannot install
install/user/mise.sh is sourced through run_logged under `bash -eE`, and its
status reaches omarchy-provision-user's `set -euo pipefail`. Every other line
in the file writes a mise stub and cannot fail; omarchy-install-hermes-cli can,
and does whenever hermes-desktop is installed but the app has not been launched
yet -- what a second user on a shared machine meets on their first login.

The rest of provisioning runs after that source: refreshing applications, the
default browser, the mailto handler, the first-install migration markers and
the finalize-user marker. Without the marker the whole step retries and fails
again at every login, and omarchy-provision-first-run calls it with `|| true`,
so nothing surfaces. omarchy-install-ai-hermes and the migration already guard
this call the same way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 10:47:53 +02:00
David Heinemeier Hansson 7bbb119a68 Merge pull request #8539 from spencerbull/review/pr-7469-hermes-install
Harden Hermes wrapper ownership
2026-08-27 10:04:23 +02:00
Spencer Bull 5909210cb3 Address Hermes review feedback 2026-08-27 01:36:26 -05:00
Spencer Bull d56c1ba972 Harden Hermes wrapper ownership 2026-08-27 01:09:45 -05:00
a12a21c02f Add Hermes as a desktop app and a coding agent
Hermes joins Install > AI as a desktop app, sits beside it under Remove > AI,
and becomes a choice in the default-agent list. The CLI installs through
omarchy-install-hermes-cli rather than a bare `mise use`, so its interpreter
is pinned before mise builds it.

Rebased onto quattro. Ori claimed U+E909 in #7709 while this branch was open,
so the Hermes mark moves to U+E90A in the icon font, the menu entries, the
font README, and the charset the menu test pins. The glyph outline itself is
unchanged; it is spliced in beside Ori rather than over it.

Co-Authored-By: witcheer <witcheer.eth@gmail.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SySdB3RtCA8BNv6Am246BP
2026-08-26 18:04:41 +02:00
Adolanium e090d01877 Cover the control-character branch in the mise-install guard
The refusal table exercised slash, leading dot and leading dash but never a
control character, so that branch of the guard could have been dropped without
a test noticing.

Add a newline and a tab case, labelled so the value stays out of the output.
2026-08-24 07:34:36 +03:00
Adolanium b6a8f2453f Quote mise-install arguments and refuse unusable command names
The wrapper is written through an unquoted heredoc, so the package and bin
names land in it as shell source. A package name carrying shell characters
became code that ran every time the wrapper ran.

The command name is used raw as a file name under ~/.local/bin, so a slash in
it wrote and removed somewhere else entirely.

Quote both values with printf %q, and refuse command names that are not plain
file names before anything is removed or written.
2026-08-24 07:15:44 +03:00
Paolo Pellicori fa3f607705 [Security] Keep the Windows VM password out of the RDP client's argument list
`launch_windows` passed the VM password to xfreerdp3 as `/p:"$WIN_PASS"`.
`/proc/<pid>/cmdline` is world-readable, so every other user on the machine
could read the password for as long as the session stayed open — `ps` was
enough.

The arguments now go in over stdin with `/args-from:stdin`, leaving only that
flag in the process list. The argument list itself is unchanged, and building
it as an array also fixes the unquoted `$RDP_SCALE` expansion.
2026-08-23 17:01:26 +02:00
Bernd Behler 6e15d76a62 Drop key auto-repeat in the lock screen password field 2026-08-22 20:18:52 +02:00
OmabotandCodex XHigh c9faea7c55 Keep PwNode objects out of the audio panel's Repeater models
The panel handed its three Repeaters snapshots of live PwNode objects, and Quickshell deletes a node the moment PipeWire drops it. Qt then dereferences that row without a null check in VDMListDelegateDataType::createMissingProperties (qqmldmlistaccessordata_p.h:222, unchanged in 6.11.2), so any teardown that emptied the graph with the panel open segfaulted the shell: a USB DAC unplugged, a Bluetooth profile switch, omarchy-restart-audio, a WirePlumber restart, or a laptop suspend.

Rows now carry the node's id and name rather than the node, and each delegate resolves its own PwNode through nodeFor(), so labels, glyphs and stream volumes stay live. The name is what pins a row to the node it came from, since PipeWire reuses object ids; it is CONSTANT on PwNode for as long as the node exists. This is the projection the bluetooth panel already uses against the same crash class.

Filtering the snapshot instead does not work, which is worth recording because it is the obvious thing to reach for: the node is usually alive when the snapshot is taken and dies while Qt is incubating the delegate, so nothing visible at snapshot time identifies the row that will dangle.

Co-Authored-By: Codex XHigh <codex@openai.com>
2026-08-22 08:10:51 -07:00
J. S. Brown b63374f388 Remember wallpaper choices per theme 2026-08-21 16:29:43 -07:00
David Heinemeier HanssonandClaude Opus 5 889b22dd85 Keep mise upgrades from pruning versions still in use
mise ships with upgrade.auto_prune on, so `mise up` deletes the old
install directory as soon as a tool upgrades. Long-running processes are
still executing out of that directory, and some re-exec themselves by
absolute path, so running `omarchy update` or `mup` breaks them mid-session.

Claude Code is the case that surfaced it. It records its own binary path
at startup and dispatches its bundled ugrep and bfs through it, so once
the upgrade pruned that path its grep and find wrappers fell back to the
mise wrapper in ~/.local/bin. That wrapper cannot stand in for the real
binary here: a shebang script never sees the argv[0] it was invoked
under, because the kernel hands the interpreter the script path instead.
The dispatch collapsed to plain `claude -G`, which fails.

Keeping the old versions costs some disk and leaves the stale directory
behind until something prunes it deliberately, which is the cheaper half
of the trade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 10:45:12 +02:00
1149 changed files with 73365 additions and 6614 deletions

No files matched your search

-2
View File
@@ -1,2 +0,0 @@
# Merges to protected branches need sign-off from an org owner.
* @dhh @ryanrhughes
+1 -1
View File
@@ -1,7 +1,7 @@
blank_issues_enabled: false
contact_links:
- name: Suggestion
url: https://github.com/basecamp/omarchy/discussions/categories/suggestions
url: https://github.com/omacom/omarchy/discussions/categories/suggestions
about: Suggest a new feature, change to existing feature, or other ideas in Discussions.
- name: Support
url: https://omarchy.org/discord
+47
View File
@@ -0,0 +1,47 @@
# Security at Omarchy
## Report a vulnerability
If you believe you’ve found a security vulnerability in Omarchy, please tell the [Omarchy Security Team](https://omarchy.org/teams/#security) privately so we have an opportunity to investigate and fix it before it is made public.
[security@omarchy.org](mailto:security@omarchy.org?subject=Security%20report)
Please don’t report potential vulnerabilities publicly in GitHub Issues, Discord, or social media before they’ve been resolved.
## What is a vulnerability?
We consider a bug a security vulnerability when it can be exploited to cross a meaningful security boundary: an untrusted or lower-privileged party gains access, permissions, or control they didn’t already have.
Code that could be more robust but does not cross a security boundary is an improvement rather than a security vulnerability. We may still merge a proposed fix and credit the reporter in our release notes.
Eligibility for our [security credits](https://omarchy.org/security/credits/) page depends on whether a report identifies a confirmed security vulnerability, not on its severity.
## What to include
Give us enough information to understand and reproduce the issue:
- The affected component and Omarchy version.
- An explanation of what an attacker can do before and after exploitation.
- Steps to reproduce the issue and any proof of concept.
- Your preferred contact details for follow-up.
## Responsible disclosure
Please act in good faith while investigating and reporting vulnerabilities:
- Only test systems and accounts you own or have explicit permission to test.
- Avoid privacy violations, disruption, data destruction, and service degradation.
- Don’t exploit a vulnerability beyond what is needed to demonstrate it.
- Give us a reasonable opportunity to investigate and address the issue before publishing details.
We’ll review your report and keep you informed as we’re able while we work toward a resolution.
## Credits
Researchers who privately report a confirmed security vulnerability and give us the chance to ship a fix are thanked on the [security credits](https://omarchy.org/security/credits/) page. Accepted improvements that don’t cross a security boundary may still be credited in our release notes.
Credits link to each reporter’s X profile and show their avatar. For duplicate reports, only the first reporter is eligible for credit.
## Regular bugs and support
For anything that isn’t a security vulnerability, please use the [Omarchy issue tracker](https://github.com/omacom/omarchy/issues).
+2
View File
@@ -2,3 +2,5 @@
# Python bytecode (orchestrator)
__pycache__/
*.pyc
.claude/
+36 -2
View File
@@ -28,7 +28,7 @@ Three documentation trees, split by genre and audience:
- Prefer `(( ))` over numeric operators inside `[[ ]]` (e.g., `(( count < 50 ))`, not `[[ $count -lt 50 ]]`)
- Prefer a full `if`/`else` conditional for simple two-path control flow; don't rely on `exec` or `exit` in one branch to make following statements unreachable
- For strings/paths with spaces, quote them instead of escaping spaces with `\ ` (e.g., `"$APP_DIR/Disk Usage.desktop"`, not `$APP_DIR/Disk\ Usage.desktop`)
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`)
- Shebangs must use `#!/bin/bash` consistently (never `#!/usr/bin/env bash`). A security-sensitive entrypoint may use the exact `#!/bin/bash -p` form only when it must suppress `BASH_ENV` and exported-function startup injection before its first command; that exception must be explained at the boundary and covered by a regression that rejects an ordinary Bash launch with a decoy `-p` argument.
- Scripts under `install/` and `migrations/` may be sourced and intentionally omit shebangs
# Command Naming
@@ -63,6 +63,30 @@ guidance does not drift from the router.
- `$OMARCHY_PATH` is set at the top level by the uwsm session environment and is always available to Omarchy runtime code.
- Commands in `bin/` and Quickshell QML should rely on `$OMARCHY_PATH` / `Quickshell.env("OMARCHY_PATH")`; do not derive fallback paths from `HOME`, `Quickshell.shellDir`, or re-export/default `OMARCHY_PATH` manually.
# Platforms
Omarchy runs on x86 and aarch64 (ARM). A platform name is the CPU architecture, then a hardware family only when that family needs handling of its own:
- `x86` - every x86_64 machine
- `aarch64` - every ARM machine without a family of its own (Snapdragon laptops, Raspberry Pis and ARM VMs today)
- `aarch64-apple` - Apple Silicon Macs
A new family is named `aarch64-<family>` (e.g. `aarch64-qualcomm`, `aarch64-n1x`) and is added only once it needs code of its own, so `aarch64*` always means ARM. `omarchy-hw-platform` prints the most specific name. Use these exact names wherever a platform is named: package lists, pacman template directories, image manifests, dispatch registration, tests and docs. Never introduce `generic`, `arm64`, `apple-silicon` or other spellings; the old `apple-silicon`, `generic-aarch64` and `generic` are read only for compatibility with older image builders.
Ask a helper; don't read `uname -m` or the device tree in feature code:
- `omarchy-hw-x86` / `omarchy-hw-aarch64` - the CPU architecture. Use them for binary and ABI availability: a package with no aarch64 build, an x86 guest VM, multilib.
- `omarchy-hw-aarch64-apple` (and later `omarchy-hw-aarch64-<family>`) - built on `omarchy-hw-platform`, image-build aware. Use them for hardware behaviour. `omarchy-hw-apple-silicon` is a hidden alias kept for the omarchy-mac packages; don't use it in new code.
Where platform code lives:
- Packages: `install/omarchy-base.packages` for everyone, `install/omarchy-aarch64.packages` for every ARM machine, `install/omarchy-<platform>.packages` for one family, and base packages with no aarch64 build in `install/omarchy-x86_64-only.packages`. `omarchy-pkg-defaults` composes them.
- Pacman templates: `default/pacman/` for x86, `default/pacman/<platform>/` for each ARM platform.
- Hardware setup: `install/hardware/<vendor-or-family>/`, gated by a predicate. `install/hardware/apple/` holds Intel Mac quirks; Apple Silicon's own setup ships in the omarchy-mac packages.
- Boot chains Omarchy can't drive generically: a platform package behind `omarchy-lifecycle-dispatch` ([`docs/lifecycle-dispatch.md`](docs/lifecycle-dispatch.md)). Platforms that boot Limine with UKIs like x86 keep the generic path.
The experience should be the same on every platform. When a package or feature is missing or broken on one, the default is to get it fixed upstream (an aarch64 build, a bug report, a patch to the maintainer) and leave the gap visible meanwhile, not to paper over it. When asked for a platform workaround, say so and propose the upstream fix first. Hiding a menu entry or skipping a package is fine only for things that can't exist there (an x86 guest VM, an x86-only proprietary binary). Alternative packages and platform-only replacements are a last resort, used only when upstream can't or won't fix it, with the reason written next to the code.
# Privileged Commands
- Follow the "Privilege Escalation" section of `default/agents/skills/omarchy/SKILL.md`. It draws the
@@ -80,7 +104,7 @@ Use these instead of raw shell commands:
- `omarchy-cmd-missing` / `omarchy-cmd-present` - check for commands
- `omarchy-pkg-missing` / `omarchy-pkg-present` - check for packages (don't use these if you can just use `omarchy-pkg-add`/`omarchy-pkg-drop`)
- `omarchy-pkg-add` - install packages (handles both pacman and AUR)
- `omarchy-pkg-add` - install packages from the configured pacman repos, Omarchy's included; use `omarchy-pkg-aur-add` for AUR packages
- `omarchy-pkg-drop` - remove packages; use this instead of raw `pacman -R*`
- `omarchy-notification-send` - send desktop notifications; do not call `notify-send` directly
- `omarchy-hw-asus-rog` - detect ASUS ROG hardware (and similar `hw-*` commands)
@@ -89,6 +113,10 @@ Commands installed by Omarchy's default package set are runtime invariants. Invo
Exceptions are allowed for migration and package-helper scripts where the helper may not be available yet, where the helper itself is being implemented, or where direct package-manager behavior is required.
# Background Work
- Long-running code (the shell, daemons, watchers) reacts to events instead of polling with a timer. Polling is the last resort, for state with no change signal; [`agents/skills/shell-dev.md`](agents/skills/shell-dev.md) lists the event sources and the push-refresh pattern.
# Menu
- The menu definition lives in `default/omarchy/omarchy-menu.jsonc`;
@@ -96,6 +124,12 @@ Exceptions are allowed for migration and package-helper scripts where the helper
- Do not add `aliases` to new menu entries. Aliases are reserved for
established alternate names users already type, kept for compatibility.
# Language
- `omarchy-language en|zh` (Setup > Language) owns `LANG` in `/etc/locale.conf`; the installer sets it from its first screen. Nothing in `omarchy update` or migrations may change `/etc/locale.conf`.
- Chinese menu labels live in `default/omarchy/omarchy-menu.zh-cn.jsonc`, merged when `LANG` starts with `zh_CN`; give a new menu row with translatable words a label there.
- Shell text goes through `I18n.tr("English %1", arg)` from `qs.Commons`, with its Chinese in `shell/Commons/I18nZh.js` keyed by the English. Notifications and OSD messages are worded by their sender with `omarchy-text "English %1" "$arg"` (Python: `text()`, QML: `I18n.tr`), which reads the same table; the shell shows them as given, so user text is never translated. `test/shell.d/shell-i18n-test.sh` fails on a missing entry.
# Config Structure
- `config/` - default configs copied to `~/.config/`
+2 -4
View File
@@ -1,14 +1,12 @@
# Omarchy
Omarchy is a beautiful, modern & opinionated Linux distribution by DHH.
Omarchy is a beautiful, fun & agentic Linux distribution by DHH.
Read more at [omarchy.org](https://omarchy.org).
## The Omarchy Manual
The manual lives in [`manual/`](manual/), which is its authoritative source. It's
mirrored to [learn.omacom.io](https://learn.omacom.io/2/the-omarchy-manual), where
its screenshots are also hosted.
The manual lives in [`manual/`](manual/), which is its authoritative source.
- [Welcome to Omarchy!](manual/01-welcome-to-omarchy.md)
+3 -7
View File
@@ -70,10 +70,6 @@ magick -background white -fill black -font default/fonts/omarchy/omarchy.ttf \
-pointsize 110 label:@/tmp/row.txt /tmp/font-row.png
```
Then confirm it in the running menu per
[`visual-verification.md`](visual-verification.md). Fontconfig prefers the
packaged font over a copy in `~/.local/share/fonts` for the same family, so a
preview needs either the real file replaced or a `<rejectfont>` rule in
`~/.config/fontconfig/conf.d/` pointing fontconfig away from the packaged one.
Restart the shell afterwards — Qt reads the font database at startup, so
`omarchy menu refresh` alone will not pick up a changed font.
Then confirm it in the running menu per [`visual-verification.md`](visual-verification.md). Avoid leaving two fonts with the `omarchy` family registered: Qt can use an old copy in `~/.local/share/fonts` even when `fc-match omarchy` reports the packaged font. For a preview, either replace the packaged file in the disposable VM or temporarily exclude it with a `<rejectfont>` rule in `~/.config/fontconfig/conf.d/` before loading the candidate.
Refresh the font cache and restart the shell afterwards — Qt reads the font database at startup, so `omarchy menu refresh` alone will not pick up a changed font. Remove temporary fonts and rules after verification.
+3
View File
@@ -12,7 +12,10 @@ commands and reusable setup leaves:
- avoid `exit` in sourced setup scripts unless intentionally aborting setup.
- use `$OMARCHY_INSTALL` and `$OMARCHY_PATH` instead of hard-coded Omarchy paths.
- keep root-scoped hardware setup under `install/hardware/` and orchestrate it through `install/hardware/all.sh`.
- in an image build every `run_logged` leaf in `install/hardware/all.sh` is queued and runs on the machine's first boot instead (`bin/omarchy-provision-hardware`, see `docs/file-layout.md`), possibly offline, using packages already in the image, and before any user exists; keep those leaves idempotent and put nothing but `run_logged` lines in `all.sh`.
- gate platform-specific setup on a predicate built on `omarchy-hw-platform` (`omarchy-hw-aarch64-apple`), not on `uname -m`; architecture checks (`omarchy-hw-x86`, `omarchy-hw-aarch64`) are only for ABI, binary availability or a repository's `$arch`. Platform names and where platform code lives are in the Platforms section of `AGENTS.md`.
- keep every per-user setup leaf under `install/user/` (including `install/user/hardware/` and `install/user/first-run/`) so it is clear what must run for each user.
- The base install supplies matching kernel headers before hardware setup. DKMS installers should install their driver packages and assume the headers exist.
- prefer helper commands for package and command checks where available.
Raw `command -v`, `pacman`, and `pacman-key` are acceptable in package-helper
+2
View File
@@ -163,6 +163,8 @@ rm ~/.local/state/omarchy/migrations/<migration>.sh
omarchy-migrate
```
Keep a dedicated test while the migration is still being written or bugfixed, if it calls an Omarchy helper whose interface can still change, or if it is a security-sensitive privileged repair (FIDO2, leftover installer artifacts, udev, sshd). Once a one-shot rewrite has shipped in a tagged release and is frozen, drop the test even when that rewrite used sudo, pacman, or limine-mkinitcpio. Keep the migration itself for late-updaters. Tests of `omarchy-migrate`, the login notifier, and `omarchy-upgrade-to-quattro` stay.
Omarchy 4.0 is upgraded through `bin/omarchy-upgrade-to-quattro`, not through the
normal migration runner. Do not add compatibility migrations for old installer
layouts; put pre-4 package-layout transition work in the upgrade command instead.
+20 -3
View File
@@ -20,12 +20,14 @@ Run `omarchy-restart-shell` after making changes to QML files.
[`docs/omarchy-shell.md`](../../docs/omarchy-shell.md) and
`shell/services/PluginRegistry.qml` for the current contract; fields such as
`activation` are optional.
- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the
shell-injected properties `omarchyPath`, `shell`, `manifest`, and
`pluginRegistry` / `barWidgetRegistry` as appropriate.
- Entry-point QML files are `Item`s (not `ShellRoot`), and accept the shell-injected properties `omarchyPath`, `shell`, `manifest`, and `pluginRegistry` / `barWidgetRegistry` as appropriate. First-party plugins receive the host objects. Third-party plugins receive capability-scoped facades: ordinary plugins may look up and control only their own service and lifecycle, built-in clones retain narrow source-specific configuration and UI compatibility, menu plugins receive an application-library facade, and plugins can read detached scalar bar state; full-bar plugins additionally receive detached bar configuration and widget-catalog snapshots, narrow proxies for the non-authentication services used by built-in bar widgets, and lifecycle control over configured non-authentication UI plugins. Authentication capabilities must be stamped from trusted first-party manifests, and third-party registry views and bar configuration must be detached snapshots rather than shared objects. These facades reduce accidental authority but are not a same-process QML sandbox: a visual bar widget can walk its parent hierarchy to ordinary host objects. Authentication services must therefore remain outside both `ShellRoot._services` and the host QObject tree. Do not expose authentication services through new third-party-facing properties.
- Panel / overlay / menu plugins must expose `open(payloadJson)` and
`close()` lifecycle methods for `shell summon` and `shell hide`.
## Text
Wrap every user-visible string in `I18n.tr()` (`import qs.Commons`) and add its Chinese to `shell/Commons/I18nZh.js`; plain JS models that Node tests load stay English, and their text is wrapped where QML shows it.
## IPC
- `bin/omarchy-shell` is the canonical IPC entry point. It forwards to
@@ -40,6 +42,17 @@ Run `omarchy-restart-shell` after making changes to QML files.
bar widgets register one target each — `omarchy.indicators`,
`omarchy.system-update`, `omarchy.clock`. There is no `bar` target.
## Keeping state current without polling
The shell runs all day on battery, and a widget is built once per monitor, so a `Timer` that starts a `Process` costs that process times the number of monitors, every tick, forever. Don't poll for state that only changes through known events. Pick the first of these that fits:
- A native Quickshell service that signals on change: `UPower` and `PowerProfiles` (`Quickshell.Services.UPower`), `Hyprland` and each workspace's `lastIpcObject`, `Pipewire`, `Mpris`, `SystemTray`, `Notifications`.
- An event stream the shell already runs, such as the Hyprland IPC events or the long-running `pactl subscribe` in `Commons/UntypedInput.qml`. Extend it rather than starting a second one.
- A file watch: `FileView` with `watchChanges`, or the directory watch in `plugins/bar/Bar.qml`, when the state lives in a file the user can read.
- A push from the command that changes the state. If only Omarchy commands change it, have them call `omarchy-shell -q <target> refresh` after the change, the way `omarchy-reminder` and `omarchy-sudo-passwordless` do. When the change happens later, without a command running (an expiry, say), schedule the refresh with a transient `systemd-run --user --on-active=…` timer. A root-owned timer cannot reach the user's shell.
Polling is the last resort, kept for state with no change signal at all (a remote API, say). Then gate the timer on the panel being open where you can, pick the longest interval the UI can tolerate, and put the poll in one shared service rather than in a per-monitor widget. Never poll a privileged probe: every `sudo -n` can write to the auth log.
## Editing widget files with glyphs
Widget files in `shell/plugins/bar/widgets/` contain Nerd Font glyphs as raw
@@ -47,3 +60,7 @@ unicode characters. Agent file-editing tools can strip multi-byte codepoints
in some positions — do **not** rewrite widget files wholesale through those
tools. For glyph fixes, make a targeted edit with the surrounding context, or
use a Python script that inserts codepoints via `chr(0xXXXXX)`.
## Palette references
Import `qs.Commons as Commons` and use `Commons.Color` for the palette singleton, including `Connections.target`. Qt 6.12 introduces a `Color` type in `QtQuick` that shadows unqualified palette references. This applies to first-party components and third-party plugin QML alike. Keep the unqualified `qs.Commons` import when the file also uses `Style`, `Util`, or `Border`.
+1 -4
View File
@@ -15,10 +15,7 @@ Take a full-screen screenshot without opening the editor:
omarchy capture screenshot fullscreen save
```
The command prints the saved path and writes to the configured Pictures
directory. Use `omarchy screenshot` for the interactive smart-region flow.
Capture reference and candidate states as separate images when changing a
layer-shell surface or layout, then compare both.
The command writes to Omasnap's configured screenshot directory. Use `omarchy screenshot` for the interactive capture flow, which copies the capture and shows a timed preview. Use `omarchy screenshot --editor=overlay` to test annotation before output. Capture reference and candidate states as separate images when changing a layer-shell surface or layout, then compare both.
Record a short full-screen video for animation, transition, timing, capture, or
screen-recording changes:
+1
View File
@@ -2,6 +2,7 @@
Version=1.0
Name=Basecamp
Exec=omarchy-launch-webapp https://launchpad.37signals.com
StartupWMClass=chrome-launchpad.37signals.com__-Default
Terminal=false
Type=Application
Icon=basecamp
+1
View File
@@ -2,6 +2,7 @@
Version=1.0
Name=Discord
Exec=omarchy-launch-webapp https://discord.com/channels/@me
StartupWMClass=chrome-discord.com__channels_@me-Default
Terminal=false
Type=Application
Icon=omarchy-discord
-8
View File
@@ -1,8 +0,0 @@
[Desktop Entry]
Version=1.0
Name=Disk Usage
Exec=xdg-terminal-exec --app-id=TUI.float -e bash -c "dua i /"
Terminal=false
Type=Application
Icon=disk-usage
StartupNotify=true
+1
View File
@@ -2,6 +2,7 @@
Version=1.0
Name=Google Contacts
Exec=omarchy-launch-webapp https://contacts.google.com/
StartupWMClass=chrome-contacts.google.com__-Default
Terminal=false
Type=Application
Icon=google-contacts
+1
View File
@@ -2,6 +2,7 @@
Version=1.0
Name=Google Maps
Exec=omarchy-launch-webapp https://maps.google.com
StartupWMClass=chrome-maps.google.com__-Default
Terminal=false
Type=Application
Icon=google-maps
+1
View File
@@ -2,6 +2,7 @@
Version=1.0
Name=Google Messages
Exec=omarchy-launch-webapp https://messages.google.com/web/conversations
StartupWMClass=chrome-messages.google.com__web_conversations-Default
Terminal=false
Type=Application
Icon=google-messages
+1
View File
@@ -2,6 +2,7 @@
Version=1.0
Name=Google Photos
Exec=omarchy-launch-webapp https://photos.google.com/
StartupWMClass=chrome-photos.google.com__-Default
Terminal=false
Type=Application
Icon=google-photos
+1
View File
@@ -2,6 +2,7 @@
Version=1.0
Name=WhatsApp
Exec=omarchy-launch-webapp https://web.whatsapp.com/
StartupWMClass=chrome-web.whatsapp.com__-Default
Terminal=false
Type=Application
Icon=whatsapp
+1
View File
@@ -2,6 +2,7 @@
Version=1.0
Name=X
Exec=omarchy-launch-webapp https://x.com/
StartupWMClass=chrome-x.com__-Default
Terminal=false
Type=Application
Icon=x
+1
View File
@@ -2,6 +2,7 @@
Version=1.0
Name=YouTube
Exec=omarchy-launch-webapp https://youtube.com/
StartupWMClass=chrome-youtube.com__-Default
Terminal=false
Type=Application
Icon=youtube
+29 -1
View File
@@ -26,7 +26,7 @@ declare -A ROUTE_IS_ALIAS
declare -A BINARY_TO_KEY
declare -A GROUP_DESCRIPTIONS
GROUP_DESCRIPTIONS[agent]="AI coding agent usage data"
GROUP_DESCRIPTIONS[agent]="AI coding agents, their usage, and subscription accounts"
GROUP_DESCRIPTIONS[ascii]="Text drawn as ASCII art"
GROUP_DESCRIPTIONS[audio]="Audio input and output controls"
GROUP_DESCRIPTIONS[bar]="Omarchy shell bar layout and settings"
@@ -46,6 +46,7 @@ GROUP_DESCRIPTIONS[finalize]="Finalize user setup"
GROUP_DESCRIPTIONS[default]="Default application selection"
GROUP_DESCRIPTIONS[dev]="Omarchy development tools"
GROUP_DESCRIPTIONS[disk]="Disk performance helpers"
GROUP_DESCRIPTIONS[dictation]="Dictation recording"
GROUP_DESCRIPTIONS[display]="Display and text scaling"
GROUP_DESCRIPTIONS[dns]="DNS resolver configuration"
GROUP_DESCRIPTIONS[drive]="Drive selection and encryption"
@@ -58,6 +59,7 @@ GROUP_DESCRIPTIONS[hw]="Hardware detection and controls"
GROUP_DESCRIPTIONS[hyprland]="Hyprland window, monitor, and toggle controls"
GROUP_DESCRIPTIONS[install]="Optional software installers"
GROUP_DESCRIPTIONS[installed]="Installed optional service checks"
GROUP_DESCRIPTIONS[language]="System language"
GROUP_DESCRIPTIONS[launch]="Application launchers"
GROUP_DESCRIPTIONS[menu]="Omarchy menu commands"
GROUP_DESCRIPTIONS[migrate]="Migration runner"
@@ -65,6 +67,7 @@ GROUP_DESCRIPTIONS[monitor]="Monitor status helpers"
GROUP_DESCRIPTIONS[network]="Network status helpers"
GROUP_DESCRIPTIONS[notification]="Notification helpers"
GROUP_DESCRIPTIONS[mise]="Mise tool wrappers"
GROUP_DESCRIPTIONS[openclaw]="OpenClaw agent platform setup"
GROUP_DESCRIPTIONS[osd]="On-screen display status helpers"
GROUP_DESCRIPTIONS[pkg]="Package management helpers"
GROUP_DESCRIPTIONS[plugin]="Omarchy shell plugin and bar widget management"
@@ -370,6 +373,20 @@ command_requires_args() {
[[ -n $required ]]
}
# A command whose documented arguments are all flags can't take a word, so a
# word after it names something else: `omarchy agent account` is the account
# commands, not an argument to omarchy-agent.
command_takes_only_flags() {
local key="$1"
local args="${COMMAND_ARGS[$key]}"
local token=""
[[ -n $args ]] || return 1
for token in $args; do
[[ $token == -* || $token == "[-"* ]] || return 1
done
}
load_group_commands() {
local group="$1"
local file=""
@@ -955,6 +972,17 @@ dispatch_fast_or_help() {
remaining=("${args[@]:DIRECT_RESOLVED_COUNT}")
binary_path="$OMARCHY_BIN_DIR/$DIRECT_RESOLVED_BINARY"
if (( ${#remaining[@]} == 1 || ${#remaining[@]} == 2 )) && [[ ${remaining[0]} != -* ]] &&
{ (( ${#remaining[@]} == 1 )) || remaining_has_help_flag "${remaining[1]}"; } &&
group_has_child_commands "${DIRECT_RESOLVED_BINARY#omarchy-}-${remaining[0]}"; then
load_command_by_binary "$DIRECT_RESOLVED_BINARY"
key="${BINARY_TO_KEY[$DIRECT_RESOLVED_BINARY]}"
if [[ -n $key ]] && command_takes_only_flags "$key"; then
load_commands
show_prefix_help "$DIRECT_RESOLVED_ROUTE ${remaining[0]}" && return 0
fi
fi
if remaining_has_help_flag "${remaining[@]}"; then
if ! load_command_by_binary "$DIRECT_RESOLVED_BINARY"; then
echo "Binary is missing or not executable: $DIRECT_RESOLVED_BINARY" >&2
+52
View File
@@ -82,10 +82,39 @@ grok)
command=(grok --permission-mode bypassPermissions)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
openclaw)
# The launcher owns onboarding and the gateway dance: OpenClaw's terminal UI
# must attach to the running gateway (the embedded `openclaw chat` refuses to
# start while the gateway owns the state directory). It has no permission
# prompts to skip, and --message seeds the session while keeping it
# interactive.
command=(omarchy-launch-openclaw --tui)
[[ -n ${prompt:-} ]] && command+=(--message "$prompt")
;;
codex)
command=(codex --approve-for-me)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
cursor-agent)
# --yolo covers commands only; the workspace trust dialog has its own flag.
# A one-word prompt naming a subcommand (update, login, help) still runs that
# subcommand after a bare --, so the agent subcommand is named outright, and
# -- after it keeps a prompt starting with a dash from being read as an option.
command=(cursor-agent --yolo --trust)
[[ -n ${prompt:-} ]] && command+=(agent -- "$prompt")
;;
hermes)
if [[ -n ${prompt:-} ]]; then
command=(env -u HERMES_SESSION_SOURCE hermes chat --yolo --tui "--query=$prompt")
else
command=(hermes --yolo)
fi
;;
muse)
# --approval-mode never skips the tool prompts but keeps Muse's own sandbox.
command=(muse --approval-mode never)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
;;
omp)
command=(omp --auto-approve)
[[ -n ${prompt:-} ]] && command+=(-- "$prompt")
@@ -107,6 +136,29 @@ pi)
;;
esac
# A new session uses the current selection rather than its parent's account
# pin. Explicit provider homes still override the selection. Carry the new pin
# in the command, since terminals launched through uwsm don't inherit our env.
case "$agent" in
claude) home_env="CLAUDE_CONFIG_DIR" ;;
codex) home_env="CODEX_HOME" ;;
grok) home_env="GROK_HOME" ;;
*) home_env="" ;;
esac
if [[ -n $home_env ]]; then
account_pin="OMARCHY_AGENT_${agent^^}_HOME"
if [[ -v $account_pin && ${!home_env:-} == "${!account_pin}" ]]; then
unset "$home_env"
fi
unset "$account_pin"
if [[ -z ${!home_env:-} ]]; then
home=$(omarchy-agent-account-home "$agent")
account_env=(env -u "$home_env" "$account_pin=$home")
[[ -n $home ]] && account_env+=("$home_env=$home")
command=("${account_env[@]}" "${command[@]}")
fi
fi
if [[ $inline == "true" ]]; then
exec "${command[@]}"
else
+223
View File
@@ -0,0 +1,223 @@
#!/bin/bash
# omarchy:summary=Sign in to a Claude, Codex, or Grok subscription
# omarchy:args=[--check] [--events] [--reauth <account>] [claude|codex|grok] [label]
# omarchy:examples=omarchy agent account add claude | omarchy agent account add Work | omarchy agent account add codex Side
set -uo pipefail
# --check prints, per provider, whether adding one now would be its first
# sign-in, an additional account, or not possible yet. --events is for the
# agents panel: progress arrives as `@@omarchy <event> <detail>` lines mixed
# into the CLI's own output (which carries codes the panel shows), nothing
# waits on a terminal, and the result also lands as a notification in case
# the panel has closed.
# --reauth <account> signs an account that's already here in again, in its
# own home, for when its sign-in has lapsed.
check=false
events=false
reauth=""
while [[ ${1:-} == --* ]]; do
case "$1" in
--check) check=true ;;
--events) events=true ;;
--reauth)
reauth="${2:-}"
shift
;;
*) break ;;
esac
shift
done
# Whether a provider's own home (~/.claude, ~/.codex, ~/.grok) is signed in.
primary_signed_in() {
omarchy-agent-account-state list "$1" | jq -e '.[0].accounts | any(.primary and .signedIn)' >/dev/null
}
if [[ $check == "true" ]]; then
for p in claude codex grok; do
if primary_signed_in "$p"; then
echo "$p additional"
else
echo "$p first"
fi
done
exit 0
fi
# The provider can be left out when it's your default agent:
# `omarchy agent account add Work`.
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
default=$(omarchy-default-agent)
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
fi
case "${1:-}" in
claude) provider="claude"; name="Claude"; site="claude.ai"; package="claude"; home_env="CLAUDE_CONFIG_DIR" ;;
codex) provider="codex"; name="Codex"; site="chatgpt.com"; package="codex"; home_env="CODEX_HOME" ;;
grok) provider="grok"; name="Grok"; site="x.ai"; package="grok"; home_env="GROK_HOME" ;;
*)
echo "Usage: omarchy-agent-account-add [--check] [--events] [--reauth <account>] [claude|codex|grok] [label]" >&2
exit 1
;;
esac
label="${2:-}"
say() {
if [[ $events == "true" ]]; then
printf '@@omarchy status %s\n' "$*"
else
echo "$*"
fi
}
finish() {
if [[ $events == "true" ]]; then
printf '@@omarchy done %s\n' "$1"
omarchy-notification-send -g 󰚩 "$1"
else
echo
echo "$1"
fi
}
fail() {
if [[ $events == "true" ]]; then
printf '@@omarchy error %s\n' "$1"
omarchy-notification-send -g 󰚩 -u critical "$(omarchy-text "Couldn't add the %1 account" "$name")" "$1"
else
echo "$1" >&2
fi
exit 1
}
# Cancelling from the panel stops the CLI's login too, and leaves no
# half-made home behind.
home=""
browser=""
cleanup() {
[[ -n $browser ]] && rm -f "$browser"
[[ -n $home && -d $home && $home == */.pending/* ]] && rm -rf "$home"
}
trap cleanup EXIT
trap 'pkill -TERM -P $$; exit 130' TERM INT
# Bash holds a trap until a foreground command finishes, and a login waits on
# the browser for as long as it takes. Running it in the background behind a
# `wait` lets Cancel stop it at once. It keeps this script's stdin, where the
# panel sends a pasted code.
interruptible() {
"$@" <&0 &
wait $!
}
if omarchy-cmd-missing "$provider"; then
say "$(omarchy-text "Installing the %1 CLI…" "$name")"
if [[ $events == "true" ]]; then
mise use -g "$package" >/dev/null 2>&1 || fail "$(omarchy-text "Installing the %1 CLI didn't finish." "$name")"
else
mise use -g "$package" || exit 1
fi
fi
# Your main browser is almost certainly signed in to the account you already
# have, and a login would quietly reuse it. The CLIs open their login page
# through $BROWSER, so this points it at a private window instead.
private_browser() {
browser=$(mktemp "${XDG_RUNTIME_DIR:-/tmp}/omarchy-account-browser.XXXXXX")
printf '#!/bin/bash\nexec omarchy-launch-browser --private "$1"\n' >"$browser"
chmod +x "$browser"
}
case "$provider" in
claude) login_command=(claude auth login) ;;
codex) login_command=(codex login) ;;
grok) login_command=(grok login) ;;
esac
# The CLI's own home, whatever this shell has pointed it at.
login() {
interruptible env -u CLAUDE_CONFIG_DIR -u CODEX_HOME -u GROK_HOME "OMARCHY_AGENT_${provider^^}_HOME=" "${login_command[@]}"
}
# Signing an account that's already here in again, where it lives: the
# primary home through the normal browser, an added one through a private
# window. Its home is never recreated or removed.
if [[ -n $reauth ]]; then
if [[ $reauth == ":primary" ]]; then
account_home="$HOME/.$provider"
else
account_home=$(omarchy-agent-account-state homes "$provider" | awk -F'\t' -v id="$reauth" '$1 == id { print $2 }')
fi
[[ -n $account_home ]] || fail "$(omarchy-text "No %1 account named %2." "$name" "$reauth")"
if [[ $account_home == "$HOME/.$provider" ]]; then
say "$(omarchy-text "Sign in to %1 again in the browser window that opens." "$name")"
login || fail "$(omarchy-text "The %1 sign-in didn't finish." "$name")"
else
say "$(omarchy-text "Sign in again as this account in the private window that opens.")"
private_browser
interruptible env BROWSER="$browser" "$home_env=$account_home" "${login_command[@]}" || fail "$(omarchy-text "The %1 sign-in didn't finish." "$name")"
fi
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
finish "$(omarchy-text "Signed in to %1 again." "$name")"
exit 0
fi
# The first subscription signs in where the CLI always looks, through your
# normal browser: nothing is signed in there yet that it could be mistaken for.
if ! primary_signed_in "$provider"; then
say "$(omarchy-text "Sign in to %1 in the browser window that opens." "$name")"
if login && primary_signed_in "$provider"; then
# The first agent signed in on a machine with none chosen becomes the
# default, so `omarchy agent` and the panel's starter prompts have one to
# run. A default someone already picked is never touched.
if [[ -z $(omarchy-default-agent) ]]; then
mkdir -p "$HOME/.config/omarchy/defaults"
printf '%s\n' "$provider" >"$HOME/.config/omarchy/defaults/agent"
fi
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
finish "$(omarchy-text "Signed in to %1." "$name")"
else
fail "$(omarchy-text "The %1 sign-in didn't finish." "$name")"
fi
else
if [[ -z $label && $events == "false" && -t 0 ]]; then
label=$(gum input --placeholder "Work" --prompt "Name this $name account: ") || exit 130
fi
if [[ $events == "true" ]]; then
say "$(omarchy-text "Sign in as the account you're adding in the private window that opens.")"
else
cat <<EOF
Sign in as the account you're adding in the private window that opens.
If a private window was already open, close it first, or $site may reuse whoever it has signed in.
Don't log out of the $name CLI to do this: logging out revokes the saved sign-in.
EOF
fi
home=$(omarchy-agent-account-state begin "$provider") || fail "$(omarchy-text "Couldn't set up a home for the new account.")"
private_browser
interruptible env BROWSER="$browser" "$home_env=$home" "${login_command[@]}"
if account=$(omarchy-agent-account-state register "$provider" "$label" "$home" 2>&1); then
home=""
added=$(jq -r '.label + (if .email != "" then " (" + .email + ")" else "" end)' <<<"$account")
omarchy-agent-usage-update --force "$provider" >/dev/null 2>&1 &
if [[ $events == "true" ]]; then
finish "$(omarchy-text "Added %1." "$added")"
else
finish "$(omarchy-text "Added %1." "$added")"
echo "Switch to it with: omarchy agent account use $provider $(jq -r .id <<<"$account")"
fi
else
home=""
fail "$account"
fi
fi
+43
View File
@@ -0,0 +1,43 @@
#!/bin/bash
# omarchy:summary=Run Claude, Codex, or Grok as the selected subscription account
# omarchy:args=<claude|codex|grok> [args...]
# omarchy:hidden=true
set -euo pipefail
case "${1:-}" in
claude) account_env="CLAUDE_CONFIG_DIR" ;;
codex) account_env="CODEX_HOME" ;;
grok) account_env="GROK_HOME" ;;
*)
echo "Usage: omarchy-agent-account-exec <claude|codex|grok> [args...]" >&2
exit 1
;;
esac
provider=$1
shift
# mise removes its wrapper dispatch directories before calling us, so exec
# finds the real CLI (or its first-run installer) rather than this wrapper.
# Pin the resolved home for children, including an implicit Main account.
# Explicit provider homes still take precedence.
if [[ -z ${!account_env:-} ]]; then
account_home=$(omarchy-agent-account-home "$provider")
export "OMARCHY_AGENT_${provider^^}_HOME=$account_home"
if [[ -n $account_home ]]; then
export "$account_env=$account_home"
fi
fi
# Resolve before restoring dispatch on PATH: mise removes the wrapper paths to
# avoid recursion, but the agent's subprocesses need them for other providers.
if ! agent_binary=$(type -P "$provider"); then
# A mise dispatcher can exist before its CLI or first-run installer does.
export MISE_MINIMUM_RELEASE_AGE=0
mise use -g --quiet "$provider"
agent_binary=$(mise which "$provider")
fi
export PATH="${MISE_DATA_DIR:-${XDG_DATA_HOME:-$HOME/.local/share}/mise}/command-wrappers/bin:$PATH"
exec "$agent_binary" "$@"
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# omarchy:summary=Print the config home of the active Claude, Codex, or Grok account
# omarchy:args=<claude|codex|grok>
# omarchy:hidden=true
# Prints nothing while the primary account (~/.claude, ~/.codex) is active, so
# a launch only sets CLAUDE_CONFIG_DIR / CODEX_HOME when there's somewhere
# else to point it. Without a registry, no Python starts at all: this runs on
# every `claude` and `codex` typed at a prompt.
registry="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/accounts/${1:-}.json"
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
echo "Usage: omarchy-agent-account-home <claude|codex|grok>" >&2
exit 1
fi
# A running session pins its home, including an empty value for Main. Claude's
# implicit default also selects ~/.claude.json, so Main must stay implicit.
account_pin="OMARCHY_AGENT_${1^^}_HOME"
if [[ -v $account_pin ]]; then
printf '%s' "${!account_pin}"
elif [[ -f $registry ]]; then
exec omarchy-agent-account-state home "$1"
fi
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# omarchy:summary=List Claude, Codex, and Grok subscription accounts and their limits
# omarchy:args=[claude|codex|grok] [--json]
# omarchy:examples=omarchy agent account list | omarchy agent account list claude --json
set -uo pipefail
provider=""
json=false
for arg in "$@"; do
case "$arg" in
claude | codex | grok) provider="$arg" ;;
--json) json=true ;;
*)
echo "Usage: omarchy-agent-account-list [claude|codex|grok] [--json]" >&2
exit 1
;;
esac
done
usage_dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/usage"
accounts=$(omarchy-agent-account-state list $provider) || exit 1
# Join each account with the limits its provider's usage record last saw. With
# a single account the collectors keep its limits at the record's top level.
accounts=$(jq -c --arg dir "$usage_dir" '
map(. as $p | .accounts |= map(. + {limits: []}))' <<<"$accounts")
for p in claude codex grok; do
record="$usage_dir/$p.json"
[[ -f $record ]] || continue
accounts=$(jq -c --arg p "$p" --slurpfile record "$record" '
map(if .provider == $p then
.accounts |= map(. as $a | . + {limits: (
if ($record[0].accounts // []) | length > 0 then
[$record[0].accounts[] | select(.id == $a.id) | .limits] | first // []
elif $a.primary then
$record[0].limits // []
else
[]
end)})
else . end)' <<<"$accounts")
done
if [[ $json == "true" ]]; then
echo "$accounts"
else
# A provider nobody has signed in to has nothing to list.
for (( i = 0; i < $(jq length <<<"$accounts"); i++ )); do
jq -e ".[$i].accounts | any(.signedIn)" <<<"$accounts" >/dev/null || continue
jq -r ".[$i] | \"\(.name) — \(.switch) switching at \(.threshold)%\"" <<<"$accounts"
jq -r ".[$i].accounts[] |
\"\(if .active then \"*\" else \" \" end)\t\(.id)\t\(.label)\t\(.plan)\t\(.email)\t\" +
([.limits[]? | select(.percent != null) | \"\(.label): \((.percent * 100) | round)%\"] | join(\", \"))" <<<"$accounts" |
column -t -s $'\t' | sed 's/^/ /'
echo
done
fi
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# omarchy:summary=Switch Claude, Codex, or Grok accounts automatically near a limit, or only notify
# omarchy:args=[claude|codex|grok] [manual|auto] [threshold]
# omarchy:examples=omarchy agent account mode auto | omarchy agent account mode auto 90 | omarchy agent account mode codex manual
set -uo pipefail
# The provider can be left out when it's your default agent:
# `omarchy agent account use work`.
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
default=$(omarchy-default-agent)
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
fi
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
echo "Usage: omarchy-agent-account-mode [claude|codex|grok] [manual|auto] [threshold]" >&2
exit 1
fi
if [[ -n ${2:-} ]]; then
omarchy-agent-account-state mode "$@" || exit 1
fi
omarchy-agent-account-state registry "$1" | jq -r '
if .switch == "auto" then
"\(.name) switches new sessions to the account with the most headroom at \(.threshold)%."
else
"\(.name) notifies at \(.threshold)% and switches when you say so."
end'
+30
View File
@@ -0,0 +1,30 @@
#!/bin/bash
# omarchy:summary=Forget an added Claude, Codex, or Grok account
# omarchy:args=[claude|codex|grok] <account>
# omarchy:examples=omarchy agent account remove work | omarchy agent account remove codex side
set -uo pipefail
# The provider can be left out when it's your default agent:
# `omarchy agent account use work`.
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
default=$(omarchy-default-agent)
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
fi
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]] || [[ -z ${2:-} ]]; then
echo "Usage: omarchy-agent-account-remove [claude|codex|grok] <account>" >&2
exit 1
fi
# Removing only deletes the account's home here. It never logs out, which
# would revoke the sign-in for every other copy of it too.
if [[ -t 0 ]] && ! gum confirm "Forget the $1 account $2? You'd have to sign in again to add it back."; then
exit 130
fi
account=$(omarchy-agent-account-state remove "$1" "$2") || exit 1
echo "Forgot $(jq -r .label <<<"$account")."
# The panel reads which accounts exist from the usage record.
omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 &
+24
View File
@@ -0,0 +1,24 @@
#!/bin/bash
# omarchy:summary=Rename a Claude, Codex, or Grok subscription account
# omarchy:args=[claude|codex|grok] <account> <name>
# omarchy:examples=omarchy agent account rename primary Hey | omarchy agent account rename codex side Side project
set -uo pipefail
# The provider can be left out when it's your default agent:
# `omarchy agent account rename primary Hey`.
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
default=$(omarchy-default-agent)
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
fi
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]] || (( $# < 3 )); then
echo "Usage: omarchy-agent-account-rename [claude|codex|grok] <account> <name>" >&2
exit 1
fi
account=$(omarchy-agent-account-state rename "$@") || exit 1
echo "Renamed to $(jq -r .label <<<"$account"). Switch to it with: omarchy agent account use $(jq -r .id <<<"$account")"
# The panel reads account names from the usage record.
omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 &
+794
View File
@@ -0,0 +1,794 @@
#!/usr/bin/python3
# omarchy:summary=Read and change the registry of Claude, Codex, and Grok subscription accounts
# omarchy:args=<registry|home|homes|list|refresh|begin|register|use|rename|remove|mode|link|autoswitch> [provider] [args...]
# omarchy:hidden=true
"""The account registry behind `omarchy agent account`.
An account is a Claude Code, Codex, or Grok login Omarchy keeps a home for.
The login you already have stays where the CLI put it (~/.claude, ~/.codex,
~/.grok) and
is the primary account; added accounts get a home of their own under
~/.local/state/omarchy/agents/accounts/<provider>/<id>/ that holds only that
account's credentials and links everything else back to the primary home, so
conversation history, settings and skills stay one set across accounts.
Each provider's registry is one JSON file beside those homes. It records which
account is active — the one new sessions start as — how switching happens, and
display-safe identity. Tokens never enter it and never leave this process.
"""
from __future__ import annotations
import base64
import datetime as dt
import fcntl
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
PROVIDERS = {
"claude": {
"name": "Claude",
"home_env": "CLAUDE_CONFIG_DIR",
# Everything a session reads or writes that isn't tied to the login. A
# missing directory is created in the primary home first; a missing file
# is linked anyway, so whichever account writes it first writes it there.
"shared_dirs": ["projects", "skills", "agents", "commands", "plugins", "hooks", "themes", "output-styles", "file-history", "todos", "plans"],
"shared_files": ["settings.json", "CLAUDE.md", "history.jsonl", "keybindings.json"],
# Keys copied from the primary's ~/.claude.json into a new account's, so a
# second login keeps MCP servers, folder trust and onboarding choices.
"carried_keys": ["mcpServers", "projects", "theme", "hasCompletedOnboarding", "lastOnboardingVersion", "editorMode"],
},
"codex": {
"name": "Codex",
"home_env": "CODEX_HOME",
"shared_dirs": ["sessions", "archived_sessions", "prompts", "skills", "rules", "plugins"],
"shared_files": ["config.toml", "AGENTS.md", "history.jsonl", "hooks.json"],
"carried_keys": [],
},
"grok": {
"name": "Grok",
"home_env": "GROK_HOME",
"shared_dirs": ["sessions", "skills", "agents", "plugins", "installed-plugins", "marketplace-cache", "hooks", "memory", "memory-v2", "rules", "commands", "workflows", "personas", "worktrees"],
"shared_files": ["config.toml", "settings.json", "lsp.json", "pager.toml", "sandbox.toml", "trusted_folders.toml", "statusline.sh", "worktrees.db"],
"carried_keys": [],
},
}
DEFAULT_THRESHOLD = 95
# Ids an account can't take because something else already answers to them:
# `use next` cycles, a provider name is read as the provider when it comes
# first, and the menu's Add Account row is `.add`.
RESERVED_IDS = {"next", "add", "claude", "codex", "grok", "primary"}
SWITCH_MODES = ("manual", "auto")
class AccountError(Exception):
pass
def text(english: str, *args: object) -> str:
"""English with %1.. filled, in the session's language, from the shell's table."""
return subprocess.run(["omarchy-text", english, *map(str, args)], capture_output=True, text=True, check=True).stdout.rstrip("\n")
# ------------------------------------------------------------------- paths
def state_root() -> Path:
return Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state") / "omarchy" / "agents"
def accounts_root() -> Path:
return state_root() / "accounts"
def registry_path(provider: str) -> Path:
return accounts_root() / f"{provider}.json"
def usage_record_path(provider: str) -> Path:
return state_root() / "usage" / f"{provider}.json"
# The primary home is where the CLI keeps a login when nothing tells it
# otherwise. It is deliberately not read from CLAUDE_CONFIG_DIR / CODEX_HOME /
# GROK_HOME:
# those are how an added account is selected, so honoring them here would make
# whichever account launched this command look like the primary.
def primary_home(provider: str) -> Path:
return Path.home() / f".{provider}"
def account_home(provider: str, account: dict[str, Any]) -> Path:
home = str(account.get("home") or "")
return Path(home) if home else primary_home(provider)
# Claude keeps its account file beside the config dir for the default home
# (~/.claude.json) and inside it for any other.
def claude_account_file(home: Path) -> Path:
if home == primary_home("claude"):
return Path.home() / ".claude.json"
return home / ".claude.json"
# --------------------------------------------------------------- identity
def read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def plan_label(tier: str, subscription: str) -> str:
match = re.search(r"max_(\d+x)", tier or "", re.IGNORECASE)
if match:
return "Max " + match.group(1)
if subscription:
return subscription[0].upper() + subscription[1:]
return ""
def jwt_claims(token: str) -> dict[str, Any]:
try:
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4)
claims = json.loads(base64.urlsafe_b64decode(payload.encode("ascii")))
return claims if isinstance(claims, dict) else {}
except Exception:
return {}
# Who a home is signed in as, from files the CLI wrote at login. Only
# display-safe fields come out; an empty accountId means nobody is signed in.
def identity(provider: str, home: Path) -> dict[str, str]:
if provider == "claude":
account = (read_json(claude_account_file(home)) or {}).get("oauthAccount") or {}
login = (read_json(home / ".credentials.json") or {}).get("claudeAiOauth") or {}
return {
"accountId": str(account.get("accountUuid") or ""),
"email": str(account.get("emailAddress") or ""),
"org": str(account.get("organizationName") or ""),
"plan": plan_label(str(login.get("rateLimitTier") or ""), str(login.get("subscriptionType") or "")),
}
if provider == "grok":
return grok_identity(home)
auth = read_json(home / "auth.json") or {}
tokens = auth.get("tokens") or {}
claims = jwt_claims(str(tokens.get("id_token") or ""))
openai = claims.get("https://api.openai.com/auth") or {}
plan = str(openai.get("chatgpt_plan_type") or "")
return {
"accountId": str(tokens.get("account_id") or openai.get("chatgpt_account_id") or ""),
"email": str(claims.get("email") or ""),
"org": "",
"plan": plan[0].upper() + plan[1:] if plan else "",
}
# Grok keeps one login per issuer in auth.json, and the plan only in the
# settings it caches from xAI, as a JSON string inside that file.
def grok_identity(home: Path) -> dict[str, str]:
auth = read_json(home / "auth.json") or {}
login = next((v for v in auth.values() if isinstance(v, dict)), {}) if isinstance(auth, dict) else {}
try:
settings = json.loads((read_json(home / "settings_cache.json") or {}).get("payload") or "{}").get("settings") or {}
except Exception:
settings = {}
return {
"accountId": str(login.get("user_id") or ""),
"email": str(login.get("email") or ""),
"org": "",
"plan": str(settings.get("subscription_tier_display") or ""),
}
# ---------------------------------------------------------------- registry
def slug(label: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")
def empty_registry(provider: str) -> dict[str, Any]:
return {"active": "main", "switch": "manual", "threshold": DEFAULT_THRESHOLD, "alert": "", "accounts": []}
def primary_entry(provider: str) -> dict[str, Any]:
entry = {"id": "main", "label": "Main", "home": "", "primary": True}
entry.update(identity(provider, primary_home(provider)))
return entry
def normalized(provider: str, data: Any) -> dict[str, Any]:
registry = empty_registry(provider)
if isinstance(data, dict):
registry.update({key: data[key] for key in registry if key in data})
accounts = [a for a in registry["accounts"] if isinstance(a, dict) and a.get("id")]
if not any(a.get("primary") for a in accounts):
accounts.insert(0, primary_entry(provider))
registry["accounts"] = accounts
if registry["switch"] not in SWITCH_MODES:
registry["switch"] = "manual"
try:
registry["threshold"] = max(50, min(100, int(registry["threshold"])))
except Exception:
registry["threshold"] = DEFAULT_THRESHOLD
if not find(registry, registry["active"]):
registry["active"] = accounts[0]["id"]
return registry
def load(provider: str) -> dict[str, Any]:
return normalized(provider, read_json(registry_path(provider)))
def save(provider: str, registry: dict[str, Any]) -> None:
path = registry_path(provider)
path.parent.mkdir(parents=True, exist_ok=True)
os.chmod(path.parent, 0o700)
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(registry, handle, indent=2)
handle.write("\n")
os.chmod(tmp, 0o600)
os.replace(tmp, path)
except Exception:
Path(tmp).unlink(missing_ok=True)
raise
# Every change is read-modify-write under one lock per provider, so the panel,
# the autoswitch run after each usage update, and a command typed at a prompt
# can't interleave and drop each other's edits.
class Locked:
def __init__(self, provider: str):
self.provider = provider
def __enter__(self) -> dict[str, Any]:
accounts_root().mkdir(parents=True, exist_ok=True)
self.handle = open(accounts_root() / f".{self.provider}.lock", "w")
fcntl.flock(self.handle, fcntl.LOCK_EX)
self.registry = load(self.provider)
return self.registry
def __exit__(self, kind, value, traceback) -> None:
try:
if kind is None:
save(self.provider, self.registry)
finally:
fcntl.flock(self.handle, fcntl.LOCK_UN)
self.handle.close()
def find(registry: dict[str, Any], account_id: str) -> dict[str, Any] | None:
for account in registry["accounts"]:
if account.get("id") == account_id:
return account
# `primary` names the login that was there first, whatever it's called now.
if account_id == "primary":
for account in registry["accounts"]:
if account.get("primary"):
return account
return None
def active(registry: dict[str, Any]) -> dict[str, Any]:
return find(registry, registry["active"]) or registry["accounts"][0]
# ------------------------------------------------------------------- homes
# Lay the links that make an added account share everything but its login
# with the primary home. Safe to repeat: an existing link or real file is left
# as it is, so nothing an account wrote for itself is ever replaced.
def link_shared(provider: str, home: Path) -> None:
spec = PROVIDERS[provider]
primary = primary_home(provider)
home.mkdir(parents=True, exist_ok=True)
os.chmod(home, 0o700)
for name in spec["shared_dirs"]:
(primary / name).mkdir(parents=True, exist_ok=True)
for name in spec["shared_dirs"] + spec["shared_files"]:
target = home / name
if target.exists() or target.is_symlink():
continue
target.symlink_to(primary / name)
def carry_settings(provider: str, home: Path) -> None:
keys = PROVIDERS[provider]["carried_keys"]
if not keys:
return
source = read_json(claude_account_file(primary_home(provider))) or {}
path = claude_account_file(home)
data = read_json(path) or {}
for key in keys:
if key in source and key not in data:
data[key] = source[key]
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(data, handle, indent=2)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
# A login in progress gets a scratch home beside the real ones, so an
# abandoned or duplicate login never shows up as an account.
def begin(provider: str) -> Path:
pending = accounts_root() / provider / ".pending"
pending.mkdir(parents=True, exist_ok=True)
os.chmod(pending.parent, 0o700)
home = Path(tempfile.mkdtemp(prefix="login-", dir=str(pending)))
link_shared(provider, home)
return home
def register(provider: str, label: str, pending: Path) -> dict[str, Any]:
pending = pending.resolve()
if pending.parent != (accounts_root() / provider / ".pending").resolve():
raise AccountError(text("%1 is not a pending %2 login", pending, PROVIDERS[provider]['name']))
found = identity(provider, pending)
if not found["accountId"]:
shutil.rmtree(pending, ignore_errors=True)
raise AccountError(text("The login didn't finish, so no account was added."))
with Locked(provider) as registry:
# Each home is asked who it is now, not trusted from when it was added: a
# `claude auth login` run by hand in ~/.claude changes the primary.
for account in registry["accounts"]:
account.update(identity(provider, account_home(provider, account)))
for account in registry["accounts"]:
if account.get("accountId") == found["accountId"]:
shutil.rmtree(pending, ignore_errors=True)
raise AccountError(text(
"That's %1 (%2). Try again, and sign in as the other account in the private window.",
account["label"], found["email"] or text("already added"),
))
label = label.strip() or found["email"].split("@")[0] or "Account"
account_id = unique_id(provider, registry, label)
# Everything that can fail happens while the login is still pending, where
# the add command cleans it up, or is undone: a home that isn't in the
# registry would keep a sign-in nothing can manage.
carry_settings(provider, pending)
home = accounts_root() / provider / account_id
pending.rename(home)
entry = {"id": account_id, "label": label, "home": str(home), "primary": False}
entry.update(found)
registry["accounts"].append(entry)
try:
save(provider, registry)
except Exception:
registry["accounts"].pop()
home.rename(pending)
raise
return entry
# The id an account answers to, from its label. It can't clash with another
# account, a routing keyword, or a home left under that name by an account
# that has since been renamed.
def unique_id(provider: str, registry: dict[str, Any], label: str, keep: dict[str, Any] | None = None) -> str:
base = slug(label) or "account"
account_id, n = base, 2
while True:
taken = find(registry, account_id)
if account_id not in RESERVED_IDS and (taken is None or taken is keep):
if keep is not None or not (accounts_root() / provider / account_id).exists():
return account_id
account_id, n = f"{base}-{n}", n + 1
# A new label, and the id that follows from it. The home stays where it is:
# sessions already running in it hold that path.
def rename(provider: str, target: str, label: str) -> dict[str, Any]:
label = label.strip()
if not label:
raise AccountError(text("Give the account a name"))
with Locked(provider) as registry:
account = find(registry, target)
if not account:
raise AccountError(text("No %1 account named %2", PROVIDERS[provider]['name'], target))
new_id = unique_id(provider, registry, label, keep=account)
if not account.get("primary") and not account.get("home"):
account["home"] = str(account_home(provider, account))
if registry["active"] == account["id"]:
registry["active"] = new_id
registry["alert"] = ""
account["id"] = new_id
account["label"] = label
return dict(account)
# Save who each home is signed in as now. A login run in a home, through the
# panel or the CLI, changes it, and the usage records take their names and
# emails from here.
def refresh(provider: str) -> None:
with Locked(provider) as registry:
for account in registry["accounts"]:
account.update(identity(provider, account_home(provider, account)))
def use(provider: str, target: str) -> tuple[dict[str, Any], dict[str, Any]]:
with Locked(provider) as registry:
previous = active(registry)
if target == "next":
ids = [a["id"] for a in registry["accounts"]]
target = ids[(ids.index(previous["id"]) + 1) % len(ids)]
account = find(registry, target)
if not account:
raise AccountError(text("No %1 account named %2", PROVIDERS[provider]['name'], target))
registry["active"] = account["id"]
registry["alert"] = ""
if not account.get("primary"):
link_shared(provider, account_home(provider, account))
return previous, account
# Whether a running session was started in this home. Removing it would pull
# the login out from under that session, which must never be touched.
def home_in_use(provider: str, home: Path) -> bool:
wanted = f"{PROVIDERS[provider]['home_env']}=".encode()
resolved = home.resolve()
for environ in Path("/proc").glob("[0-9]*/environ"):
try:
entries = environ.read_bytes().split(b"\0")
except OSError:
continue
for entry in entries:
if entry.startswith(wanted) and Path(entry[len(wanted):].decode(errors="replace")).resolve() == resolved:
return True
return False
def remove(provider: str, target: str) -> dict[str, Any]:
with Locked(provider) as registry:
account = find(registry, target)
if not account:
raise AccountError(text("No %1 account named %2", PROVIDERS[provider]['name'], target))
if account.get("primary"):
raise AccountError(text("%1 is the login in %2; it can't be removed here.", account["label"], primary_home(provider)))
home = account_home(provider, account)
if home_in_use(provider, home):
raise AccountError(text("A running %1 session uses %2. Quit it first, then remove the account.", PROVIDERS[provider]['name'], account["label"]))
# Only the account's own directory goes. Its links point into the primary
# home, and rmtree removes a link without following it.
if home.resolve().parent == (accounts_root() / provider).resolve():
shutil.rmtree(home, ignore_errors=True)
registry["accounts"] = [a for a in registry["accounts"] if a["id"] != account["id"]]
if registry["active"] == account["id"]:
registry["active"] = registry["accounts"][0]["id"]
return account
def set_mode(provider: str, mode: str, threshold: str | None) -> dict[str, Any]:
if mode not in SWITCH_MODES:
raise AccountError(text("Switch mode is manual or auto"))
with Locked(provider) as registry:
registry["switch"] = mode
if threshold is not None:
try:
registry["threshold"] = max(50, min(100, int(threshold)))
except ValueError:
raise AccountError(text("The threshold is a percentage between 50 and 100"))
registry["alert"] = ""
return dict(registry)
# ------------------------------------------------------------- autoswitch
def window_open(limit: dict[str, Any], now: dt.datetime) -> bool:
raw = str(limit.get("resetsAt") or "")
if not raw:
return True
try:
resets_at = dt.datetime.fromisoformat(raw.replace("Z", "+00:00"))
except Exception:
return True
if resets_at.tzinfo is None:
resets_at = resets_at.replace(tzinfo=dt.timezone.utc)
return resets_at > now
# How spent an account is: its fullest window that hasn't reset yet, in
# percent. A window whose reset has passed counts as empty even when the
# numbers are stale, since that's exactly what a reset means. None when
# nothing is known at all.
def peak(limits: Any, now: dt.datetime) -> float | None:
if not isinstance(limits, list):
return None
known = [l for l in limits if isinstance(l, dict) and isinstance(l.get("percent"), (int, float))]
if not known:
return None
open_windows = [float(l["percent"]) * 100 for l in known if window_open(l, now)]
return max(open_windows) if open_windows else 0.0
def next_reset(limits: Any, now: dt.datetime) -> dt.datetime | None:
times = []
for limit in limits if isinstance(limits, list) else []:
try:
when = dt.datetime.fromisoformat(str(limit.get("resetsAt") or "").replace("Z", "+00:00"))
except Exception:
continue
if when.tzinfo is None:
when = when.replace(tzinfo=dt.timezone.utc)
if when > now:
times.append(when)
return min(times) if times else None
# When an account over the threshold drops back under it: once every window
# holding it there has reset, which is the latest of those resets. A 10%
# session resetting in an hour doesn't help an account whose weekly window is
# full for six more days. None when a blocking window never says.
def available_at(limits: Any, threshold: float, now: dt.datetime) -> dt.datetime | None:
latest = None
for limit in limits if isinstance(limits, list) else []:
if not isinstance(limit, dict) or not isinstance(limit.get("percent"), (int, float)):
continue
if float(limit["percent"]) * 100 < threshold or not window_open(limit, now):
continue
try:
when = dt.datetime.fromisoformat(str(limit.get("resetsAt") or "").replace("Z", "+00:00"))
except Exception:
return None
if when.tzinfo is None:
when = when.replace(tzinfo=dt.timezone.utc)
latest = when if latest is None or when > latest else latest
return latest
def duration(delta: dt.timedelta) -> str:
minutes = max(1, int(delta.total_seconds() // 60))
hours, minutes = divmod(minutes, 60)
days, hours = divmod(hours, 24)
if days:
return text("%1d %2h", days, hours)
if hours:
return text("%1h %2m", hours, minutes)
return text("%1m", minutes)
# The whole switching policy, with no I/O: given the registry and the per
# account limits from the usage record, what should happen. Returns None when
# nothing should, or {"action": "switch"|"notify"|"exhausted", ...}.
#
# Only the active account crossing the threshold starts anything, so auto
# mode never flaps back to an account that just reset. `alert` remembers the
# last thing said, so a shell restart or the next refresh doesn't repeat it.
#
# Numbers kept from an earlier check may be out of date, but a parked account's
# sign-in lapses within hours and can't be checked again until a session runs
# in it. So a stale account is still a candidate: among accounts with room, one
# checked just now wins, but room comes first. An account within 15 points of
# the threshold (where the panel starts checking more often) is near its limit
# however fresh its numbers are.
def decide(registry: dict[str, Any], usage: dict[str, Any], now: dt.datetime, stale: frozenset[str] = frozenset()) -> dict[str, Any] | None:
threshold = float(registry["threshold"])
current = active(registry)
current_peak = peak(usage.get(current["id"]), now)
if current_peak is None or current_peak < threshold:
return {"action": "clear"} if registry.get("alert") else None
candidates = []
for account in registry["accounts"]:
if account["id"] == current["id"]:
continue
level = peak(usage.get(account["id"]), now)
if level is None or level >= threshold:
continue
reset = next_reset(usage.get(account["id"]), now) or dt.datetime.max.replace(tzinfo=dt.timezone.utc)
candidates.append((level >= threshold - 15, account["id"] in stale, level, reset, account))
candidates.sort(key=lambda c: c[:4])
if candidates:
_, _, level, _, target = candidates[0]
action = "switch" if registry["switch"] == "auto" else "notify"
alert = f"{action}:{current['id']}:{target['id']}"
if registry.get("alert") == alert:
return None
return {"action": action, "alert": alert, "from": current, "to": target, "fromPeak": current_peak, "toPeak": level}
alert = f"exhausted:{current['id']}"
if registry.get("alert") == alert or len(registry["accounts"]) < 2:
return None
# Only when every account is known to be over. One that's signed out or
# couldn't be checked might have room, so there's nothing honest to say.
if any(peak(usage.get(a["id"]), now) is None for a in registry["accounts"]):
return None
soonest = None
for account in registry["accounts"]:
reset = available_at(usage.get(account["id"]), threshold, now)
if reset and (soonest is None or reset < soonest[0]):
soonest = (reset, account)
return {"action": "exhausted", "alert": alert, "from": current, "fromPeak": current_peak, "soonest": soonest}
# Limits per account id. An account nobody is signed in to is left out, so it
# is never a candidate: its cached headroom is no use to a session that can't
# start. One whose token merely expired stays in, since a session refreshes it.
def usage_by_account(provider: str) -> tuple[dict[str, Any], frozenset[str]]:
record = read_json(usage_record_path(provider)) or {}
accounts = [
a for a in record.get("accounts") or []
if isinstance(a, dict) and a.get("id") and a.get("usageStatusText") != "Waiting for auth"
]
return (
{str(a["id"]): a.get("limits") for a in accounts},
frozenset(str(a["id"]) for a in accounts if a.get("stale") is True),
)
def notify(title: str, body: str, action: list[str] | None = None) -> None:
command = ["omarchy-notification-send", "--app-name", "Omarchy Agents", "-g", "󰚩", title, body]
if action:
command += ["--exec"] + action
subprocess.run(command, check=False)
# Returns True when the active account changed.
def autoswitch(provider: str, now: dt.datetime | None = None) -> bool:
now = now or dt.datetime.now(dt.timezone.utc)
name = PROVIDERS[provider]["name"]
with Locked(provider) as registry:
if len(registry["accounts"]) < 2:
return False
usage, stale = usage_by_account(provider)
decision = decide(registry, usage, now, stale)
if decision is None:
return False
if decision["action"] == "clear":
registry["alert"] = ""
return False
registry["alert"] = decision["alert"]
source = decision["from"]
threshold = registry["threshold"]
if decision["action"] == "exhausted":
soonest = decision["soonest"]
staying = text("Staying on %1.", source["label"])
if soonest:
staying = text("Staying on %1. %2 resets in %3.", source["label"], soonest[1]["label"], duration(soonest[0] - now))
notify(text("All %1 accounts are over %2%", name, threshold), staying)
return False
target = decision["to"]
headline = text("%1 is at %2% of a %3 limit", source["label"], round(decision["fromPeak"]), name)
if decision["action"] == "switch":
registry["active"] = target["id"]
if not target.get("primary"):
link_shared(provider, account_home(provider, target))
notify(headline, text("New %1 sessions now use %2 (%3%). Running sessions stay on %4.", name, target["label"], round(decision["toPeak"]), source["label"]))
return True
notify(
headline,
text("%1 is at %2%. Click to switch new sessions to it.", target["label"], round(decision["toPeak"])),
["omarchy-agent-account-use", provider, target["id"]],
)
return False
# --------------------------------------------------------------------- CLI
def provider_arg(args: list[str]) -> str:
if not args or args[0] not in PROVIDERS:
raise AccountError("Name a provider: claude, codex, or grok")
return args[0]
def summary(provider: str, registry: dict[str, Any]) -> dict[str, Any]:
# Identity is re-read from each home rather than trusted from the registry:
# a `claude auth login` run by hand in a home changes who it is.
for account in registry["accounts"]:
account.update(identity(provider, account_home(provider, account)))
return {
"provider": provider,
"name": PROVIDERS[provider]["name"],
"active": registry["active"],
"switch": registry["switch"],
"threshold": registry["threshold"],
"accounts": [
{key: account.get(key, "") for key in ("id", "label", "email", "org", "plan", "primary")}
| {
"home": str(account_home(provider, account)),
"active": account["id"] == registry["active"],
"signedIn": bool(account.get("accountId")),
}
for account in registry["accounts"]
],
}
def main(argv: list[str]) -> int:
if not argv:
print(__doc__.strip().splitlines()[0], file=sys.stderr)
return 1
command, args = argv[0], argv[1:]
try:
if command == "registry":
provider = provider_arg(args)
print(json.dumps(summary(provider, load(provider))))
elif command == "list":
providers = [provider_arg(args)] if args else list(PROVIDERS)
print(json.dumps([summary(p, load(p)) for p in providers]))
elif command == "home":
# Empty for the primary, so launching it needs no environment at all.
provider = provider_arg(args)
registry = read_json(registry_path(provider))
if registry:
account = active(normalized(provider, registry))
if not account.get("primary"):
print(account_home(provider, account))
elif command == "homes":
provider = provider_arg(args)
registry = load(provider)
for account in registry["accounts"]:
print("\t".join([account["id"], str(account_home(provider, account)), "1" if account["id"] == registry["active"] else "0"]))
elif command == "refresh":
for provider in ([provider_arg(args)] if args else list(PROVIDERS)):
if registry_path(provider).exists():
refresh(provider)
elif command == "begin":
print(begin(provider_arg(args)))
elif command == "register":
provider = provider_arg(args)
if len(args) < 3:
raise AccountError("register <provider> <label> <pending-home>")
print(json.dumps(register(provider, args[1], Path(args[2]))))
elif command == "use":
provider = provider_arg(args)
previous, account = use(provider, args[1] if len(args) > 1 else "next")
print(json.dumps({"previous": previous, "active": account}))
elif command == "remove":
provider = provider_arg(args)
if len(args) < 2:
raise AccountError("remove <provider> <account>")
print(json.dumps(remove(provider, args[1])))
elif command == "rename":
provider = provider_arg(args)
if len(args) < 3:
raise AccountError("rename <provider> <account> <label>")
print(json.dumps(rename(provider, args[1], " ".join(args[2:]))))
elif command == "mode":
provider = provider_arg(args)
if len(args) < 2:
raise AccountError("mode <provider> <manual|auto> [threshold]")
set_mode(provider, args[1], args[2] if len(args) > 2 else None)
elif command == "link":
provider = provider_arg(args)
link_shared(provider, Path(args[1]))
elif command == "autoswitch":
# Prints each provider whose active account changed, so the caller
# knows which usage records to collect again.
for provider in ([provider_arg(args)] if args else list(PROVIDERS)):
if registry_path(provider).exists() and autoswitch(provider):
print(provider)
else:
raise AccountError(f"Unknown command: {command}")
except AccountError as error:
print(error, file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:]))
+39
View File
@@ -0,0 +1,39 @@
#!/bin/bash
# omarchy:summary=Choose which Claude, Codex, or Grok account new sessions use
# omarchy:args=[claude|codex|grok] [account|next]
# omarchy:examples=omarchy agent account use work | omarchy agent account use main | omarchy agent account use codex next
set -uo pipefail
# The provider can be left out when it's your default agent:
# `omarchy agent account use work`.
if [[ ${1:-} != "claude" && ${1:-} != "codex" && ${1:-} != "grok" ]]; then
default=$(omarchy-default-agent)
[[ $default == "claude" || $default == "codex" || $default == "grok" ]] && set -- "$default" "$@"
fi
case "${1:-}" in
claude) name="Claude" ;;
codex) name="Codex" ;;
grok) name="Grok" ;;
*)
echo "Usage: omarchy-agent-account-use [claude|codex|grok] [account|next]" >&2
exit 1
;;
esac
result=$(omarchy-agent-account-state use "$1" "${2:-next}") || exit 1
previous=$(jq -r .previous.label <<<"$result")
active=$(jq -r '.active.label + (if (.active.plan // "") != "" then " (" + .active.plan + ")" else "" end)' <<<"$result")
if [[ $(jq -r .previous.id <<<"$result") == "$(jq -r .active.id <<<"$result")" ]]; then
echo "New $name sessions already use $active."
else
message=$(omarchy-text "Running sessions stay on %1." "$previous")
echo "New $name sessions now use $active. $message"
omarchy-notification-send -g 󰚩 "$(omarchy-text "New %1 sessions now use %2" "$name" "$active")" "$message"
# The usage record's own limits describe the active account.
omarchy-agent-usage-update --limits-only "$1" >/dev/null 2>&1 &
fi
+498 -110
View File
@@ -22,6 +22,7 @@ import json
import os
import re
import sqlite3
import subprocess
import sys
import tempfile
import time
@@ -32,9 +33,18 @@ from typing import Any
AGENT_ID = "claude"
AGENT_NAME = "Claude Code"
def text(english, *args):
"""English with %1.. filled, in the session's language, from the shell's table."""
return subprocess.run(["omarchy-text", english, *map(str, args)], capture_output=True, text=True, check=True).stdout.rstrip("\n")
AUTH_HELP = "Run `claude auth login` to restore authoritative usage."
USAGE_ENDPOINT = "https://api.anthropic.com/api/oauth/usage"
PROBE_MIN_INTERVAL_SECONDS = 15
# Anthropic rate-limits its usage endpoint readily. A refused re-check of
# numbers this recent still describes the account; only older ones are
# reported as stale.
CURRENT_ENOUGH_SECONDS = 900
def config_dir() -> Path:
@@ -118,12 +128,209 @@ def empty_bucket() -> dict[str, int]:
# ---------------------------------------------------------------- local scan
def scan_projects(projects_path: Path) -> dict[str, Any]:
# Transcripts are append-only, and most of the corpus never changes between
# ticks, so the scan keeps a per-file index: the unique usage records already
# parsed out of each file plus the byte offset they end at. A file whose size
# and mtime match the index is not opened; a file that grew is read from the
# stored offset; anything else (truncated, rewritten, replaced, unknown) is
# read from the start. Without this the whole history was re-parsed every
# refresh, and the cost grew for the life of the machine.
INDEX_VERSION = 4
# How much of each end of what was read the index remembers a hash of, so a
# file replaced or rewritten with more than it had is not mistaken for an
# append.
INDEX_HEAD_BYTES = 4096
# Records hold local days, so an index is only true in the timezone that
# wrote it; after a change every transcript is read again.
def index_zone() -> list[Any]:
return [os.environ.get("TZ"), list(time.tzname), time.timezone, time.altzone]
def load_index(index_file: Path) -> dict[str, Any]:
try:
data = json.loads(index_file.read_text(encoding="utf-8"))
if (
isinstance(data, dict)
and data.get("version") == INDEX_VERSION
and data.get("zone") == index_zone()
and isinstance(data.get("files"), dict)
):
return data["files"]
except Exception:
pass
return {}
# What was already read, by its first and last few kilobytes: a transcript
# rewritten in place rather than appended to changes one or the other.
def read_digest(path: Path, length: int) -> str:
digest = hashlib.sha1()
with path.open("rb") as handle:
digest.update(handle.read(min(length, INDEX_HEAD_BYTES)))
handle.seek(max(0, length - INDEX_HEAD_BYTES))
digest.update(handle.read(min(length, INDEX_HEAD_BYTES)))
return digest.hexdigest()
def parse_usage_records(path: Path, entry: dict[str, Any] | None) -> dict[str, Any]:
"""Return the index entry for one transcript, reusing `entry` where the file
only grew. Records are [key, model, day, input, output, cacheRead,
cacheWrite, session]; `day` is None when the line carries no timestamp."""
stat = path.stat()
size = stat.st_size
mtime = stat.st_mtime_ns
if entry and entry.get("size") == size and entry.get("mtime") == mtime:
return entry
# Only the same file, grown past what was read with what was read still in
# place, is an append. A transcript replaced by a larger one is a new file,
# and one rewritten in place has a different start or end.
if (
entry
and size > number(entry.get("size"))
and entry.get("file") == [stat.st_dev, stat.st_ino]
and entry.get("head") == read_digest(path, number(entry.get("offset")))
):
records = list(entry.get("records") or [])
offset = number(entry.get("offset"))
line_number = number(entry.get("lines"))
else:
records = []
offset = 0
line_number = 0
# A streamed response is several lines sharing one message id, and the
# first line's output_tokens is a placeholder while the last carries the
# real count. Keep each message's line with the highest output count (the
# last one read wins a tie), whole: a response that fell back to another
# model mid-stream has lines that are separate snapshots, and mixing fields
# across them over-counts cache tokens and credits the wrong model.
positions = {record[0]: index for index, record in enumerate(records)}
with path.open("rb") as handle:
handle.seek(offset)
for raw in handle:
if not raw.endswith(b"\n"):
# A line still being written; pick it up whole next time.
break
offset += len(raw)
line_number += 1
# Cheap pre-filter before JSON parsing keeps files with unrelated
# lines inexpensive.
if b'"usage":' not in raw:
continue
try:
parsed = json.loads(raw.decode("utf-8", errors="replace"))
except Exception:
continue
message = parsed.get("message") if isinstance(parsed.get("message"), dict) else {}
if parsed.get("type") != "assistant" and message.get("role") != "assistant":
continue
usage = message.get("usage") or parsed.get("usage")
if not isinstance(usage, dict):
continue
message_id = message.get("id") or parsed.get("messageId") or ""
key = str(message_id) if message_id else f"{path}:{parsed.get('uuid') or parsed.get('requestId') or line_number}"
input_tokens = usage_token(usage, "input_tokens", "inputTokens")
output_tokens = usage_token(usage, "output_tokens", "outputTokens")
cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens")
cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens")
if input_tokens + output_tokens + cache_read + cache_write <= 0:
continue
held = positions.get(key)
if held is not None and output_tokens < records[held][4]:
continue
timestamp = parsed.get("timestamp") or message.get("timestamp")
record = [
key,
str(message.get("model") or parsed.get("model") or "claude"),
local_date_from_timestamp(timestamp) if timestamp is not None else None,
input_tokens,
output_tokens,
cache_read,
cache_write,
str(parsed.get("sessionId") or path),
]
if held is None:
positions[key] = len(records)
records.append(record)
else:
records[held] = record
return {
"size": size,
"file": [stat.st_dev, stat.st_ino],
"mtime": mtime,
"head": read_digest(path, offset),
"offset": offset,
"lines": line_number,
"records": records,
}
def scan_projects(projects_path: Path, index_file: Path | None = None) -> dict[str, Any]:
previous = load_index(index_file) if index_file else {}
current: dict[str, Any] = {}
# Directory order, as before: when one API message shows up in several
# transcripts with the same output count, the last file visited wins, and
# the numbers must not move because the scan got faster.
order: list[str] = []
unreadable_count = 0
unreadable_examples: list[str] = []
files = projects_path.rglob("*.jsonl") if projects_path.is_dir() else []
for path in files:
key = str(path)
try:
current[key] = parse_usage_records(path, previous.get(key))
except Exception as exc:
unreadable_count += 1
if len(unreadable_examples) < 3:
unreadable_examples.append(f"{path}: {exc}")
# What was read before is still true of the file.
if key in previous:
current[key] = previous[key]
if key in current:
order.append(key)
if unreadable_count:
examples = "; ".join(unreadable_examples)
print(
f"Ignoring {unreadable_count} unreadable Claude project files"
f" (examples: {examples})",
file=sys.stderr,
)
if index_file and current != previous:
try:
write_json(index_file, {"version": INDEX_VERSION, "zone": index_zone(), "files": current})
except Exception as exc:
print(f"Could not write Claude scan index {index_file}: {exc}", file=sys.stderr)
return summarize_records(current, order)
def summarize_records(indexed: dict[str, Any], order: list[str]) -> dict[str, Any]:
today = local_date_string()
recent_dates = recent_date_strings()
recent = {day: {"date": day, "messageCount": 0} for day in recent_dates}
seen: set[str] = set()
# One API message can show up in several transcripts (a resumed session
# carries the earlier ones). As within a file, the record with the highest
# output count wins, the last one visited breaking a tie.
seen: dict[str, list[Any]] = {}
sessions: set[str] = set()
active_days: set[str] = set()
today_sessions: set[str] = set()
@@ -133,68 +340,38 @@ def scan_projects(projects_path: Path) -> dict[str, Any]:
today_prompt_count = 0
today_token_total = 0
files = projects_path.rglob("*.jsonl") if projects_path.is_dir() else []
for path in files:
try:
with path.open("r", encoding="utf-8", errors="replace") as handle:
for line_number, line in enumerate(handle, 1):
# Cheap pre-filter before JSON parsing keeps files with unrelated
# lines inexpensive.
if '"usage":' not in line:
continue
for key in order:
for record in indexed[key].get("records") or []:
held = seen.get(record[0])
if held is not None and record[4] < held[4]:
continue
seen[record[0]] = record
try:
entry = json.loads(line)
except Exception:
continue
for _, model, day, input_tokens, output_tokens, cache_read, cache_write, session_key in seen.values():
if day is None:
day = today
total = input_tokens + output_tokens + cache_read + cache_write
message = entry.get("message") if isinstance(entry.get("message"), dict) else {}
if entry.get("type") != "assistant" and message.get("role") != "assistant":
continue
sessions.add(session_key)
active_days.add(day)
prompts += 1
usage = message.get("usage") or entry.get("usage")
if not isinstance(usage, dict):
continue
bucket = usage_by_model.setdefault(model, empty_bucket())
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
message_id = message.get("id") or entry.get("messageId") or ""
unique_key = str(message_id) if message_id else f"{path}:{entry.get('uuid') or entry.get('requestId') or line_number}"
if unique_key in seen:
continue
seen.add(unique_key)
if day in recent:
# recentDays.messageCount is actually a token total, despite the
# legacy name shared with synced snapshots.
recent[day]["messageCount"] += total
input_tokens = usage_token(usage, "input_tokens", "inputTokens")
output_tokens = usage_token(usage, "output_tokens", "outputTokens")
cache_read = usage_token(usage, "cache_read_input_tokens", "cacheReadInputTokens")
cache_write = usage_token(usage, "cache_creation_input_tokens", "cacheCreationInputTokens")
total = input_tokens + output_tokens + cache_read + cache_write
if total <= 0:
continue
model = str(message.get("model") or entry.get("model") or "claude")
day = local_date_from_timestamp(entry.get("timestamp") or message.get("timestamp"))
session_key = str(entry.get("sessionId") or path)
sessions.add(session_key)
active_days.add(day)
prompts += 1
bucket = usage_by_model.setdefault(model, empty_bucket())
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent:
# recentDays.messageCount is actually a token total, despite the
# legacy name shared with synced snapshots.
recent[day]["messageCount"] += total
if day == today:
today_prompt_count += 1
today_sessions.add(session_key)
today_token_total += total
today_tokens[model] = today_tokens.get(model, 0) + total
except Exception as exc:
print(f"Ignoring unreadable Claude project file {path}: {exc}", file=sys.stderr)
if day == today:
today_prompt_count += 1
today_sessions.add(session_key)
today_token_total += total
today_tokens[model] = today_tokens.get(model, 0) + total
return {
"todayPrompts": today_prompt_count,
@@ -213,18 +390,28 @@ def scan_projects(projects_path: Path) -> dict[str, Any]:
}
def scan_cache_paths(projects_path: Path) -> tuple[Path, Path]:
def scan_cache_paths(projects_path: Path) -> tuple[Path, Path, Path]:
digest = hashlib.sha1(str(projects_path).encode("utf-8")).hexdigest()[:16]
root = cache_root()
return root / f"claude-scan-{digest}.json", root / f"claude-scan-{digest}.lock"
return root / f"claude-scan-{digest}.json", root / f"claude-scan-{digest}.lock", root / f"claude-index-{digest}.json"
def read_json(path: Path) -> dict[str, Any] | None:
try:
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
def read_fresh_json(path: Path, max_age_seconds: float) -> dict[str, Any] | None:
if max_age_seconds <= 0 or not path.exists():
return None
try:
if time.time() - path.stat().st_mtime <= max_age_seconds:
return json.loads(path.read_text(encoding="utf-8"))
# A negative age means the mtime is in the future: the clock moved
# backwards since the write, so the cache's freshness cannot be trusted.
age = time.time() - path.stat().st_mtime
if 0 <= age <= max_age_seconds:
return read_json(path)
except Exception:
return None
return None
@@ -248,8 +435,8 @@ def write_json(path: Path, payload: dict[str, Any]) -> None:
raise
def cached_scan(projects_path: Path, max_age_seconds: float) -> dict[str, Any]:
cache_file, lock_file = scan_cache_paths(projects_path)
def cached_scan(projects_path: Path, max_age_seconds: float, use_index: bool = True) -> dict[str, Any]:
cache_file, lock_file, index_file = scan_cache_paths(projects_path)
cached = read_fresh_json(cache_file, max_age_seconds)
if cached is not None:
@@ -260,7 +447,9 @@ def cached_scan(projects_path: Path, max_age_seconds: float) -> dict[str, Any]:
cached = read_fresh_json(cache_file, max_age_seconds)
if cached is not None:
return cached
summary = scan_projects(projects_path)
if not use_index:
index_file.unlink(missing_ok=True)
summary = scan_projects(projects_path, index_file)
write_json(cache_file, summary)
return summary
@@ -337,11 +526,27 @@ def today_prompts_from_history(claude_dir: Path) -> tuple[int, int]:
# provider, model, and token usage on every assistant message.
# `omp --profile=<name>` (and pi's equivalent) relocates the whole agent tree
# under <base>/profiles/<name>/, so a subscription driven entirely through a
# profile leaves the default root empty and its usage uncounted. Sessions are
# keyed by file path, so a profile adds sessions rather than double-counting
# the default root.
def pi_session_roots() -> list[Path]:
roots: list[Path] = []
for base in (Path.home() / ".pi", Path.home() / ".omp"):
roots.append(base / "agent" / "sessions")
profiles = base / "profiles"
try:
# Sorted so the scan order does not depend on directory order.
roots.extend(sorted(child / "agent" / "sessions" for child in profiles.iterdir() if child.is_dir()))
except OSError:
# No profiles directory, or it is unreadable: the default root stands.
pass
return roots
def scan_pi_usage(max_age_seconds: float) -> dict[str, Any] | None:
roots = [
Path.home() / ".pi" / "agent" / "sessions",
Path.home() / ".omp" / "agent" / "sessions",
]
roots = pi_session_roots()
cache_file = cache_root() / "claude-pi-sessions.json"
cached = read_fresh_json(cache_file, max_age_seconds)
if cached is not None:
@@ -473,16 +678,36 @@ def scan_opencode_usage(max_age_seconds: float) -> dict[str, Any] | None:
return None
try:
conn.execute("PRAGMA query_only = ON")
for session_id, raw in conn.execute("SELECT session_id, data FROM message"):
tables = {name for (name,) in conn.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
# opencode v1 keeps messages in `message`, with the role, provider and model
# in each row's data. v2 writes `session_message` instead: the role moves to
# the `type` column and the provider and model nest under data.model. A
# database can hold either or both.
queries = []
if "message" in tables:
queries.append(("opencode:", "SELECT session_id, data, NULL FROM message"))
if "session_message" in tables:
queries.append(("opencode-v2:", "SELECT session_id, data, type FROM session_message"))
rows = ((prefix, row) for prefix, query in queries for row in conn.execute(query))
for session_prefix, (session_id, raw, row_type) in rows:
# One malformed row must not abort the scan, so every shape assumption
# lives inside the try.
try:
entry = json.loads(raw)
if not isinstance(entry, dict):
continue
if row_type is None:
role = entry.get("role")
provider = entry.get("providerID")
model_id = entry.get("modelID")
else:
role = row_type
model_info = entry.get("model") or {}
provider = model_info.get("providerID")
model_id = model_info.get("id")
# Exact match: opencode provider ids are free-form, and a custom
# "anthropic-proxy" gateway is not this subscription.
if not isinstance(entry, dict) or entry.get("role") != "assistant":
continue
if str(entry.get("providerID") or "") != "anthropic":
if role != "assistant" or str(provider or "") != "anthropic":
continue
tokens = entry.get("tokens") or {}
cache = tokens.get("cache") or {}
@@ -497,10 +722,10 @@ def scan_opencode_usage(max_age_seconds: float) -> dict[str, Any] | None:
created = number((entry.get("time") or {}).get("created"))
day = dt.datetime.fromtimestamp(created / 1000).strftime("%Y-%m-%d") if created > 0 else today
model = str(entry.get("modelID") or "claude").rstrip("/").split("/")[-1]
model = str(model_id or "claude").rstrip("/").split("/")[-1]
except Exception:
continue
session_key = "opencode:" + str(session_id)
session_key = session_prefix + str(session_id)
sessions.add(session_key)
active_days.add(day)
prompts += 1
@@ -581,28 +806,75 @@ def merge_stats(base: dict[str, Any], extra: dict[str, Any]) -> dict[str, Any]:
# CLI's login. Nothing else leaves the credential store: the token goes
# nowhere but the Authorization header of the limits probe, and only the
# plan label may travel into the printed record.
def oauth_login(claude_dir: Path) -> tuple[str, int, str]:
def oauth_login(claude_dir: Path) -> tuple[str, int, int, str]:
try:
data = json.loads((claude_dir / ".credentials.json").read_text(encoding="utf-8"))
except Exception:
return "", 0, ""
return "", 0, 0, ""
login = data.get("claudeAiOauth")
if not isinstance(login, dict):
return "", 0, ""
plan = plan_label(str(login.get("rateLimitTier") or ""), str(login.get("subscriptionType") or ""))
return str(login.get("accessToken") or ""), number(login.get("expiresAt")), plan
return "", 0, 0, ""
# The profile's tier is asked first, but only one that names a multiplier
# outranks the credentials'. The subscription still leads the label, so a
# Team seat keeps reading Team.
tier = profile_tier(claude_dir)
if not re.search(r"max_(\d+x)", tier, re.IGNORECASE):
tier = str(login.get("rateLimitTier") or "")
plan = plan_label(tier, str(login.get("subscriptionType") or ""))
return (
str(login.get("accessToken") or ""),
number(login.get("expiresAt")),
number(login.get("refreshTokenExpiresAt")),
plan,
)
def plan_label(tier: str, subscription: str) -> str:
if tier:
match = re.search(r"max_(\d+x)", tier, re.IGNORECASE)
if match:
return "Max " + match.group(1)
if subscription:
return subscription[0].upper() + subscription[1:]
# The credentials carry the tier their token was minted with, and an upgrade
# does not restamp it: a refresh rewrites the file and keeps the old value, so
# an account moved to Max 20x keeps reading Max 5x. The CLI's own profile is
# refreshed against the account, so the tier it states there is asked first.
def profile_tier(claude_dir: Path) -> str:
try:
account = json.loads(profile_path(claude_dir).read_text(encoding="utf-8")).get("oauthAccount")
except Exception:
return ""
if not isinstance(account, dict):
return ""
# A seat with no tier of its own is limited by its organization, which is
# the shape a personal Max subscription takes.
for key in ("userRateLimitTier", "organizationRateLimitTier"):
tier = str(account.get(key) or "")
if tier:
return tier
return ""
# The CLI's profile for a config home: a legacy .config.json inside it, else
# ~/.claude.json for the default home and <home>/.claude.json for one it was
# pointed at with CLAUDE_CONFIG_DIR. A copy anywhere else is never refreshed,
# and another home's profile describes another account.
def profile_path(home: Path) -> Path:
legacy = home / ".config.json"
if legacy.exists():
return legacy
configured = os.environ.get("CLAUDE_CONFIG_DIR")
if home == expand_path("~/.claude") and not (configured and expand_path(configured) == home):
return Path.home() / ".claude.json"
return home / ".claude.json"
# The rate-limit tier names the ceiling, not the plan: a Team premium seat
# runs on `default_claude_max_5x` while paying for Team. Lead with the
# subscription and keep the multiplier as its qualifier, so Max still reads
# "Max 5x" and a Team seat reads "Team 5x" rather than passing as Max.
def plan_label(tier: str, subscription: str) -> str:
plan = subscription[0].upper() + subscription[1:] if subscription else ""
match = re.search(r"max_(\d+x)", tier, re.IGNORECASE) if tier else None
if not match:
return plan
return (plan or "Max") + " " + match.group(1)
def parse_utilization(value: Any) -> float:
try:
return float(str(value).strip().replace("%", ""))
@@ -723,11 +995,12 @@ def probe_limits(access_token: str) -> dict[str, Any]:
except urllib.error.HTTPError as error:
retry_after = error.headers.get("retry-after", "") if error.headers else ""
if error.code == 429:
help_text = "Anthropic's usage endpoint is rate limiting checks right now" + (
f" (retry after {retry_after}s)" if retry_after else ""
) + ". Local Claude Code stats are still shown."
if retry_after:
help_text = text("Anthropic's usage endpoint is rate limiting checks right now (retry after %1s). Local Claude Code stats are still shown.", retry_after)
else:
help_text = text("Anthropic's usage endpoint is rate limiting checks right now. Local Claude Code stats are still shown.")
else:
help_text = f"Anthropic's usage endpoint returned status {error.code}. Local Claude Code stats are still shown."
help_text = text("Anthropic's usage endpoint returned status %1. Local Claude Code stats are still shown.", error.code)
return {"ok": False, "helpText": help_text}
except Exception:
# A transport failure reached no server at all — no route, no DNS. Any
@@ -765,11 +1038,8 @@ def probe_limits(access_token: str) -> dict[str, Any]:
return {"ok": True, "limits": limits}
# A cached percentage outlives the probe that measured it, but only until its
# window rolls over: once a window has reset, the figure describes a period
# that is over, and a stale 78% would misreport an allowance that is now
# untouched. A window with no reset time, or one that will not parse, is kept
# — an unreadable timestamp is no reason to throw away a real number.
# Cached windows reset to zero when their measured period ends. Without a
# fresh probe, the next rolling window has no known reset time yet.
def limit_window_open(entry: dict[str, Any], now: dt.datetime) -> bool:
raw = str(entry.get("resetsAt") or "")
if raw == "":
@@ -788,30 +1058,54 @@ def usable_cached_limits(cached: dict[str, Any]) -> list[dict[str, Any]]:
if not isinstance(entries, list):
return []
now = dt.datetime.now(dt.timezone.utc)
return [entry for entry in entries if isinstance(entry, dict) and limit_window_open(entry, now)]
return [
entry if limit_window_open(entry, now) else dict(entry, percent=0.0, resetsAt="")
for entry in entries if isinstance(entry, dict)
]
def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[str, Any]:
result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP}
def collect_limits(access_token: str, expires_at_ms: int, refresh_expires_at_ms: int, force: bool, cache_name: str = "claude-limits.json") -> dict[str, Any]:
# `live` says the numbers came from Anthropic just now (or within the reuse
# window) rather than from a cache kept past a failed or impossible probe.
result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP, "live": False}
# When the numbers were measured, so a stale account can say how old
# they are.
result["fetchedAtMs"] = 0
# A panel that is opened and shut repeatedly must not turn into a request
# per flick, so recent probe results are reused for a short window — and
# kept as the answer of record when a later probe fails.
probe_cache = cache_root() / "claude-limits.json"
cached = read_fresh_json(probe_cache, float("inf")) or {}
# kept as the answer of record when a later probe fails. Every account
# keeps its own, so one account's allowance never stands in for another's.
probe_cache = cache_root() / cache_name
cached = read_json(probe_cache) or {}
fallback = usable_cached_limits(cached)
result["fetchedAtMs"] = number(cached.get("fetchedAtMs"))
# Probing needs a live token and only the Claude Code CLI can mint one: it
# refreshes the credential file when it runs, so a machine left alone long
# enough finds the saved token lapsed. Say so — an empty limits list with
# nothing else set hides the whole section and explains nothing — and keep
# showing the last numbers whose window has not since reset.
# showing the last numbers, resetting elapsed windows to zero.
if access_token == "":
result["limits"] = fallback
result["usageStatusText"] = "Waiting for auth"
return result
if expires_at_ms > 0 and expires_at_ms <= time.time() * 1000:
result["limits"] = fallback
# A lapsed access token is routine, not a sign-out: it lives about eight
# hours, and the CLI mints a new one from the refresh token the next time
# it runs. Only a lapsed *refresh* token is a sign-in a person must redo,
# and saying so either way sends people to `claude auth login` nightly --
# which does not restore anything the CLI would not have restored itself.
if refresh_expires_at_ms > time.time() * 1000:
result["usageStatusText"] = "Limits paused"
result["authHelpText"] = (
"Claude Code's access token has lapsed"
+ (" — showing the last known limits." if fallback else ".")
+ " Start Claude Code to refresh it; signing in again is not needed."
)
return result
result["usageStatusText"] = "Sign-in expired"
result["authHelpText"] = (
"Claude Code's saved sign-in expired"
@@ -824,14 +1118,20 @@ def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[s
# entirely; the interval is there to absorb repeated panel opens, not to
# overrule someone who pressed refresh.
fetched_at = number(cached.get("fetchedAtMs")) / 1000
if fallback and not force and time.time() - fetched_at < PROBE_MIN_INTERVAL_SECONDS:
fetched_age = time.time() - fetched_at
if fallback and not force and 0 <= fetched_age < PROBE_MIN_INTERVAL_SECONDS:
result["limits"] = fallback
result["live"] = True
result["authHelpText"] = ""
return result
probe = probe_limits(access_token)
if probe["ok"]:
result["limits"] = probe["limits"]
write_json(probe_cache, {"fetchedAtMs": round(time.time() * 1000), "limits": probe["limits"]})
result["live"] = True
result["authHelpText"] = ""
result["fetchedAtMs"] = round(time.time() * 1000)
write_json(probe_cache, {"fetchedAtMs": result["fetchedAtMs"], "limits": probe["limits"]})
return result
# The first probe after login often fires before DHCP has handed out a
@@ -840,25 +1140,95 @@ def collect_limits(access_token: str, expires_at_ms: int, force: bool) -> dict[s
result["retryAdvised"] = True
if fallback:
result["limits"] = fallback
fetched_age = time.time() - result["fetchedAtMs"] / 1000
result["live"] = 0 <= fetched_age < CURRENT_ENOUGH_SECONDS
else:
result["usageStatusText"] = "Claude limits unavailable"
result["authHelpText"] = probe["helpText"]
return result
# ---------------------------------------------------------------- accounts
# The accounts `omarchy agent account` registered, read straight from its
# registry. Only a registry holding a second account matters: with one, the
# record is exactly what it always was.
def registered_accounts() -> list[dict[str, Any]]:
state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local" / "state")
try:
registry = json.loads((state / "omarchy" / "agents" / "accounts" / "claude.json").read_text(encoding="utf-8"))
except Exception:
return []
accounts = [a for a in registry.get("accounts") or [] if isinstance(a, dict) and a.get("id")]
if len(accounts) < 2:
return []
active_id = registry.get("active") or accounts[0]["id"]
if not any(a["id"] == active_id for a in accounts):
active_id = accounts[0]["id"]
for account in accounts:
account["active"] = account["id"] == active_id
account["switch"] = {
"mode": "auto" if registry.get("switch") == "auto" else "manual",
"threshold": registry.get("threshold") or 95,
}
return accounts
def current_account_id(home: Path) -> str:
try:
account = json.loads(profile_path(home).read_text(encoding="utf-8")).get("oauthAccount") or {}
except Exception:
return ""
return str(account.get("accountUuid") or "")
def account_limits(account: dict[str, Any], force: bool) -> dict[str, Any]:
home = expand_path(account["home"]) if account.get("home") else expand_path("~/.claude")
access_token, expires_at_ms, refresh_expires_at_ms, plan = oauth_login(home)
# Keyed by the subscription, not the label: forget "Work" and add a different
# "Work", and the new one must never fall back on the old one's allowance.
# Who the home is signed in as now, not when it was added: a `claude auth
# login` run in it by hand makes it a different subscription.
identity = current_account_id(home)
key = re.sub(r"[^A-Za-z0-9_-]", "", identity or str(account.get("accountId") or "")) or account["id"]
# The primary home is keyed the same way once it says who it's signed in
# as, so signing it in to another subscription never inherits the last
# one's numbers; until then it keeps the single-account cache.
if account.get("primary") and not identity:
cache_name = "claude-limits.json"
else:
cache_name = f"claude-limits-{key}.json"
limits = collect_limits(access_token, expires_at_ms, refresh_expires_at_ms, force, cache_name)
return {
"id": account["id"],
"label": str(account.get("label") or account["id"]),
"email": str(account.get("email") or ""),
"plan": plan or str(account.get("plan") or ""),
"active": account["active"],
"primary": bool(account.get("primary")),
"limits": limits["limits"],
"stale": not limits["live"],
"fetchedAt": limits["fetchedAtMs"],
"usageStatusText": limits["usageStatusText"],
"authHelpText": limits["authHelpText"],
"retryAdvised": bool(limits.get("retryAdvised")),
}
# -------------------------------------------------------------------- record
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--force", action="store_true", help="rescan transcripts and re-probe limits, ignoring caches")
parser.add_argument("--force", action="store_true", help="rescan transcripts from scratch and re-probe limits, ignoring caches")
parser.add_argument("--limits-only", action="store_true", help="reuse any recent transcript scan; only the limits probe must be fresh")
parser.add_argument("--cache-seconds", type=float, default=20)
args = parser.parse_args()
claude_dir = config_dir()
scan_age = 0 if args.force else (900 if args.limits_only else args.cache_seconds)
stats = cached_scan(claude_dir / "projects", scan_age)
stats = cached_scan(claude_dir / "projects", scan_age, use_index=not args.force)
if number(stats.get("totalPrompts")) <= 0:
fallback = stats_cache_fallback(claude_dir)
@@ -879,8 +1249,19 @@ def main() -> int:
if opencode is not None:
stats = merge_stats(stats, opencode)
access_token, expires_at_ms, plan = oauth_login(claude_dir)
limits = collect_limits(access_token, expires_at_ms, args.force)
registered = registered_accounts()
accounts = [account_limits(account, args.force) for account in registered]
current = next((a for a in accounts if a["active"]), None)
if current:
# The top-level fields keep describing the account new sessions use, so
# the bar icon and anything else reading one set of limits stays right.
plan = current["plan"]
limits = {key: current[key] for key in ("limits", "usageStatusText", "authHelpText", "retryAdvised")}
stale, fetched_at = current["stale"], current["fetchedAt"]
else:
access_token, expires_at_ms, refresh_expires_at_ms, plan = oauth_login(claude_dir)
limits = collect_limits(access_token, expires_at_ms, refresh_expires_at_ms, args.force)
stale, fetched_at = not limits["live"], limits["fetchedAtMs"]
record = {
"schemaVersion": 1,
@@ -893,9 +1274,16 @@ def main() -> int:
"usageStatusText": limits["usageStatusText"],
"authHelpText": limits["authHelpText"],
"limits": limits["limits"],
# Numbers kept past a failed probe, and when they were measured, so the
# panel can tell them apart from ones checked just now.
"limitsStale": stale,
"limitsFetchedAt": fetched_at,
}
if limits.get("retryAdvised"):
record["retryAdvised"] = True
if accounts:
record["accountSwitch"] = registered[0]["switch"]
record["accounts"] = accounts
record.update(stats)
print(json.dumps(record, separators=(",", ":"), sort_keys=True))
return 0
+583 -109
View File
@@ -22,6 +22,7 @@ import subprocess
import sys
import tempfile
import time
import tomllib
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -29,6 +30,11 @@ AGENT_ID = "codex"
AGENT_NAME = "Codex"
AUTH_HELP = "Run `codex login` to authenticate."
# session_meta.model_provider for the built-in OpenAI backend, which is the
# only one this subscription pays for. Any other id points somewhere else:
# `--oss`'s ollama or lmstudio, or a custom provider in config.toml.
NATIVE_PROVIDER = "openai"
# A scan this recent is only reused to dedup concurrent collector runs (the
# update command backgrounds one per agent while the panel refreshes on its
# own); every periodic widget refresh lands a real rescan, however low
@@ -37,6 +43,11 @@ AUTH_HELP = "Run `codex login` to authenticate."
SCAN_REUSE_SECONDS = 20
LIMITS_ONLY_REUSE_SECONDS = 900
# Per-file totals of native Codex sessions survive between runs, so a rescan
# only reads the session files that changed. Bumping the schema retires every
# record written by an older layout.
FILE_CACHE_SCHEMA = 1
def local_day(value):
if value is None:
@@ -91,6 +102,52 @@ def find_command(name):
return shutil.which(name, path=ENV.get("PATH"))
def mise_shims_dir():
data_home = ENV.get("MISE_DATA_DIR") or os.path.join(
ENV.get("XDG_DATA_HOME") or os.path.join(str(Path.home()), ".local", "share"), "mise")
return os.path.join(data_home, "shims")
def is_lazy_launcher(path):
# omarchy-mise-install writes a regular file whose first step is `mise use
# -g`, and mise's shims exec `mise x`; running either installs the tool it
# wraps. A shim is a symlink to the mise binary itself, so the symlink
# check comes after the shim checks: a symlink elsewhere is the user's own
# binary.
if os.path.dirname(os.path.abspath(path)) == mise_shims_dir():
return True
if os.path.islink(path):
mise = find_command("mise")
return bool(mise) and os.path.realpath(path) == os.path.realpath(mise)
try:
with open(path, "rb") as handle:
head = handle.read(4096)
return b"mise use" in head or b"mise x " in head
except OSError:
return False
def find_codex_binary():
for directory in ENV.get("PATH", "").split(os.pathsep):
candidate = os.path.join(directory, "codex")
if os.path.isfile(candidate) and os.access(candidate, os.X_OK) and not is_lazy_launcher(candidate):
return candidate
# `mise which` only prints a binary that is already installed, so it is the
# safe way to resolve a codex managed by mise without running the launcher.
# `mise which` can resolve a configured `latest` over the network; the usage
# probe stays offline.
mise = find_command("mise")
if mise:
try:
out = subprocess.run([mise, "which", "codex"], capture_output=True, text=True, env=dict(ENV, MISE_OFFLINE="1"), timeout=10)
resolved = out.stdout.strip()
if out.returncode == 0 and os.path.isfile(resolved) and not is_lazy_launcher(resolved):
return resolved
except Exception:
pass
return None
now = datetime.now()
today = now.strftime("%Y-%m-%d")
recent_dates = [(now - timedelta(days=offset)).strftime("%Y-%m-%d") for offset in range(6, -1, -1)]
@@ -107,46 +164,109 @@ total_sessions = set()
seen_pi_messages = set()
def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read, cache_write):
# One session file's totals, the unit the file cache stores: day -> model ->
# [input, output, cacheRead, cacheWrite, prompts]. Day-keyed and with no
# "today" in it, so a record stays true however long it is kept.
def new_file_record(stat_result):
return {"mtime": stat_result.st_mtime, "size": stat_result.st_size, "days": {}}
def record_usage(record, day, model, input_tokens, output_tokens, cache_read, cache_write):
totals = record["days"].setdefault(day, {}).setdefault(model, [0, 0, 0, 0, 0])
totals[0] += input_tokens
totals[1] += output_tokens
totals[2] += cache_read
totals[3] += cache_write
totals[4] += 1
def merge_file_record(session_key, record):
"""Fold one file's totals, freshly read or replayed from cache, into the run."""
global today_prompts, today_total_tokens, total_prompts
total = input_tokens + output_tokens + cache_read + cache_write
total_prompts += 1
total_sessions.add(session_key)
active_days.add(day)
for day, models in record["days"].items():
for model, totals in models.items():
input_tokens, output_tokens, cache_read, cache_write, prompts = totals
total = input_tokens + output_tokens + cache_read + cache_write
total_prompts += prompts
total_sessions.add(session_key)
active_days.add(day)
bucket = model_usage.setdefault(model, {
"inputTokens": 0,
"outputTokens": 0,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
})
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
bucket = model_usage.setdefault(model, {
"inputTokens": 0,
"outputTokens": 0,
"cacheReadInputTokens": 0,
"cacheCreationInputTokens": 0,
})
bucket["inputTokens"] += input_tokens
bucket["outputTokens"] += output_tokens
bucket["cacheReadInputTokens"] += cache_read
bucket["cacheCreationInputTokens"] += cache_write
if day in recent:
recent[day]["messageCount"] += total
if day in recent:
recent[day]["messageCount"] += total
if day == today:
today_prompts += 1
today_sessions.add(session_key)
today_total_tokens += total
today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + total
if day == today:
today_prompts += prompts
today_sessions.add(session_key)
today_total_tokens += total
today_tokens_by_model[model] = today_tokens_by_model.get(model, 0) + total
def add_usage(day, session_key, model, input_tokens, output_tokens, cache_read, cache_write):
record = {"days": {}}
record_usage(record, day, model, input_tokens, output_tokens, cache_read, cache_write)
merge_file_record(session_key, record)
def pi_parent_session(path):
try:
with open(path, encoding="utf-8", errors="replace") as session_file:
header = json.loads(session_file.readline())
except (OSError, json.JSONDecodeError):
return None
if not isinstance(header, dict):
return None
parent = header.get("parentSession")
if not isinstance(parent, str) or not parent:
return None
return os.path.abspath(os.path.expanduser(parent))
# `omp --profile=<name>` (and pi's equivalent) relocates the whole agent tree
# under <base>/profiles/<name>/, so a subscription driven entirely through a
# profile leaves the default root empty and its usage uncounted. Each root is
# scanned separately and sessions are keyed by path, so a profile adds
# sessions rather than double-counting the default one.
def pi_session_roots():
roots = []
for base in (Path.home() / ".pi", Path.home() / ".omp"):
roots.append(base / "agent" / "sessions")
profiles = base / "profiles"
try:
# Sorted so the scan order does not depend on directory order.
roots.extend(sorted(child / "agent" / "sessions" for child in profiles.iterdir() if child.is_dir()))
except OSError:
# No profiles directory, or it is unreadable: the default root stands.
pass
return roots
def scan_pi_sessions():
roots = [
Path.home() / ".pi" / "agent" / "sessions",
Path.home() / ".omp" / "agent" / "sessions",
]
roots = pi_session_roots()
rg = find_command("rg") or "rg"
session_keys = {}
today_session_keys = {}
for root in roots:
if not root.exists():
continue
try:
proc = subprocess.Popen(
[rg, "--json", "-e", r'"provider"\s*:\s*"openai-codex"', "-e", r'"api"\s*:\s*"openai-codex', str(root)],
# --no-ignore: these are data files, not a source tree. When $HOME is
# itself a git checkout (a common dotfiles setup), the parent repo's
# ignore rules would otherwise silently exclude every session file and
# the scan would count zero usage.
[rg, "--json", "--no-ignore", "-e", r'"provider"\s*:\s*"openai-codex"', "-e", r'"api"\s*:\s*"openai-codex', str(root)],
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
text=True,
@@ -163,16 +283,22 @@ def scan_pi_sessions():
if event.get("type") != "match":
continue
line = event.get("data", {}).get("lines", {}).get("text", "")
path = event.get("data", {}).get("path", {}).get("text", "pi-session")
raw_path = event.get("data", {}).get("path", {}).get("text", "pi-session")
path = os.path.abspath(os.path.expanduser(raw_path))
entry = json.loads(line)
except Exception:
continue
if entry.get("type") != "message":
continue
message_key = path + ":" + str(entry.get("id") or "")
if message_key in seen_pi_messages:
continue
message_id = str(entry.get("id") or "")
message_timestamp = str(entry.get("timestamp") or "")
# Forks retain both fields; IDs alone can collide across sessions.
if message_id and message_timestamp:
message_key = ("entry", message_id, message_timestamp)
else:
message_key = ("path", path, message_id)
duplicate = message_key in seen_pi_messages
seen_pi_messages.add(message_key)
message = entry.get("message") or {}
if message.get("role") != "assistant":
@@ -196,14 +322,37 @@ def scan_pi_sessions():
continue
day = local_day(entry.get("timestamp") or message.get("timestamp"))
session_key = path
add_usage(day, session_key, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write)
session_keys.setdefault(path, set()).add(message_key)
if day == today:
today_session_keys.setdefault(path, set()).add(message_key)
if duplicate:
continue
add_usage(day, path, model_name(message.get("model")), input_tokens, output_tokens, cache_read, cache_write)
try:
proc.wait(timeout=1)
except Exception:
proc.kill()
# Resolve session ownership after scanning so rg's file order cannot affect it.
parents = {path: pi_parent_session(path) for path in session_keys}
total_sessions.difference_update(session_keys)
today_sessions.difference_update(session_keys)
for path, keys in session_keys.items():
inherited = set()
visited = {path}
parent = parents.get(path)
while parent and parent not in visited:
visited.add(parent)
inherited.update(session_keys.get(parent, ()))
parent = parents.get(parent)
unique_keys = keys - inherited
if unique_keys:
total_sessions.add(path)
if unique_keys & today_session_keys.get(path, set()):
today_sessions.add(path)
def scan_opencode_sessions():
# A subscription burned entirely through opencode leaves no native session
@@ -283,7 +432,7 @@ def scan_opencode_sessions():
return True
def scan_native_codex_sessions():
def scan_native_codex_sessions(cached_files, scanned_files):
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
roots = [codex_home / "sessions", codex_home / "archived_sessions"]
files = []
@@ -293,48 +442,98 @@ def scan_native_codex_sessions():
continue
for path in root.rglob("*.jsonl"):
try:
if path.stat().st_mtime >= cutoff:
files.append(path)
stat_result = path.stat()
except OSError:
pass
continue
if stat_result.st_mtime >= cutoff:
files.append((path, stat_result))
for path in files:
current_model = "codex"
try:
with path.open(errors="replace") as handle:
for raw in handle:
try:
entry = json.loads(raw)
except Exception:
for path, stat_result in files:
key = str(path)
record = reusable_record(cached_files, key, stat_result)
if record is None:
record = read_native_codex_session(path, stat_result)
if record["mtime"] is not None:
scanned_files[key] = record
merge_file_record(key, record)
def read_native_codex_session(path, stat_result):
record = new_file_record(stat_result)
current_model = "codex"
seen_meta = False
previous_total_usage = None
try:
with path.open(errors="replace") as handle:
for raw in handle:
# Cheap pre-filter before JSON parsing keeps files with unrelated
# lines inexpensive. session_meta still has to get through: it says
# which provider served the rollout.
if '"token_count"' not in raw and '"turn_context"' not in raw and '"session_meta"' not in raw:
continue
try:
entry = json.loads(raw)
except Exception:
continue
if entry.get("type") == "session_meta":
# A forked rollout copies its parent's session_meta after its own,
# so only the first one, as Codex reads it, says who served this.
if seen_meta:
continue
if entry.get("type") == "turn_context":
payload = entry.get("payload") or {}
current_model = model_name(payload.get("model") or payload.get("model_slug") or current_model)
seen_meta = True
# Codex CLI happily fronts any OpenAI-compatible backend (`--oss`,
# or a custom model_provider in config.toml). Those turns bill the
# local box or a third party, never this subscription, so drop the
# whole rollout the way the pi and opencode scans drop foreign
# providers.
meta = entry.get("payload") or {}
provider = str(meta.get("model_provider") or "")
# Rollouts predating the field carry no provider at all; count
# those rather than silently lose the history.
if provider and provider != NATIVE_PROVIDER:
break
continue
if entry.get("type") == "turn_context":
payload = entry.get("payload") or {}
current_model = model_name(payload.get("model") or payload.get("model_slug") or current_model)
continue
payload = entry.get("payload") or entry
if entry.get("type") == "response_item" and isinstance(payload, dict):
payload = payload.get("payload") or payload
if not isinstance(payload, dict):
continue
if payload.get("type") != "token_count":
continue
info = payload.get("info") or {}
# total_token_usage is cumulative for the session. Adding every
# snapshot makes usage grow quadratically, so count the last turn.
usage = info.get("last_token_usage") or {}
cache_read = number(usage.get("cached_input_tokens"))
cache_write = number(usage.get("cache_write_input_tokens"))
# Cached tokens are included in input_tokens, and reasoning tokens
# are included in output_tokens. Keep the cache split without
# counting either category twice.
input_tokens = max(0, number(usage.get("input_tokens")) - cache_read - cache_write)
output_tokens = number(usage.get("output_tokens"))
if not (input_tokens or output_tokens or cache_read or cache_write):
continue
# Quota updates can repeat the previous request's last_token_usage.
# Only an unchanged cumulative snapshot proves this is a repeat:
# separate requests may have identical last-token counts, and older
# records may not carry cumulative counters at all. Keep this state
# per rollout and accept changed counters, including counter resets.
total_usage = info.get("total_token_usage")
if isinstance(total_usage, dict) and total_usage:
if total_usage == previous_total_usage:
continue
payload = entry.get("payload") or entry
if entry.get("type") == "response_item" and isinstance(payload, dict):
payload = payload.get("payload") or payload
if not isinstance(payload, dict):
continue
if payload.get("type") != "token_count":
continue
info = payload.get("info") or {}
# total_token_usage is cumulative for the session. Adding every
# snapshot makes usage grow quadratically, so count the last turn.
usage = info.get("last_token_usage") or {}
cache_read = number(usage.get("cached_input_tokens"))
cache_write = number(usage.get("cache_write_input_tokens"))
# Cached tokens are included in input_tokens, and reasoning tokens
# are included in output_tokens. Keep the cache split without
# counting either category twice.
input_tokens = max(0, number(usage.get("input_tokens")) - cache_read - cache_write)
output_tokens = number(usage.get("output_tokens"))
if not (input_tokens or output_tokens or cache_read or cache_write):
continue
day = local_day(entry.get("timestamp") or path.stat().st_mtime)
add_usage(day, str(path), current_model, input_tokens, output_tokens, cache_read, cache_write)
except Exception:
continue
previous_total_usage = total_usage
day = local_day(entry.get("timestamp") or stat_result.st_mtime)
record_usage(record, day, current_model, input_tokens, output_tokens, cache_read, cache_write)
except Exception:
# Keep what was read before the failure, but caching a partial record would
# hide the rest until the file changes, so hand back one the cache refuses.
record["mtime"] = None
return record
def cache_root():
@@ -343,16 +542,86 @@ def cache_root():
return root
def scan_cache_paths():
def scan_cache_digest():
codex_home = Path(os.environ.get("CODEX_HOME") or (Path.home() / ".codex"))
db = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share")) / "opencode" / "opencode.db"
# The digest covers every data path the scan reads: the codex session
# roots, the opencode DB, and (via Path.home()) the pi/omp session roots.
digest = hashlib.sha1((str(Path.home()) + "\n" + str(codex_home) + "\n" + str(db)).encode("utf-8")).hexdigest()[:16]
return hashlib.sha1((str(Path.home()) + "\n" + str(codex_home) + "\n" + str(db)).encode("utf-8")).hexdigest()[:16]
def scan_cache_paths():
digest = scan_cache_digest()
root = cache_root()
return root / f"codex-scan-{digest}.json", root / f"codex-scan-{digest}.lock"
# Records hold local days, so they are only true in the timezone that wrote them.
def file_cache_zone():
return [os.environ.get("TZ"), list(time.tzname), time.timezone, time.altzone]
def file_cache_path():
return cache_root() / f"codex-files-{scan_cache_digest()}.json"
def valid_file_record(value):
try:
counts = [totals for models in value["days"].values() for totals in models.values()]
return (
type(value["mtime"]) in (int, float) and type(value["size"]) is int
and all(len(totals) == 5 and all(type(n) is int for n in totals) for totals in counts)
)
except Exception:
return False
def read_file_cache():
"""Per-file totals of native sessions from earlier scans, keyed by path.
Session files are append-only, so a file whose mtime and size both match the
record cannot have grown a turn since: its totals can be replayed instead of
read. That is what keeps a refresh off the gigabytes of history it already
counted. Unlike the aggregate scan cache these records carry no "today" and
never expire on their own -- only the file they describe or a change of
timezone can invalidate one, so midnight and the clock moving leave them true.
pi and omp sessions stay out of it: a forked pi session repeats its parent's
messages, so they are only deduplicated across the whole tree at once.
"""
try:
payload = json.loads(file_cache_path().read_text(encoding="utf-8"))
except Exception:
return {}
if not isinstance(payload, dict) or payload.get("schemaVersion") != FILE_CACHE_SCHEMA:
return {}
if payload.get("zone") != file_cache_zone():
return {}
files = payload.get("files")
if not isinstance(files, dict):
return {}
return {path: record for path, record in files.items() if valid_file_record(record)}
def write_file_cache(files):
# Only the files this scan saw are written back, so deleted sessions and
# sessions that aged out of the 30-day window leave the cache instead of
# growing it forever.
try:
write_json(file_cache_path(), {"schemaVersion": FILE_CACHE_SCHEMA, "zone": file_cache_zone(), "files": files})
except Exception as exc:
print(f"omarchy-agent-usage-codex: could not write file cache ({exc})", file=sys.stderr)
def reusable_record(cached_files, key, stat_result):
record = cached_files.get(key)
if record is None:
return None
if record["mtime"] != stat_result.st_mtime or record["size"] != stat_result.st_size:
return None
return record
def read_fresh_json(path, max_age_seconds):
if max_age_seconds <= 0 or not path.exists():
return None
@@ -389,9 +658,10 @@ def write_json(path, payload):
# The cache payload is a versioned envelope around the local-stats dict, so a
# corrupted or foreign-shaped file is a cache miss (rescan + rewrite) instead
# of a crash or a garbage record.
# Version 2 invalidates totals counted before native notification deduplication.
def read_cached_stats(cache_file, max_age_seconds):
cached = read_fresh_json(cache_file, max_age_seconds)
if not isinstance(cached, dict) or cached.get("schemaVersion") != 1:
if not isinstance(cached, dict) or cached.get("schemaVersion") != 2:
return None
# today* fields only mean "today" on the day they were scanned. A cache
# from another local date (midnight passed, or the clock moved) is a miss,
@@ -408,7 +678,7 @@ def read_cached_stats(cache_file, max_age_seconds):
def write_cached_stats(cache_file, stats):
try:
write_json(cache_file, {"schemaVersion": 1, "scanDate": today, "stats": stats})
write_json(cache_file, {"schemaVersion": 2, "scanDate": today, "stats": stats})
except Exception as exc:
print(f"omarchy-agent-usage-codex: could not write usage cache ({exc})", file=sys.stderr)
@@ -432,10 +702,14 @@ def local_stats():
}
def run_local_scans():
# A forced scan (no reuse at all) reads every session file again.
def run_local_scans(max_age):
cached_files = read_file_cache() if max_age > 0 else {}
scanned_files = {}
scan_pi_sessions()
scan_native_codex_sessions()
scan_native_codex_sessions(cached_files, scanned_files)
complete = scan_opencode_sessions()
write_file_cache(scanned_files)
return local_stats(), complete
@@ -450,7 +724,7 @@ def cached_local_stats(max_age):
return _cached_local_stats(max_age)
except Exception as exc:
print(f"omarchy-agent-usage-codex: cache unavailable ({exc}); scanning directly", file=sys.stderr)
stats, _ = run_local_scans()
stats, _ = run_local_scans(max_age)
return stats
@@ -466,7 +740,7 @@ def _cached_local_stats(max_age):
cached = read_cached_stats(cache_file, max_age)
if cached is not None:
return cached
stats, complete = run_local_scans()
stats, complete = run_local_scans(max_age)
# An interrupted scan still serves this run, but caching it would
# suppress the missing usage for every reader until the cache expires.
if complete:
@@ -474,25 +748,55 @@ def _cached_local_stats(max_age):
return stats
def rpc_send(proc, payload, operation):
"""Write one JSON-RPC frame. Raise immediately if the app-server is gone."""
try:
proc.stdin.write(json.dumps(payload) + "\n")
proc.stdin.flush()
except OSError as exc:
try:
proc.stdin.close()
except OSError:
pass
raise RuntimeError(f"Codex app-server exited before {operation}") from exc
def rpc_request(proc, request_id, method, params=None, timeout=8):
payload = {"id": request_id, "method": method, "params": params or {}}
proc.stdin.write(json.dumps(payload) + "\n")
proc.stdin.flush()
deadline = time.time() + timeout
while time.time() < deadline:
ready, _, _ = select.select([proc.stdout], [], [], 0.25)
if not ready:
continue
line = proc.stdout.readline()
if not line:
break
try:
message = json.loads(line)
except Exception:
continue
if message.get("id") == request_id:
return message
raise TimeoutError(method)
rpc_send(proc, payload, method)
# Read the raw fd and split lines ourselves. The app-server can emit
# notifications in the same write as a reply; a buffered readline() would
# pull them all into Python's buffer where select() cannot see them, and
# the reply would sit there until the deadline.
fd = proc.stdout.fileno()
pending = getattr(proc, "_rpc_pending", b"")
deadline = time.monotonic() + timeout
try:
while True:
while b"\n" in pending:
line, pending = pending.split(b"\n", 1)
try:
message = json.loads(line)
except Exception:
continue
if isinstance(message, dict) and message.get("id") == request_id:
return message
remaining = deadline - time.monotonic()
if remaining <= 0:
break
ready, _, _ = select.select([fd], [], [], min(0.25, remaining))
if not ready:
if proc.poll() is not None:
raise RuntimeError(f"Codex app-server exited before {method}")
continue
chunk = os.read(fd, 65536)
if not chunk:
raise RuntimeError(f"Codex app-server exited before {method}")
pending += chunk
finally:
proc._rpc_pending = pending
# Process still up but silent: keep the method name searchable, but name the stall.
raise TimeoutError(f"Codex app-server did not answer {method}")
def limit_window(window):
@@ -518,47 +822,153 @@ def limit_window(window):
}
def fetch_codex_rpc():
def codex_rpc_help(proc, stderr_file, exc):
"""Explain an app-server RPC failure: a live server's stall, a dead one's own
stderr, or the login hint when it died saying nothing."""
try:
proc.wait(timeout=1)
except Exception:
pass
text = str(exc).strip()
if proc.poll() is None:
# Still up: stall or unparseable payload. Not an auth problem, and any
# stderr so far is its logging, not why it stopped.
return text[:300] or AUTH_HELP
try:
stderr_file.seek(0)
detail = stderr_file.read()
except Exception:
detail = ""
lines = [line.strip() for line in detail.splitlines() if line.strip()]
if lines:
# The fatal error comes last, after whatever the CLI logged on the way up.
return f"codex app-server exited: {' '.join(lines)[-275:]}"
# Exited cleanly with empty stderr — typical "not logged in" shape.
return AUTH_HELP
def reset_credits(result):
granted = (result.get("rateLimitResetCredits") or {}).get("credits") or []
available = [c for c in granted if isinstance(c, dict) and c.get("status") == "available"]
if not available:
return None
expiries = [number(c.get("expiresAt")) for c in available if number(c.get("expiresAt")) > 0]
return {
"available": len(available),
"nextExpiresAt": datetime.fromtimestamp(min(expiries), timezone.utc).isoformat() if expiries else "",
}
# Keyring and auto stores keep credentials outside auth.json, so only the file
# store can be known to be empty without asking Codex.
def has_codex_credentials(codex_home, env):
if env.get("CODEX_ACCESS_TOKEN"):
return True
try:
with (codex_home / "config.toml").open("rb") as handle:
config = tomllib.load(handle)
except FileNotFoundError:
config = {}
except Exception:
# A config this can't read may still name another store; let Codex say.
return True
if str(config.get("cli_auth_credentials_store") or "file") != "file":
return True
return (codex_home / "auth.json").is_file()
def fetch_codex_rpc(home=None):
result = {"limits": [], "tierLabel": "", "usageStatusText": "", "authHelpText": AUTH_HELP}
codex = find_command("codex")
env = dict(ENV, CODEX_HOME=str(home)) if home else ENV
codex = find_codex_binary()
if not codex:
result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = "codex not found in PATH"
return result
# No auth.json under the file store and no CODEX_ACCESS_TOKEN means
# account/read can only fail — and starting the app-server is not free: it
# syncs the plugin list, a git fetch per refresh that also leaves
# .tmp/git-* folders behind. The home is the one being probed, so a
# signed-out secondary account reads as signed out even while ~/.codex
# holds a login.
if not has_codex_credentials(Path(env.get("CODEX_HOME") or (Path.home() / ".codex")), env):
result["usageStatusText"] = "Waiting for auth"
result["authHelpText"] = AUTH_HELP
return result
# stderr goes to a regular file, not a PIPE: an unread pipe can fill and
# deadlock a chatty app-server. It is only read once the process has exited.
# Without temporary space the probe still runs, just without its error text.
try:
stderr_file = tempfile.TemporaryFile("w+", errors="replace")
except OSError:
stderr_file = None
try:
proc = subprocess.Popen(
[codex, "-s", "read-only", "-a", "on-request", "app-server"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL,
stderr=stderr_file or subprocess.DEVNULL,
text=True,
env=ENV,
env=env,
)
except Exception as exc:
if stderr_file:
stderr_file.close()
result["usageStatusText"] = "Codex unavailable"
result["authHelpText"] = str(exc)
return result
try:
rpc_request(proc, 1, "initialize", {"clientInfo": {"name": "omarchy-agent-usage", "version": "1"}}, timeout=8)
proc.stdin.write(json.dumps({"method": "initialized", "params": {}}) + "\n")
proc.stdin.flush()
account_msg = rpc_request(proc, 2, "account/read", timeout=4)
limits_msg = rpc_request(proc, 3, "account/rateLimits/read", timeout=4)
rpc_send(proc, {"method": "initialized", "params": {}}, "initialized")
limits_msg = rpc_request(proc, 2, "account/rateLimits/read", timeout=8)
account = (account_msg.get("result") or {}).get("account") or {}
# A home nobody is signed in to answers with an error rather than limits.
# Say so the same way the Claude collector does, so the panel offers to
# sign that account in again.
error = limits_msg.get("error")
if isinstance(error, dict):
message = str(error.get("message") or "")
if "auth" in message.lower():
result["usageStatusText"] = "Waiting for auth"
result["authHelpText"] = AUTH_HELP
else:
result["usageStatusText"] = "Codex limits unavailable"
result["authHelpText"] = message
return result
limits = (limits_msg.get("result") or {}).get("rateLimits") or {}
plan = limits.get("planType") or account.get("planType") or account.get("type") or ""
result["authHelpText"] = ""
plan = limits.get("planType") or ""
# The limits name the plan themselves. account/read is only a fallback
# for when they don't, and never a reason to lose them: Codex 0.158's
# app-server can leave it unanswered for good.
if not plan:
try:
account_msg = rpc_request(proc, 3, "account/read", timeout=2)
account = (account_msg.get("result") or {}).get("account") or {}
plan = account.get("planType") or account.get("type") or ""
except (TimeoutError, RuntimeError):
pass
result["tierLabel"] = str(plan) if plan else ""
for window in (limits.get("primary"), limits.get("secondary")):
entry = limit_window(window)
if entry:
result["limits"].append(entry)
# OpenAI hands out free "full reset" credits that wipe the rate limits on
# demand and lapse after a month. Worth knowing about before a limit bites.
credits = reset_credits(limits_msg.get("result") or {})
if credits:
result["resetCredits"] = credits
except Exception as exc:
result["usageStatusText"] = "Codex limits unavailable"
result["authHelpText"] = str(exc)
result["authHelpText"] = codex_rpc_help(proc, stderr_file, exc)
finally:
try:
proc.terminate()
@@ -568,9 +978,56 @@ def fetch_codex_rpc():
proc.kill()
except Exception:
pass
if stderr_file:
stderr_file.close()
return result
# The accounts `omarchy agent account` registered, read straight from its
# registry. Only a registry holding a second account matters: with one, the
# record is exactly what it always was.
def registered_accounts():
state = Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local" / "state"))
try:
registry = json.loads((state / "omarchy" / "agents" / "accounts" / "codex.json").read_text(encoding="utf-8"))
except Exception:
return []
accounts = [a for a in registry.get("accounts") or [] if isinstance(a, dict) and a.get("id")]
if len(accounts) < 2:
return []
active_id = registry.get("active") or accounts[0]["id"]
if not any(a["id"] == active_id for a in accounts):
active_id = accounts[0]["id"]
for account in accounts:
account["active"] = account["id"] == active_id
account["switch"] = {
"mode": "auto" if registry.get("switch") == "auto" else "manual",
"threshold": registry.get("threshold") or 95,
}
return accounts
# Each account's app-server runs in that account's home, so it answers for that
# login and refreshes that login's tokens there.
def account_limits(account):
# The primary is ~/.codex by definition, not whatever CODEX_HOME this run
# happened to inherit — that would report another account's limits as Main's.
rpc = fetch_codex_rpc(account.get("home") or (Path.home() / ".codex"))
return {
"id": account["id"],
"label": str(account.get("label") or account["id"]),
"email": str(account.get("email") or ""),
"plan": rpc["tierLabel"] or str(account.get("plan") or ""),
"active": account["active"],
"primary": bool(account.get("primary")),
"limits": rpc["limits"],
"stale": not rpc["limits"],
"usageStatusText": rpc["usageStatusText"],
"authHelpText": rpc["authHelpText"],
"resetCredits": rpc.get("resetCredits"),
}
def main():
parser = argparse.ArgumentParser()
# --force rescans everything and rewrites the cache. --limits-only is kept
@@ -584,7 +1041,21 @@ def main():
max_age = 0 if args.force else (LIMITS_ONLY_REUSE_SECONDS if args.limits_only else SCAN_REUSE_SECONDS)
stats = cached_local_stats(max_age)
rpc = fetch_codex_rpc()
registered = registered_accounts()
accounts = [account_limits(account) for account in registered]
current = next((a for a in accounts if a["active"]), None)
if current:
# The top-level fields keep describing the account new sessions use.
rpc = {
"limits": current["limits"],
"tierLabel": current["plan"],
"usageStatusText": current["usageStatusText"],
"authHelpText": current["authHelpText"],
}
if current.get("resetCredits"):
rpc["resetCredits"] = current["resetCredits"]
else:
rpc = fetch_codex_rpc()
record = {
"schemaVersion": 1,
@@ -596,6 +1067,9 @@ def main():
}
record.update(stats)
record.update(rpc)
if accounts:
record["accountSwitch"] = registered[0]["switch"]
record["accounts"] = accounts
print(json.dumps(record, separators=(",", ":")))
+50 -3
View File
@@ -29,7 +29,7 @@ from typing import Any
AGENT_ID = "fireworks"
AGENT_NAME = "Fireworks"
AUTH_HELP = "Set FIREWORKS_API_KEY, run `firectl set-api-key`, or sign in to Fireworks in opencode."
AUTH_HELP = "Set FIREWORKS_API_KEY, run `firectl set-api-key`, or sign in to Fireworks in pi or opencode."
API_BASE_URL = "https://api.fireworks.ai"
@@ -196,11 +196,57 @@ def read_auth_file(path: Path) -> tuple[str, str]:
return api_key, account_id
def pi_auth_path() -> Path:
agent_dir = Path(os.environ.get("PI_CODING_AGENT_DIR") or (Path.home() / ".pi" / "agent"))
return agent_dir / "auth.json"
def opencode_auth_path() -> Path:
data_home = Path(os.environ.get("XDG_DATA_HOME") or (Path.home() / ".local" / "share"))
return data_home / "opencode" / "auth.json"
PI_ENV_REFERENCE = re.compile(r"\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))")
def resolve_pi_key(raw: Any) -> str:
# pi credential keys are literals, $ENV_VAR/${ENV_VAR} references, or
# !command shell lookups. The collector resolves the first two; command
# lookups stay pi-only and are skipped rather than sent to the API verbatim.
key = str(raw or "").strip()
if not key or key.startswith("!"):
return ""
# Escapes survive interpolation as sentinels, then restore.
key = key.replace("$$", "\x00").replace("$!", "\x01")
unresolved = False
def substitute(match: re.Match[str]) -> str:
nonlocal unresolved
value = os.environ.get(match.group(1) or match.group(2), "")
if not value:
unresolved = True
return value
key = PI_ENV_REFERENCE.sub(substitute, key)
if unresolved:
return ""
return key.replace("\x00", "$").replace("\x01", "!")
def read_pi_key(path: Path) -> str:
try:
parsed = json.loads(path.read_text())
except (OSError, json.JSONDecodeError):
return ""
entry = parsed.get("fireworks") if isinstance(parsed, dict) else None
if not isinstance(entry, dict):
return ""
if entry.get("type") not in (None, "api_key"):
return ""
return resolve_pi_key(entry.get("key"))
def read_opencode_key(path: Path) -> str:
try:
parsed = json.loads(path.read_text())
@@ -227,11 +273,12 @@ def read_config() -> dict[str, Any]:
def credentials(auth_path: Path, config: dict[str, Any]) -> tuple[str, str]:
file_key, file_account = read_auth_file(auth_path)
# opencode is the last resort: an explicit key or a firectl login should
# win over whatever another tool happens to be signed in with.
# Agent logins are the fallback ladder below an explicit key or a firectl
# login: pi first as Omarchy's default agent, opencode as the last resort.
api_key = (
str(os.environ.get("FIREWORKS_API_KEY", "")).strip()
or file_key
or read_pi_key(pi_auth_path())
or read_opencode_key(opencode_auth_path())
)
account_id = (
+361
View File
@@ -0,0 +1,361 @@
#!/usr/bin/python3
# omarchy:summary=Print the Grok usage record as JSON
# omarchy:args=[--force] [--limits-only]
# omarchy:hidden=true
"""Collect Grok usage into one display-ready JSON record.
The plan comes from the settings Grok caches from xAI, and the limit from the
credits endpoint the Grok CLI itself reads for its /usage view, asked with
each account's own sign-in. Sessions come from the summary Grok keeps beside
each session, and tokens and prompts from its usage.json, the ledger `grok
usage` prints: one entry per finished turn, with when it ended and its tokens
by model. The agents panel only ever reads the JSON this prints.
"""
import argparse
import json
import os
import re
import tempfile
import time
import urllib.error
import urllib.request
from datetime import datetime, timedelta, timezone
from pathlib import Path
AGENT_ID = "grok"
AGENT_NAME = "Grok"
AUTH_HELP = "Start Grok, or run `grok login`, to sign in."
CREDITS_URL = "https://cli-chat-proxy.grok.com/v1/billing?format=credits"
# Grok's own CLI watches its subscription once a minute; a panel opened and
# shut repeatedly reuses an answer for about that long.
PROBE_REUSE_SECONDS = 60
# Grok's docs give a saved sign-in 30 days; a refresh token older than that
# can't renew the access token, so it no longer counts as signed in.
REFRESH_LIFETIME_SECONDS = 30 * 24 * 3600
# A limits-only refresh needs fresh limits, not a fresh history scan.
LIMITS_ONLY_SCAN_REUSE_SECONDS = 900
def read_json(path):
try:
return json.loads(Path(path).read_text(encoding="utf-8"))
except Exception:
return None
# A temporary file of its own per write, so overlapping runs can't rename
# each other's away.
def write_json(path, payload):
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", dir=str(path.parent))
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump(payload, handle)
os.replace(tmp, path)
except Exception:
Path(tmp).unlink(missing_ok=True)
raise
def cache_root():
return Path(os.environ.get("XDG_CACHE_HOME") or (Path.home() / ".cache")) / "omarchy" / "agent-usage"
def parse_time(value):
try:
parsed = datetime.fromisoformat(str(value or "").replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
# The home Grok uses when nothing selects an account: GROK_HOME when set,
# as the CLI itself reads it, else ~/.grok.
def default_home():
return Path(os.environ.get("GROK_HOME") or (Path.home() / ".grok"))
# Every session under <home>/sessions/<folder>/<session>/: its summary says
# when it was last active and how many prompts it had, and its usage.json
# lists each finished turn with its tokens by model. Added accounts link
# their sessions to the primary's, so this covers all of them. Grok counts
# cached input inside inputTokens; the record keeps the two apart, as the
# other collectors do.
def local_stats(home):
now = datetime.now().astimezone()
today = now.date().isoformat()
week = [(now.date() - timedelta(days=offset)).isoformat() for offset in range(6, -1, -1)]
prompts = sessions = today_sessions = today_prompts = today_tokens = 0
days = set()
by_day = {day: 0 for day in week}
today_by_model = {}
models = {}
for session in (Path(home) / "sessions").glob("*/*/"):
summary = read_json(session / "summary.json")
if isinstance(summary, dict):
active = parse_time(summary.get("last_active_at"))
if active:
day = active.astimezone().date().isoformat()
sessions += 1
prompts += int(summary.get("num_messages") or 0)
days.add(day)
if day == today:
today_sessions += 1
usage = read_json(session / "usage.json")
turns = usage.get("turns") if isinstance(usage, dict) else None
for turn in turns if isinstance(turns, list) else []:
ended = parse_time(turn.get("endedAt")) if isinstance(turn, dict) else None
if not ended:
continue
day = ended.astimezone().date().isoformat()
days.add(day)
if day == today:
today_prompts += 1
for model, bucket in (turn.get("modelUsage") or {}).items():
if not isinstance(bucket, dict):
continue
# Grok's totalTokens leaves cache writes out; counting them here keeps
# the day's total equal to the sum of its model's buckets.
cached = int(bucket.get("cachedReadTokens") or 0)
uncached = max(0, int(bucket.get("inputTokens") or 0) - cached)
output = int(bucket.get("outputTokens") or 0)
created = int(bucket.get("cacheCreationTokens") or 0)
tokens = uncached + cached + output + created
totals = models.setdefault(model, {"inputTokens": 0, "outputTokens": 0, "cacheReadInputTokens": 0, "cacheCreationInputTokens": 0})
totals["inputTokens"] += uncached
totals["outputTokens"] += output
totals["cacheReadInputTokens"] += cached
totals["cacheCreationInputTokens"] += created
if day in by_day:
by_day[day] += tokens
if day == today:
today_tokens += tokens
today_by_model[model] = today_by_model.get(model, 0) + tokens
return {
"hasLocalStats": sessions > 0,
"todayPrompts": today_prompts,
"todaySessions": today_sessions,
"todayTotalTokens": today_tokens,
"todayTokensByModel": today_by_model,
"recentDays": [{"date": day, "messageCount": by_day[day]} for day in week],
"totalPrompts": prompts,
"totalSessions": sessions,
"activeDays": len(days),
"activeDates": sorted(days),
"modelUsage": models,
}
# A scan from before midnight would count yesterday's sessions as today's.
def cached_local_stats(home, max_age):
cache = cache_root() / "grok-stats.json"
cached = read_json(cache) or {}
today = datetime.now().astimezone().date().isoformat()
if cached.get("home") == str(home) and cached.get("day") == today and time.time() - float(cached.get("at") or 0) < max_age:
return cached["stats"]
stats = local_stats(home)
write_json(cache, {"home": str(home), "day": today, "at": time.time(), "stats": stats})
return stats
# One login per issuer in auth.json; the first is the CLI's own.
def login(home):
auth = read_json(Path(home) / "auth.json")
if not isinstance(auth, dict):
return {}
return next((v for v in auth.values() if isinstance(v, dict)), {})
def plan(home):
cached = read_json(Path(home) / "settings_cache.json") or {}
try:
settings = json.loads(cached.get("payload") or "{}").get("settings") or {}
except Exception:
return ""
return str(settings.get("subscription_tier_display") or "")
# The credits answer as one limit window: how much of this period's included
# usage is gone, and when the period ends. The answer is protobuf JSON, which
# leaves out a field holding zero, so a period with nothing used yet has no
# percentage at all.
def credits_limit(data):
config = data["config"]
period = config["currentPeriod"]
return {
"label": "Weekly" if "WEEK" in str(period.get("type")) else "Monthly",
"percent": max(0.0, min(1.0, float(config.get("creditUsagePercent") or 0) / 100)),
"resetsAt": parse_time(period["end"]).isoformat(),
}
# A window whose reset has passed starts over empty. A weekly one's next
# reset is a whole number of weeks later; a monthly one's isn't known.
def rolled_forward(limits):
now = datetime.now(timezone.utc)
rolled = []
for limit in limits:
resets = parse_time(limit.get("resetsAt"))
if resets and resets <= now:
limit = dict(limit, percent=0.0, resetsAt="")
if limit.get("label") == "Weekly":
weeks = (now - resets) // timedelta(weeks=1) + 1
limit["resetsAt"] = (resets + weeks * timedelta(weeks=1)).isoformat()
rolled.append(limit)
return rolled
def collect_limits(home, key, force):
result = {"limits": [], "usageStatusText": "", "authHelpText": AUTH_HELP, "live": False, "fetchedAtMs": 0}
entry = login(home)
token = str(entry.get("key") or "")
if not token:
result["usageStatusText"] = "Waiting for auth"
return result
cache = cache_root() / f"grok-limits-{key}.json"
cached = read_json(cache) or {}
fetched_at = float(cached.get("fetchedAtMs") or 0)
result["fetchedAtMs"] = fetched_at
expires = parse_time(entry.get("expires_at"))
if expires and expires.timestamp() <= time.time():
result["limits"] = cached.get("limits") or []
# The access token lives six hours and Grok mints a new one from its
# refresh token whenever it starts, so a lapsed one is routine, not a
# sign-out. And while Grok hasn't run, nothing here has spent its
# allowance, so the last numbers still stand, rolled past any reset.
if entry.get("refresh_token") and time.time() - expires.timestamp() < REFRESH_LIFETIME_SECONDS:
result.update(limits=rolled_forward(result["limits"]), live=True)
if not result["limits"]:
result["usageStatusText"] = "Limits paused"
result["authHelpText"] = "Start Grok to see its limits; signing in again is not needed."
else:
result["usageStatusText"] = "Sign-in expired"
result["authHelpText"] = "Grok's saved sign-in expired. Start Grok to refresh it."
return result
if cached.get("limits") and not force and time.time() - fetched_at / 1000 < PROBE_REUSE_SECONDS:
result.update(limits=cached["limits"], live=True, authHelpText="")
return result
request = urllib.request.Request(CREDITS_URL, headers={"Authorization": f"Bearer {token}", "Accept": "application/json"})
try:
with urllib.request.urlopen(request, timeout=8) as response:
limit = credits_limit(json.loads(response.read()))
except urllib.error.HTTPError as error:
limit = None
if error.code in (401, 403):
result["usageStatusText"] = "Waiting for auth"
except Exception:
limit = None
if limit:
result.update(limits=[limit], live=True, fetchedAtMs=round(time.time() * 1000), authHelpText="")
write_json(cache, {"fetchedAtMs": result["fetchedAtMs"], "limits": result["limits"]})
else:
result["limits"] = cached.get("limits") or []
if not result["usageStatusText"] and not result["limits"]:
result["usageStatusText"] = "Grok limits unavailable"
return result
# The accounts `omarchy agent account` registered, read straight from its
# registry. Only a registry holding a second account matters: with one, the
# record describes ~/.grok alone.
def registered_accounts():
state = Path(os.environ.get("XDG_STATE_HOME") or (Path.home() / ".local" / "state"))
registry = read_json(state / "omarchy" / "agents" / "accounts" / "grok.json") or {}
accounts = [a for a in registry.get("accounts") or [] if isinstance(a, dict) and a.get("id")]
if len(accounts) < 2:
return []
active_id = registry.get("active") or accounts[0]["id"]
if not any(a["id"] == active_id for a in accounts):
active_id = accounts[0]["id"]
for account in accounts:
account["active"] = account["id"] == active_id
account["switch"] = {
"mode": "auto" if registry.get("switch") == "auto" else "manual",
"threshold": registry.get("threshold") or 95,
}
return accounts
def home_of(account):
return Path(account.get("home") or (Path.home() / ".grok"))
# Keyed by who the home is signed in as, so a home signed in to someone else
# never shows the last one's numbers.
def cache_key(home, fallback):
user = str(login(home).get("user_id") or "")
return re.sub(r"[^A-Za-z0-9_-]", "", user) or fallback
def account_limits(account, force):
home = home_of(account)
limits = collect_limits(home, cache_key(home, account["id"]), force)
return {
"id": account["id"],
"label": str(account.get("label") or account["id"]),
"email": str(account.get("email") or ""),
"plan": plan(home) or str(account.get("plan") or ""),
"active": account["active"],
"primary": bool(account.get("primary")),
"limits": limits["limits"],
"stale": not limits["live"],
"fetchedAt": limits["fetchedAtMs"],
"usageStatusText": limits["usageStatusText"],
"authHelpText": limits["authHelpText"],
}
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--force", action="store_true")
parser.add_argument("--limits-only", action="store_true")
args = parser.parse_args()
registered = registered_accounts()
accounts = [account_limits(account, args.force) for account in registered]
current = next((a for a in accounts if a["active"]), None)
if current:
tier = current["plan"]
limits = current
stale, fetched_at = current["stale"], current["fetchedAt"]
else:
home = default_home()
tier = plan(home)
limits = collect_limits(home, cache_key(home, "main"), args.force)
stale, fetched_at = not limits["live"], limits["fetchedAtMs"]
# Nobody signed in to Grok here: an empty record, which the panel skips.
# With accounts registered, the primary is ~/.grok by definition.
stats_home = Path.home() / ".grok" if accounts else default_home()
signed_in = bool(accounts) or bool(login(stats_home))
record = {
"schemaVersion": 1,
"id": AGENT_ID,
"name": AGENT_NAME,
"updatedAt": datetime.now(timezone.utc).isoformat(),
"ready": signed_in,
"tierLabel": tier,
"usageStatusText": limits["usageStatusText"],
"authHelpText": limits["authHelpText"],
"limits": limits["limits"],
"limitsStale": stale,
"limitsFetchedAt": fetched_at,
}
record.update(cached_local_stats(stats_home, LIMITS_ONLY_SCAN_REUSE_SECONDS if args.limits_only and not args.force else 0))
if accounts:
record["accountSwitch"] = registered[0]["switch"]
record["accounts"] = accounts
print(json.dumps(record, separators=(",", ":")))
if __name__ == "__main__":
main()
+21
View File
@@ -51,6 +51,13 @@ collect() {
mv "$tmp" "$USAGE_DIR/$agent.json"
}
# Accounts can be signed in to someone else since they were added; the
# records name them from the registry, so bring it up to date first.
accounts_dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/agents/accounts"
if [[ -f $accounts_dir/claude.json || -f $accounts_dir/codex.json || -f $accounts_dir/grok.json ]]; then
"$OMARCHY_PATH/bin/omarchy-agent-account-state" refresh
fi
pids=()
for collector in "$OMARCHY_PATH"/bin/omarchy-agent-usage-*; do
[[ -x $collector ]] || continue
@@ -65,4 +72,18 @@ status=0
for pid in "${pids[@]}"; do
wait "$pid" || status=1
done
# Fresh limits are when switching accounts near a limit gets decided. A switch
# changes which account the record's own limits describe, so that record is
# collected once more; the limits probes it repeats are still cached.
for agent in claude codex grok; do
if wanted "$agent" && [[ -f $accounts_dir/$agent.json ]]; then
switched=$("$OMARCHY_PATH/bin/omarchy-agent-account-state" autoswitch "$agent")
if [[ -n $switched ]]; then
flags=(--limits-only)
collect "$OMARCHY_PATH/bin/omarchy-agent-usage-$agent" "$agent" || status=1
fi
fi
done
exit $status
+15 -1
View File
@@ -19,6 +19,9 @@ for diagnostics or after hardware/package updates.
--defer-provisioning runs without an install user (deferred-provisioning installs create the user at
first boot); group grants are recorded in /var/lib/omarchy/provisioning/groups.
In an image build (/var/lib/omarchy/image/target exists) nothing runs here: every step is queued
for omarchy-provision-hardware on the machine's first boot.
USAGE
}
@@ -66,10 +69,21 @@ else
fi
export OMARCHY_INSTALL_USER="$install_user"
# Only omarchy-provision-hardware runs steps as an image's first boot.
unset OMARCHY_IMAGE_DEFERRED_HARDWARE
export OMARCHY_PATH="${OMARCHY_PATH:-/usr/share/omarchy}"
export OMARCHY_INSTALL="${OMARCHY_INSTALL:-$OMARCHY_PATH/install}"
export OMARCHY_INSTALL_LOG_FILE="${OMARCHY_INSTALL_LOG_FILE:-/var/log/omarchy-install.log}"
export PATH="$OMARCHY_PATH/bin:$PATH"
source "$OMARCHY_INSTALL/helpers/logging.sh"
source "$OMARCHY_INSTALL/hardware/all.sh"
source "$OMARCHY_INSTALL/helpers/image-target.sh"
omarchy_image_init
omarchy_image_lock
if omarchy_image_manifest_present; then
omarchy_image_read_manifest
omarchy_image_defer_hardware
else
source "$OMARCHY_INSTALL/hardware/all.sh"
fi
+34 -2
View File
@@ -6,6 +6,12 @@
set -e
# Install and upgrade callers can start this helper as root. Ignore their PATH
# so optional commands never fall through to a user-writable directory.
if (( EUID == 0 )); then
export PATH=/usr/share/omarchy/bin:/usr/local/bin:/usr/bin:/bin
fi
target_user=${OMARCHY_INSTALL_USER:-${SUDO_USER:-}}
if [[ -z $target_user && -n ${PKEXEC_UID:-} ]]; then
target_user=$(getent passwd "$PKEXEC_UID" | cut -d: -f1)
@@ -34,15 +40,41 @@ auth required pam_faillock.so authsucc
account include system-local-login
EOF
if omarchy-cmd-present fprintd-list && fprintd-list "$target_user" 2>/dev/null | grep -qi finger; then
# Install resume recovery alongside fingerprint PAM, so setup and upgrades
# cannot leave one configured without the other.
resume_hook_src="$OMARCHY_PATH/default/systemd/system-sleep/fprintd-resume"
resume_hook_dst=/usr/lib/systemd/system-sleep/fprintd-resume
stop_timeout_src="$OMARCHY_PATH/default/systemd/system/fprintd.service.d/10-stop-timeout.conf"
stop_timeout_dst=/etc/systemd/system/fprintd.service.d/10-stop-timeout.conf
# Keep the privileged probe pinned to /usr/bin; failure is not empty enrollment.
if [[ -x /usr/bin/fprintd-list ]]; then
fingerprint_status=$(/usr/bin/fprintd-list "$target_user" 2>&1 || true)
else
fingerprint_status="no"
fi
if grep -qE '^[[:space:]]*-[[:space:]]*#[0-9]+:' <<< "$fingerprint_status"; then
echo "Configuring lock screen fingerprint authentication..."
as_root install -Dm755 -o root -g root "$resume_hook_src" "$resume_hook_dst"
as_root install -Dm644 -o root -g root "$stop_timeout_src" "$stop_timeout_dst"
as_root systemctl daemon-reload
as_root tee /etc/pam.d/omarchy-lock-fingerprint >/dev/null <<'EOF'
#%PAM-1.0
auth required pam_fprintd.so
account include system-local-login
EOF
elif [[ $fingerprint_status == "no" ]] || grep -qi 'has no fingers enrolled' <<< "$fingerprint_status"; then
as_root rm -f /etc/pam.d/omarchy-lock-fingerprint "$resume_hook_dst"
# Only reload when something was there: this branch also runs in the
# install chroot, where there is no systemd to reload.
if [[ -e $stop_timeout_dst ]]; then
as_root rm -f "$stop_timeout_dst"
as_root rmdir --ignore-fail-on-non-empty "$(dirname "$stop_timeout_dst")"
as_root systemctl daemon-reload
fi
else
as_root rm -f /etc/pam.d/omarchy-lock-fingerprint
echo "Could not check fingerprint enrollment; keeping the existing configuration." >&2
fi
# omarchy-shell can't reach a running shell during chroot install. The echo
+2 -2
View File
@@ -6,8 +6,8 @@ wpctl set-mute @DEFAULT_AUDIO_SOURCE@ toggle >/dev/null
if wpctl get-volume @DEFAULT_AUDIO_SOURCE@ | grep -q MUTED; then
omarchy-brightness-keyboard-mute on
omarchy-osd -i microphone-muted -m "Microphone muted"
omarchy-osd -i microphone-muted -m "$(omarchy-text "Microphone muted")"
else
omarchy-brightness-keyboard-mute off
omarchy-osd -i microphone -m "Microphone on"
omarchy-osd -i microphone -m "$(omarchy-text "Microphone on")"
fi
+9 -1
View File
@@ -15,6 +15,14 @@ fi
wpctl set-default "$node_id" 2>/dev/null || true
pactl set-default-source "$source_name" 2>/dev/null || true
pactl list short source-outputs 2>/dev/null | awk '{ print $1 }' | while read -r output; do
# A filter's own capture (the input half of a filter chain, loopback or echo
# canceller, which PipeWire joins to its output by node.link-group) is the
# processing, not an app: moving it would feed the filter from the new input, or
# from its own output. pactl translates the listing, so it is read in the C
# locale.
LC_ALL=C pactl list source-outputs 2>/dev/null | awk '
/^Source Output #/ { if (id != "" && !filter) print id; id = substr($3, 2); filter = 0 }
/^[[:space:]]*node\.link-group = / { filter = 1 }
END { if (id != "" && !filter) print id }' | while read -r output; do
[[ -n $output ]] && pactl move-source-output "$output" "$source_name" 2>/dev/null || true
done
+43 -2
View File
@@ -29,16 +29,25 @@ fi
# stream down to the sink underneath.
downstream="$(pactl list sink-inputs 2>/dev/null |
awk -v virt="$sink" '
BEGIN {
output = virt
sub(/^effect_input\./, "effect_output.", output)
}
/^Sink Input #/ {target = ""}
/^[[:space:]]*Sink:/ {target = $2}
/node\.name = / {
name = $0
sub(/.*node\.name = "/, "", name)
sub(/"$/, "", name)
if (index(name, virt) == 1 && target != "") {print target; exit}
if (target != "" && (name == output || (output == virt && index(name, virt) == 1))) {selected = target; exit}
if (target != "" && fallback == "" && index(name, virt) == 1) fallback = target
}
/application\.name = "EasyEffects"/ {
if (virt == "easyeffects_sink" && target != "") {print target; exit}
if (virt == "easyeffects_sink" && target != "") {selected = target; exit}
}
END {
if (selected != "") print selected
else if (fallback != "") print fallback
}')"
if [[ -n $downstream ]]; then
@@ -50,6 +59,38 @@ if [[ -n $downstream ]]; then
fi
fi
# EasyEffects 8 does not play into the physical sink through a stream of its
# own: its output node links its ports straight to the sink's playback ports,
# so the sink-input search above finds nothing and the keys would move the
# EasyEffects sink -- whose volume its monitor, the input of the processing,
# does not follow. Follow the port links instead: the first ALSA or Bluetooth
# sink any EasyEffects output port is linked to.
if [[ $sink == "easyeffects_sink" ]]; then
linked="$(pw-link -l 2>/dev/null |
awk '
/^[^ ]/ {src = $0}
/^[[:space:]]*\|->/ {
if (src ~ /^ee_/ && $2 ~ /^(alsa|bluez)_output\..*:playback_/) {
sub(/:playback_.*$/, "", $2); print $2; exit
}
}')"
# Idle, EasyEffects drops those links: fall back to the output device it is
# configured to feed, when that sink exists.
if [[ -z $linked ]]; then
configured="$(sed -n 's/^outputDevice=//p' \
"${XDG_CONFIG_HOME:-$HOME/.config}/easyeffects/db/easyeffectsrc" 2>/dev/null)"
if [[ -n $configured ]] && pactl list sinks short 2>/dev/null | awk '{print $2}' | grep -qxF -- "$configured"; then
linked="$configured"
fi
fi
if [[ -n $linked ]]; then
printf '%s\n' "$linked"
exit 0
fi
fi
# Nothing resolvable downstream -- the DSP sink may simply be idle and unlinked.
# Fall back to the sink itself so callers still have something to act on.
printf '%s\n' "$sink"
+1 -1
View File
@@ -13,7 +13,7 @@ sinks=$(timeout 2 pactl -f json list sinks |
sinks_count=$(jq 'length' <<<"$sinks")
if (( sinks_count == 0 )); then
omarchy-osd -m "No audio devices found"
omarchy-osd -m "$(omarchy-text "No audio devices found")"
exit 1
fi
+17 -4
View File
@@ -1,14 +1,27 @@
#!/bin/bash
# omarchy:summary=Print PulseAudio sink availability for the shell
# omarchy:summary=Print PulseAudio sink or source availability for the shell
# omarchy:group=audio
# omarchy:args=[sources]
# With "sources", print source availability instead: a jack input such as a
# headset microphone reports its port unavailable while nothing is plugged in.
# pactl translates the listing ("not available" too), so it is read in the C
# locale.
kind=sinks
header="Sink #"
if [[ ${1:-} == "sources" ]]; then
kind=sources
header="Source #"
fi
# A speaker tuning is a virtual sink in front of the real speakers. Both exist
# in the graph, but selecting the physical one would only bypass the tuning, so
# report it unavailable and keep it out of the output list.
fronted="$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)"
fronted=""
[[ $kind == sinks ]] && fronted="$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)"
pactl list sinks 2>/dev/null | awk -v fronted="$fronted" '
LC_ALL=C pactl list "$kind" 2>/dev/null | awk -v fronted="$fronted" -v header="$header" '
function emit_sink() {
if (name == "") return
if (fronted != "" && name == fronted) {
@@ -18,7 +31,7 @@ pactl list sinks 2>/dev/null | awk -v fronted="$fronted" '
print name "\t" ((port_count == 0 || available) ? 1 : 0)
}
/^Sink #/ {
index($0, header) == 1 {
emit_sink()
name = ""
in_ports = 0
+104 -10
View File
@@ -2,7 +2,7 @@
# omarchy:summary=Configure the bar and its widget layout
# omarchy:group=bar
# omarchy:args=use <id> | reset | defaults | position <top|bottom|left|right> | transparent <true|false|toggle> | put <id> [placement] | move <id> [placement] | set <id> <key> <value> [--json] [placement]
# omarchy:args=use <id> | reset | defaults | position [top|bottom|left|right] | background [opaque|glassy|none|cycle] | put <id> [placement] | move <id> [placement] | set <id> <key> <value> [--json] [placement] | get <id> <key>
# omarchy:examples=omarchy bar use local.neon-bar | omarchy bar put omarchy.keyboard-layout --after omarchy.clock | omarchy bar move omarchy.clock --section center --index 0 | omarchy bar set omarchy.clock format HH:mm
set -euo pipefail
@@ -16,13 +16,19 @@ Usage: omarchy bar <command> [args...]
use <id> Use a bar option as the active bar
reset Return to the built-in Omarchy bar
defaults Restore the default bar and service widgets
position <top|bottom|left|right> Bar position
transparent <true|false|toggle> Bar transparency
position [top|bottom|left|right] Bar position; prints the current one
when given none
background [opaque|glassy|none|cycle]
Bar background: glassy shows what is
behind the bar blurred, cycle steps
none, glassy, opaque; prints the
current one when given none
put <id> [placement] Put a widget on the bar, leaving one
that is already there where it is
move <id> [placement] Move a widget within or between sections
set <id> <key> <value> [--json] [placement]
Set a per-widget option
get <id> <key> Print a per-widget option
Placement:
--section <left|center|right> Target section
@@ -192,30 +198,92 @@ cmd_defaults() {
" \
--slurpfile defaults "$DEFAULTS_FILE" \
--argjson widgets "$optional_widgets"
sync_glass || fail "could not change the blur behind the bar"
echo "Restored the default Omarchy bar"
}
cmd_position() {
local position="${1:-}"
[[ -n $position ]] || fail "position is required"
if (( $# == 0 )); then
jq -r '.bar.position // "top"' "$(source_file)"
return
fi
local position="$1"
(( $# == 1 )) || fail "position takes a single value"
[[ $position =~ ^(top|bottom|left|right)$ ]] || fail "position must be top, bottom, left, or right"
commit "$NORMALIZE | .bar.position = \$position" --arg position "$position"
echo "Bar position set to $position"
}
# The bar background is opaque, glassy, or none. A shell.json from before it
# had one says transparent instead, which is none, and setting a background
# drops it.
current_background() {
jq -r '.bar.background // (if .bar.transparent == true then "none" else "opaque" end)' "$(source_file)"
}
cmd_background() {
if (( $# == 0 )); then
current_background
return
fi
local background="$1"
(( $# == 1 )) || fail "background takes a single value"
[[ $background =~ ^(opaque|glassy|none|cycle)$ ]] || fail "background must be opaque, glassy, none, or cycle"
if [[ $background == "cycle" ]]; then
# Each step changes the bar by one degree: clear, then glassy, then opaque.
case $(current_background) in
none) background="glassy" ;;
glassy) background="opaque" ;;
*) background="none" ;;
esac
fi
local previous
previous=$(current_background)
commit "$NORMALIZE | .bar.background = \$background | del(.bar.transparent)" --arg background "$background"
if ! sync_glass; then
commit "$NORMALIZE | .bar.background = \$background | del(.bar.transparent)" --arg background "$previous"
fail "could not change the blur behind the bar"
fi
echo "Bar background set to $background"
}
# The older on/off form of the background, kept for scripts and dotfiles:
# transparent is none, and not transparent is opaque.
cmd_transparent() {
local transparent="${1:-}"
[[ -n $transparent ]] || fail "transparent is required"
(( $# == 1 )) || fail "transparent takes a single value"
[[ $transparent =~ ^(true|false|toggle)$ ]] || fail "transparent must be true, false, or toggle"
if [[ $transparent == "toggle" ]]; then
commit "$NORMALIZE | .bar.transparent = (.bar.transparent != true)"
echo "Bar transparency toggled"
else
commit "$NORMALIZE | .bar.transparent = \$transparent" --argjson transparent "$transparent"
echo "Bar transparency set to $transparent"
[[ $(current_background) == "none" ]] && transparent="false" || transparent="true"
fi
if [[ $transparent == "true" ]]; then
cmd_background none
else
cmd_background opaque
fi
}
# Hyprland blurs behind the bar only while the bar-glassy toggle is on, so it
# follows the bar background, reloading Hyprland only when it changes. The
# toggle sits where Hyprland reads it, under XDG_STATE_HOME.
sync_glass() {
local dir="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/toggles/hypr"
local flag="$dir/bar-glassy.lua"
if [[ $(current_background) == "glassy" ]]; then
[[ ! -f $flag ]] || return 0
mkdir -p "$dir" && cp "$OMARCHY_PATH/default/hypr/toggles/bar-glassy.lua" "$flag" || return 1
else
[[ -f $flag ]] || return 0
rm -f "$flag" || return 1
fi
hyprctl reload >/dev/null 2>&1 || true
}
bar_widget_default_section() {
@@ -363,6 +431,21 @@ cmd_set() {
echo "Set $key on $id"
}
# Prints the option from the first instance of the widget on the bar, raw for a
# string and as JSON otherwise, and fails when no instance sets it.
cmd_get() {
local id="${1:-}"
local key="${2:-}"
[[ -n $id ]] || fail "get requires a widget id"
[[ -n $key ]] || fail "get requires a setting key"
(( $# == 2 )) || fail "get takes a widget id and a setting key"
jq -e -r --arg id "$id" --arg key "$key" '
first(.bar.layout[]?[]? | select(.id == $id) | .[$key] | select(. != null))
| if type == "string" then . else tojson end
' "$(source_file)" 2>/dev/null || exit 1
}
# --------------------------------------------------------------------- dispatch
command="${1:-}"
@@ -385,6 +468,14 @@ case "$command" in
transparent)
cmd_transparent "$@"
;;
background)
cmd_background "$@"
;;
# Internal: the shell runs this when it loads a bar background, so one set
# by editing shell.json gets its blur too.
sync-glass)
sync_glass || fail "could not change the blur behind the bar"
;;
put)
cmd_put "$@"
;;
@@ -394,6 +485,9 @@ case "$command" in
set)
cmd_set "$@"
;;
get)
cmd_get "$@"
;;
-h | --help | help | "")
usage
;;
+4 -1
View File
@@ -109,7 +109,10 @@ right)
;;
esac
pixel=$(magick "$background_path" -auto-orient \
# Sample the first frame only. Without the selector a video background makes
# ImageMagick decode the whole file and emit one value per frame, and the match
# below then fails into the fallback colour.
pixel=$(magick "$background_path[0]" -auto-orient \
-resize "${screen_width}x${screen_height}^" \
-gravity center -extent "${screen_width}x${screen_height}" \
-gravity NorthWest -crop "$crop" +repage \
+1 -1
View File
@@ -13,5 +13,5 @@ fi
level=$1
omarchy-notification-send -g 󱐋 -u critical "Time to recharge!" "Battery is down to ${level}%" -i battery-caution -t 30000
omarchy-notification-send -g 󱐋 -u critical "$(omarchy-text "Time to recharge!")" "$(omarchy-text "Battery is down to %1%" "$level")" -i battery-caution -t 30000
omarchy-hook battery-low "$level"
+10 -4
View File
@@ -2,10 +2,16 @@
# omarchy:summary=Returns true if a battery is present on the system.
for bat in /sys/class/power_supply/BAT*; do
[[ -r $bat/present ]] &&
[[ $(cat $bat/present) == "1" ]] &&
[[ $(cat $bat/type) == "Battery" ]] &&
# Not every battery is called BAT*: Apple Silicon reports macsmc-battery and
# some Framework and Chromebook firmware CMB0, so decide by type, not by name.
power_supply_path="${OMARCHY_POWER_SUPPLY_PATH:-/sys/class/power_supply}"
for bat in "$power_supply_path"/*; do
[[ -r $bat/present && $(<"$bat/present") == "1" ]] || continue
[[ -r $bat/type && $(<"$bat/type") == "Battery" ]] || continue
# A wireless mouse or keyboard registers a Battery too, scoped Device. The
# one that powers the machine is scoped System, or unscoped on older drivers.
[[ -r $bat/scope && $(<"$bat/scope") == "Device" ]] && continue
exit 0
done
+30 -9
View File
@@ -18,11 +18,23 @@ case "${1:-}" in
;;
esac
battery=$(upower -e 2>/dev/null | grep BAT | head -n 1)
# UPower names the device after the sysfs entry, and only some kernels call
# that BAT*: Apple Silicon reports battery_macsmc_battery. Match on the class,
# then take the first one that powers the machine: a wireless mouse or keyboard
# is a battery_ device too, and UPower marks it "power supply: no".
battery=""
battery_info=""
while read -r candidate; do
[[ -n $candidate ]] || continue
candidate_info=$(upower -i "$candidate" 2>/dev/null) || continue
if grep -qE '^\s*power supply:\s+yes' <<<"$candidate_info"; then
battery=$candidate
battery_info=$candidate_info
break
fi
done < <(upower -e 2>/dev/null | grep '/battery_')
[[ -z $battery ]] && exit 0
battery_info=$(upower -i "$battery")
percentage=$(awk '/percentage/ { print int($2); exit }' <<<"$battery_info")
capacity=$(awk '/energy-full:/ { printf "%d", $2; exit }' <<<"$battery_info")
time_remaining=$(awk '/time to (empty|full)/ {
@@ -56,7 +68,10 @@ elif [[ -r $battery_path/current_now && -r $battery_path/voltage_now ]]; then
'BEGIN { print microamps * microvolts / 1000000000000 }')
fi
# Some kernels sign power_now by direction (negative while discharging on
# Apple Silicon); the state field already says which way it flows.
power_rate=$(awk -v rate="${power_rate_raw:-0}" 'BEGIN {
if (rate < 0) rate = -rate
rounded = sprintf("%.1f", rate)
sub(/\.0$/, "", rounded)
print rounded
@@ -65,8 +80,12 @@ state=$(awk '/state/ { print $2; exit }' <<<"$battery_info")
threshold_start=$(awk '/charge-start-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info")
threshold_end=$(awk '/charge-end-threshold:/ { gsub(/%/, "", $2); print int($2); exit }' <<<"$battery_info")
[[ -z $threshold_end ]] && threshold_end=$(cat "$power_supply_path"/BAT*/charge_control_end_threshold 2>/dev/null | head -1)
[[ -z $threshold_start ]] && threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1)
# Read the thresholds from the battery UPower resolved rather than a BAT* glob,
# which misses every battery with another name.
[[ -n $threshold_end || ! -r $battery_path/charge_control_end_threshold ]] || threshold_end=$(<"$battery_path/charge_control_end_threshold")
[[ -n $threshold_start || ! -r $battery_path/charge_control_start_threshold ]] || threshold_start=$(<"$battery_path/charge_control_start_threshold")
[[ -n $threshold_end ]] || threshold_end=$(cat "$power_supply_path"/BAT*/charge_control_end_threshold 2>/dev/null | head -1)
[[ -n $threshold_start ]] || threshold_start=$(cat "$power_supply_path"/BAT*/charge_control_start_threshold 2>/dev/null | head -1)
ac_online=false
for supply in "$power_supply_path"/*; do
@@ -107,7 +126,9 @@ if [[ $shell_output == "true" ]]; then
printf 'size\t%s\n' "${capacity}Wh"
printf 'time\t%s\n' "$time_remaining"
cycles=$(cat "$power_supply_path"/BAT*/cycle_count 2>/dev/null | head -1)
cycles=""
[[ ! -r $battery_path/cycle_count ]] || cycles=$(<"$battery_path/cycle_count")
[[ -n $cycles ]] || cycles=$(cat "$power_supply_path"/BAT*/cycle_count 2>/dev/null | head -1)
[[ -n $cycles ]] && printf 'cycles\t%s\n' "$cycles"
@@ -129,9 +150,9 @@ if [[ $charge_holding == "true" ]]; then
threshold_label="${threshold_end}%"
fi
echo "Battery ${percentage}% · Holding at ${threshold_label} · ${power_rate}W / ${capacity}Wh"
omarchy-text "Battery %1% · Holding at %2 · %3W / %4Wh" "$percentage" "$threshold_label" "$power_rate" "$capacity"
elif [[ $state == "charging" ]]; then
echo "Battery ${percentage}% · ${time_remaining} to full ·  ${power_rate}W / ${capacity}Wh"
omarchy-text "Battery %1% · %2 to full ·  %3W / %4Wh" "$percentage" "$time_remaining" "$power_rate" "$capacity"
else
echo "Battery ${percentage}% · ${time_remaining} left ·  ${power_rate}W / ${capacity}Wh"
omarchy-text "Battery %1% · %2 left ·  %3W / %4Wh" "$percentage" "$time_remaining" "$power_rate" "$capacity"
fi
+28 -1
View File
@@ -40,7 +40,30 @@ use_apple_display() {
}
use_ddc_display() {
[[ -n $monitor ]] && ! monitor_is_internal
[[ -n $monitor ]] && ! monitor_is_internal && monitor_may_have_ddc
}
# A platform whose display driver registers no DDC channel says so in its
# displays.conf (see docs/file-layout.md): there a probe finds only unrelated
# I2C buses, slowly, so only a connector with a ddc node is probed. Everywhere
# else every external monitor is.
monitor_may_have_ddc() {
local displays_conf=/usr/share/omarchy-platform/displays.conf directive argument
[[ -r $displays_conf ]] || return 0
while IFS=$' \t\r' read -r directive argument || [[ -n $directive ]]; do
if [[ $directive == "ddc-require-connector-ddc" && -z $argument ]]; then
compgen -G "/sys/class/drm/card*-$monitor/ddc" >/dev/null
return
fi
done <"$displays_conf"
return 0
}
# The kernel backlight is the built-in panel's, so an external monitor without
# DDC fails rather than dimming the panel.
use_backlight_display() {
[[ -z $monitor ]] || monitor_is_internal
}
if (( $# == 0 )); then
@@ -50,6 +73,8 @@ if (( $# == 0 )); then
elif use_ddc_display; then
omarchy-brightness-display-ddc "$monitor"
exit
elif ! use_backlight_display; then
exit 1
fi
device="$(omarchy-hw-display)" || exit 1
@@ -85,6 +110,8 @@ if use_apple_display; then
elif use_ddc_display; then
brightness="$(omarchy-brightness-display-ddc "$monitor" "$step")" || exit 1
(( no_osd )) || omarchy-osd -i brightness -p "$brightness"
elif ! use_backlight_display; then
exit 1
else
# Current device highlighted
device="$(omarchy-hw-display)" || exit 1
+2 -2
View File
@@ -23,7 +23,7 @@ SELECTION=$(slurp 2>/dev/null)
RESULT=$(grim -g "$SELECTION" - | zbarimg -q --raw -Sdisable -Sqrcode.enable - 2>/dev/null)
if [[ -z $RESULT ]]; then
omarchy-notification-send -g 󰐲 -u critical "No QR code found" "Select a region containing a QR code"
omarchy-notification-send -g 󰐲 -u critical "$(omarchy-text "No QR code found")" "$(omarchy-text "Select a region containing a QR code")"
exit 1
fi
@@ -33,4 +33,4 @@ fi
# the notification history, and an unmarked copy would be retained by clipboard
# history. Pasting still works; only the recorded copy is given up.
printf '%s' "$RESULT" | wl-copy --sensitive
omarchy-notification-send -g 󰐲 "QR code copied to clipboard"
omarchy-notification-send -g 󰐲 "$(omarchy-text "QR code copied to clipboard")"
+324 -48
View File
@@ -2,7 +2,7 @@
# omarchy:summary=Start or stop screen recording
# omarchy:group=capture
# omarchy:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=<device>] [--webcam-size=<small|medium|large>] [--resolution=<size>] [--stop-recording]
# omarchy:args=[--fullscreen] [--with-desktop-audio] [--with-microphone-audio] [--with-webcam] [--webcam-device=<device>] [--webcam-size=<small|medium|large>] [--resolution=<size>] [--stop-recording] [--status]
# omarchy:examples=omarchy screenrecord | omarchy capture screenrecording --with-desktop-audio
# omarchy:aliases=omarchy screenrecord
#
@@ -14,18 +14,20 @@
# DMA-BUF modifier import on some configurations, leaving recording unable to
# start.
#
# Env: OMARCHY_SCREENRECORD_DEBUG=true appends gpu-screen-recorder's stderr (and
# the picker target it was launched with) to /tmp/omarchy-screenrecord.log so
# users can attach a log when reporting capture failures.
# Env: OMARCHY_SCREENRECORD_DEBUG=true appends the recorder's stderr (and the
# picker target it was launched with) to $XDG_RUNTIME_DIR/omarchy-screenrecord.log
# so users can attach a log when reporting capture failures.
#
# Backend: gpu-screen-recorder. Where it is not installed, or it exits before
# the recording starts (it knows only NVIDIA, AMD, Intel and Broadcom GPUs, and
# aborts on others such as Apple Silicon's), wf-recorder records instead when
# it is installed. wf-recorder encodes on the CPU and captures one audio
# device, so desktop and microphone audio together go through a temporary null
# sink. Portal capture and --resolution are gpu-screen-recorder only.
[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs
OUTPUT_DIR="${OMARCHY_SCREENRECORD_DIR:-${XDG_VIDEOS_DIR:-$HOME/Videos}}"
if [[ ! -d $OUTPUT_DIR ]]; then
omarchy-notification-send -u critical -t 3000 "Screen recording directory does not exist: $OUTPUT_DIR"
exit 1
fi
DESKTOP_AUDIO="false"
MICROPHONE_AUDIO="false"
WEBCAM="false"
@@ -34,9 +36,30 @@ WEBCAM_SIZE="medium"
RESOLUTION=""
FULLSCREEN="false"
STOP_RECORDING="false"
RECORDING_FILE="/tmp/omarchy-screenrecord-filename"
REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region"
LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "/tmp/omarchy-screenrecord.log" || echo "/dev/null")
STATUS="false"
# Both of these live in the per-user runtime directory, which is 0700. What
# RECORDING_FILE holds is read back on stop and used as a path -- ffmpeg writes
# beside it, `mv` replaces it, and `rm -f` deletes its preview -- so a name in
# world-writable /tmp is a name any other local account can create first and
# then point wherever it likes. The debug log carries the recorder's stderr and
# the picked geometry, which is nobody else's business either. Fall back to
# the state directory when there is no session runtime dir -- :-/tmp would put
# both files back at the fixed names this comment describes.
RUNTIME_DIR="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}"
RECORDING_FILE="$RUNTIME_DIR/omarchy-screenrecord-filename"
REGION_FILE="$RUNTIME_DIR/omarchy-screenrecord-region"
# The recorder's pid, so stop and status act on that process alone.
REC_PID_FILE="$RUNTIME_DIR/omarchy-screenrecord-pid"
# The PulseAudio module ids of a combined-audio null sink, for cleanup.
PA_MODULES_FILE="$RUNTIME_DIR/omarchy-screenrecord-pa-modules"
# Present while a start holds the lock; another toggle or stop then leaves
# CANCEL_FILE for that start instead of queueing behind its picker or recorder.
STARTING_FILE="$RUNTIME_DIR/omarchy-screenrecord-starting"
CANCEL_FILE="$RUNTIME_DIR/omarchy-screenrecord-cancel"
# How long a recorder may take to create its file, except through the portal,
# whose picker waits on the user.
READY_TIMEOUT_TICKS=75
LOG_FILE=$([[ ${OMARCHY_SCREENRECORD_DEBUG:-false} == "true" ]] && echo "$RUNTIME_DIR/omarchy-screenrecord.log" || echo "/dev/null")
for arg in "$@"; do
case "$arg" in
@@ -48,6 +71,7 @@ for arg in "$@"; do
--resolution=*) RESOLUTION="${arg#*=}" ;;
--fullscreen) FULLSCREEN="true" ;;
--stop-recording) STOP_RECORDING="true" ;;
--status) STATUS="true" ;;
esac
done
@@ -59,6 +83,13 @@ small | medium | large) ;;
;;
esac
# --status only answers whether our recording runs, for the bar and the menu, so
# it needs no recordings directory and never notifies.
if [[ $STATUS != "true" && ! -d $OUTPUT_DIR ]]; then
omarchy-notification-send -u critical -t 3000 "$(omarchy-text "Screen recording directory does not exist: %1" "$OUTPUT_DIR")"
exit 1
fi
start_webcam_overlay() {
cleanup_webcam
@@ -66,7 +97,7 @@ start_webcam_overlay() {
if [[ -z $WEBCAM_DEVICE ]]; then
WEBCAM_DEVICE=$(omarchy-capture-webcam-list | sed -n '1s/[[:space:]].*//p')
if [[ -z $WEBCAM_DEVICE ]]; then
omarchy-notification-send -u critical -t 3000 "No webcam devices found"
omarchy-notification-send -u critical -t 3000 "$(omarchy-text "No webcam devices found")"
return 1
fi
fi
@@ -89,7 +120,7 @@ start_webcam_overlay() {
'--vf=lavfi=[crop=ih*8/9:ih]' \
--title="WebcamOverlay" --wayland-app-id="WebcamOverlay-$WEBCAM_SIZE" \
--no-border --no-audio --no-osc --osd-level=0 \
--really-quiet &>/dev/null &
--really-quiet 9>&- &>/dev/null &
# The move has to settle before gpu-screen-recorder starts, or the camera is
# recorded sliding into its corner. Waiting for the map is what the blind
@@ -143,7 +174,134 @@ select_capture_target() {
echo "region:${BASH_REMATCH[3]}x${BASH_REMATCH[4]}+${BASH_REMATCH[1]}+${BASH_REMATCH[2]}"
}
# gpu-screen-recorder first, then wf-recorder, of those installed. The base
# packages carry gpu-screen-recorder, but an architecture may build none.
recorder_backends() {
local backend found=false
for backend in gpu-screen-recorder wf-recorder; do
if omarchy-cmd-present "$backend"; then
echo "$backend"
found=true
fi
done
# Neither installed: fail the way a missing gpu-screen-recorder always has.
$found || echo gpu-screen-recorder
}
# wf-recorder records a single audio device. Map the desktop/mic flags onto one:
# desktop -> default sink monitor, mic -> default source. When both are requested,
# route them through a throwaway null sink so a single track carries both; the
# module ids are saved to PA_MODULES_FILE so stop can unload them. A requested
# source that can't be recorded is left out with a notification naming it, and
# the recording carries whichever source remains.
wf_audio_device() {
local sink="" source="" m
[[ $DESKTOP_AUDIO == "true" ]] && sink=$(pactl get-default-sink 2>/dev/null)
[[ $MICROPHONE_AUDIO == "true" ]] && source=$(pactl get-default-source 2>/dev/null)
if [[ -n $sink && -n $source ]]; then
if m=$(pactl load-module module-null-sink sink_name=omarchy_screenrec sink_properties=device.description=omarchy_screenrec 2>/dev/null); then
echo "$m" >"$PA_MODULES_FILE"
if m=$(pactl load-module module-loopback source="${sink}.monitor" sink=omarchy_screenrec latency_msec=20 2>/dev/null); then
echo "$m" >>"$PA_MODULES_FILE"
else
sink=""
fi
if m=$(pactl load-module module-loopback source="$source" sink=omarchy_screenrec latency_msec=20 2>/dev/null); then
echo "$m" >>"$PA_MODULES_FILE"
else
source=""
fi
if [[ -n $sink && -n $source ]]; then
echo "omarchy_screenrec.monitor"
return
fi
wf_audio_cleanup
else
# No device to mix into: desktop audio records alone, as it always has.
source=""
fi
fi
[[ $DESKTOP_AUDIO == "true" && -z $sink ]] && wf_audio_unavailable desktop
[[ $MICROPHONE_AUDIO == "true" && -z $source ]] && wf_audio_unavailable microphone
if [[ -n $sink ]]; then
echo "${sink}.monitor"
elif [[ -n $source ]]; then
echo "$source"
fi
}
wf_audio_unavailable() {
local audio
audio=$(omarchy-text "$1")
omarchy-notification-send -u critical -t 5000 "$(omarchy-text "Recording without %1 audio" "$audio")" "$(omarchy-text "The %1 audio could not be added to this recording." "$audio")" >/dev/null 2>&1
}
wf_audio_cleanup() {
[[ -f $PA_MODULES_FILE ]] || return
# Unload in reverse so the loopbacks drop before the null sink they feed.
tac "$PA_MODULES_FILE" 2>/dev/null | while read -r m; do
[[ -n $m ]] && pactl unload-module "$m" 2>/dev/null
done
rm -f "$PA_MODULES_FILE"
}
# Launches wf-recorder for the given picker target, backgrounded, with REC_PID set.
launch_wf_recorder() {
local target=$1 filename=$2
local geom_args=()
case $target in
monitor:*)
geom_args=(-o "${target#monitor:}")
;;
region:*)
# region:WxH+X+Y (logical coords) -> wf-recorder -g "X,Y WxH" (same space).
[[ ${target#region:} =~ ^([0-9]+)x([0-9]+)\+(-?[0-9]+)\+(-?[0-9]+)$ ]] || return 1
geom_args=(-g "${BASH_REMATCH[3]},${BASH_REMATCH[4]} ${BASH_REMATCH[1]}x${BASH_REMATCH[2]}")
;;
*)
return 1
;;
esac
local audio_args=() dev
dev=$(wf_audio_device)
[[ -n $dev ]] && audio_args=(--audio="$dev")
# -r 60 caps the framerate: capturing a 120 Hz panel at its native rate makes
# needlessly large files. -R 48000 keeps the AAC track at 48 kHz rather than
# following a sink that advertises 96 kHz into a file many players, and some
# speaker DSPs, play as silence.
wf-recorder "${geom_args[@]}" -r 60 -R 48000 "${audio_args[@]}" -f "$filename" 9>&- 2>>"$LOG_FILE" &
REC_PID=$!
}
start_cancelled() {
[[ -e $CANCEL_FILE ]]
}
# Stops a recorder this start launched: TERM, two seconds to exit, then KILL.
end_launched_recorder() {
local pid=$1 waited=0 state
kill "$pid" 2>/dev/null
while (( waited++ < 20 )); do
state=$(<"/proc/$pid/stat") 2>/dev/null || break
state=${state##*) }
[[ ${state:0:1} != "Z" ]] || break
sleep 0.1
done
kill -KILL "$pid" 2>/dev/null
wait "$pid" 2>/dev/null
}
start_screenrecording() {
# A pid left by a recording that ended without a stop would otherwise hide
# this one from the toggle until it has started and saved its own.
rm -f "$REC_PID_FILE"
local capture_args=()
local target
@@ -159,6 +317,7 @@ start_screenrecording() {
capture_args=(-w portal -s "${RESOLUTION:-$(default_resolution)}")
else
target=$(select_capture_target) || return 1
start_cancelled && return 1
case $target in
monitor:*)
@@ -172,51 +331,102 @@ start_screenrecording() {
fi
[[ $WEBCAM == "true" ]] && start_webcam_overlay "$target"
start_cancelled && return 1
local filename="$OUTPUT_DIR/screenrecording-$(date +'%Y-%m-%d_%H-%M-%S').mp4"
local audio_devices=""
local audio_args=()
local backend pid=""
[[ $DESKTOP_AUDIO == "true" ]] && audio_devices+="default_output"
for backend in $(recorder_backends); do
[[ $backend == "wf-recorder" && $target == "portal" ]] && continue
echo "===== $(date '+%F %T') args: $* backend: $backend target: $target =====" >>"$LOG_FILE"
if [[ $MICROPHONE_AUDIO == "true" ]]; then
# Merge audio tracks into one - separate tracks only play one at a time in most players
[[ -n $audio_devices ]] && audio_devices+="|"
audio_devices+="default_input"
fi
if [[ $backend == "wf-recorder" ]]; then
launch_wf_recorder "$target" "$filename" || continue
pid=$REC_PID
else
local audio_devices=""
local audio_args=()
[[ -n $audio_devices ]] && audio_args+=(-a "$audio_devices" -ac aac)
[[ $DESKTOP_AUDIO == "true" ]] && audio_devices+="default_output"
echo "===== $(date '+%F %T') args: $* target: $target =====" >>"$LOG_FILE"
gpu-screen-recorder "${capture_args[@]}" -k auto -f 60 -fm cfr -fallback-cpu-encoding yes -o "$filename" "${audio_args[@]}" 2>>"$LOG_FILE" &
local pid=$!
if [[ $MICROPHONE_AUDIO == "true" ]]; then
# Merge audio tracks into one - separate tracks only play one at a time in most players
[[ -n $audio_devices ]] && audio_devices+="|"
audio_devices+="default_input"
fi
while kill -0 $pid 2>/dev/null && [[ ! -f $filename ]]; do
sleep 0.2
[[ -n $audio_devices ]] && audio_args+=(-a "$audio_devices" -ac aac)
gpu-screen-recorder "${capture_args[@]}" -k auto -f 60 -fm cfr -fallback-cpu-encoding yes -o "$filename" "${audio_args[@]}" 9>&- 2>>"$LOG_FILE" &
pid=$!
fi
local ticks=0
while kill -0 $pid 2>/dev/null && [[ ! -f $filename ]]; do
start_cancelled && break
if [[ $target != "portal" ]] && (( ++ticks > READY_TIMEOUT_TICKS )); then
end_launched_recorder "$pid"
wf_audio_cleanup
rm -f "$filename"
continue 2
fi
sleep 0.2
done
if start_cancelled; then
end_launched_recorder "$pid"
wf_audio_cleanup
rm -f "$filename"
return 1
fi
if kill -0 $pid 2>/dev/null; then
echo "$pid" >"$REC_PID_FILE"
echo "$filename" >"$RECORDING_FILE"
toggle_screenrecording_indicator
return 0
fi
wf_audio_cleanup
rm -f "$filename"
done
if kill -0 $pid 2>/dev/null; then
echo "$filename" >"$RECORDING_FILE"
toggle_screenrecording_indicator
fi
return 1
}
# Selects the recorder this script started, by the pid it saved. A saved pid
# that is no longer a recorder is a recording that ended without a stop, so
# nothing of ours is recording and no other recorder is touched. Without a
# saved pid (a recording started before the pid was saved), every recorder the
# user runs counts, as for the bar indicator and the menu.
select_recorder() {
RECORDER=()
[[ -f $REC_PID_FILE ]] || return 0
local pid
pid=$(<"$REC_PID_FILE")
[[ $pid =~ ^[0-9]+$ ]] && omarchy-capture-screenrecording-process --pid "$pid" || return 1
RECORDER=(--pid "$pid")
}
# Stops the recorder screenrecording_active selected.
stop_screenrecording() {
pkill -SIGINT -f "^gpu-screen-recorder" # SIGINT required to save video properly
omarchy-capture-screenrecording-process "${RECORDER[@]}" --signal INT # SIGINT required to save video properly
# Wait a maximum of 5 seconds to finish before hard killing
local count=0
while pgrep -f "^gpu-screen-recorder" >/dev/null && ((count < 50)); do
while omarchy-capture-screenrecording-process "${RECORDER[@]}" && ((count < 50)); do
sleep 0.1
count=$((count + 1))
done
toggle_screenrecording_indicator
cleanup_webcam
wf_audio_cleanup
if pgrep -f "^gpu-screen-recorder" >/dev/null; then
pkill -9 -f "^gpu-screen-recorder"
omarchy-notification-send -u critical -t 5000 "Screen recording error" "Recording process had to be force-killed. Video may be corrupted."
if omarchy-capture-screenrecording-process "${RECORDER[@]}"; then
omarchy-capture-screenrecording-process "${RECORDER[@]}" --signal KILL
omarchy-notification-send -u critical -t 5000 "$(omarchy-text "Screen recording error")" "$(omarchy-text "Recording process had to be force-killed. Video may be corrupted.")"
else
finalize_recording
local filename=$(cat "$RECORDING_FILE" 2>/dev/null)
@@ -226,7 +436,7 @@ stop_screenrecording() {
# Generate a preview thumbnail from the first frame
ffmpeg -y -i "$filename" -ss 00:00:00.1 -vframes 1 -q:v 2 "$preview" -loglevel quiet 2>/dev/null
omarchy-notification-send "Screen recording saved" "Open with Super + Alt + , (or click this)" \
omarchy-notification-send "$(omarchy-text "Screen recording saved")" "$(omarchy-text "Open with Super + Alt + , (or click this)")" \
-t 10000 --image "${preview:-$filename}" \
--exec mpv -- "$filename"
@@ -236,10 +446,10 @@ stop_screenrecording() {
(
sleep 2
rm -f "$preview"
) &
) 9>&- &
fi
rm -f "$RECORDING_FILE"
rm -f "$RECORDING_FILE" "$REC_PID_FILE"
}
toggle_screenrecording_indicator() {
@@ -247,7 +457,14 @@ toggle_screenrecording_indicator() {
}
screenrecording_active() {
pgrep -f "^gpu-screen-recorder" >/dev/null
if select_recorder && omarchy-capture-screenrecording-process "${RECORDER[@]}"; then
return 0
else
if [[ -f $REC_PID_FILE ]]; then
wf_audio_cleanup
fi
return 1
fi
}
finalize_recording() {
@@ -268,7 +485,10 @@ finalize_recording() {
# Hard-mute the first 400ms to drop the PipeWire capture-open pop (a near-clipping
# transient around 130-200ms that a gentle fade-in can't attenuate enough), then a
# 50ms fade avoids a click at the boundary before loudnorm normalizes the rest.
args+=(-af "volume=enable='lt(t,0.4)':volume=0,afade=t=in:st=0.4:d=0.05,loudnorm=I=-14:TP=-1.5:LRA=11")
#
# loudnorm declares 192 kHz output, so without -ar the AAC encoder writes
# 96 kHz from a 48 kHz recording.
args+=(-ar 48000 -af "volume=enable='lt(t,0.4)':volume=0,afade=t=in:st=0.4:d=0.05,loudnorm=I=-14:TP=-1.5:LRA=11")
fi
local processed="${latest%.mp4}-processed.mp4"
@@ -279,10 +499,66 @@ finalize_recording() {
fi
}
if screenrecording_active; then
stop_screenrecording
elif [[ $STOP_RECORDING == "true" ]]; then
exit 1
else
start_screenrecording || cleanup_webcam
mkdir -p "$RUNTIME_DIR" || exit 1
# XDG_STATE_HOME may be outside a private home; protect existing fallbacks too.
if [[ -z ${XDG_RUNTIME_DIR:-} ]]; then
chmod 700 "$RUNTIME_DIR" || exit 1
fi
# Serialize changes to the recorder and its files; background children close 9.
# A busy status query stays read-only so a picker cannot stall the bar.
{
if [[ $STATUS == "true" ]]; then
if flock -n 9; then
screenrecording_active
else
select_recorder && omarchy-capture-screenrecording-process "${RECORDER[@]}"
fi
else
# A start still in its picker or waiting on its recorder is cancelled, not
# queued behind: that toggle would otherwise begin another recording.
# The start reads CANCEL_FILE only after it removes STARTING_FILE, so a
# request left while STARTING_FILE stands is always seen. One that finds it
# gone waits its turn, and a request the start used up by then is done.
if ! flock -w 1 9; then
touch "$CANCEL_FILE"
# A start that was killed leaves its marker: it counts only while that
# start still runs.
starter=""
[[ -r $STARTING_FILE ]] && starter=$(<"$STARTING_FILE")
if [[ $starter =~ ^[0-9]+$ ]] && kill -0 "$starter" 2>/dev/null; then
exit 0
fi
flock 9 || exit 1
[[ -e $CANCEL_FILE ]] || exit 0
rm -f "$CANCEL_FILE"
fi
if screenrecording_active; then
stop_screenrecording
elif [[ $STOP_RECORDING == "true" ]]; then
exit 1
else
rm -f "$CANCEL_FILE"
echo $$ >"$STARTING_FILE"
if start_screenrecording; then
rm -f "$STARTING_FILE"
# Cancelled while the recording was being saved as started.
if start_cancelled; then
end_launched_recorder "$(<"$REC_PID_FILE")"
wf_audio_cleanup
cleanup_webcam
rm -f "$(<"$RECORDING_FILE")" "$REC_PID_FILE" "$RECORDING_FILE"
toggle_screenrecording_indicator
rm -f "$CANCEL_FILE"
fi
# A request after that check is left for its requester, who stops this
# recording once it has the lock.
else
rm -f "$STARTING_FILE"
wf_audio_cleanup
cleanup_webcam
rm -f "$CANCEL_FILE"
fi
fi
fi
} 9>>"$RUNTIME_DIR/omarchy-screenrecord.lock"
+72
View File
@@ -0,0 +1,72 @@
#!/bin/bash
# omarchy:summary=Check or signal the current user's recording processes
# omarchy:args=[--pid <pid>] [--signal INT|KILL]
# omarchy:hidden=true
usage() {
echo "Usage: omarchy-capture-screenrecording-process [--pid <pid>]... [--signal INT|KILL]" >&2
exit 2
}
proc_root=${OMARCHY_PROC_ROOT:-/proc}
wanted=" "
signal=""
while (($#)); do
case $1 in
--pid)
# Compare textually first: bash arithmetic wraps values past 64 bits.
[[ ${2-} =~ ^0*([0-9]+)$ ]] || usage
pid=${BASH_REMATCH[1]}
((${#pid} <= 10 && 10#$pid <= 4194304)) || usage
wanted+="$((10#$pid)) "
shift 2
;;
--signal)
[[ ${2-} == "INT" || ${2-} == "KILL" ]] || usage
signal=$2
shift 2
;;
*)
usage
;;
esac
done
is_recorder() {
local exe
exe=$(readlink "$proc_root/$1/exe" 2>/dev/null) || return 1
# Linux appends this when an upgrade unlinks a still-running executable.
exe=${exe% (deleted)}
exe=${exe##*/}
[[ $exe == "gpu-screen-recorder" || $exe == "wf-recorder" ]]
}
# Linux truncates a process name to 15 characters: gpu-screen-recorder runs as
# gpu-screen-reco, which any executable sharing that prefix also matches, and a
# process can rename itself, so the executable path confirms each candidate.
matched=()
while read -r pid; do
if [[ $wanted == " " || $wanted == *" $pid "* ]] && is_recorder "$pid"; then
matched+=("$pid")
fi
done < <(pgrep -u "$UID" -x 'gpu-screen-reco(rder)?|wf-recorder')
if ((${#matched[@]} == 0)); then
exit 1
fi
if [[ -n $signal ]]; then
delivered=0
for pid in "${matched[@]}"; do
# The pid can be reused between discovery and kill; re-checking exe here narrows that window.
if is_recorder "$pid" && kill "-$signal" "$pid" 2>/dev/null; then
delivered=1
fi
done
# A recorder that exits before its signal has stopped anyway; fail only if none was signalled.
((delivered)) || exit 1
fi
exit 0
@@ -5,7 +5,7 @@
mapfile -t devices < <(omarchy-capture-webcam-list)
if (( ${#devices[@]} == 0 )); then
omarchy-notification-send "No webcam devices found" -u critical -t 3000
omarchy-notification-send "$(omarchy-text "No webcam devices found")" -u critical -t 3000
exit 1
fi
+11 -70
View File
@@ -2,81 +2,22 @@
# omarchy:summary=Take a screenshot
# omarchy:group=capture
# omarchy:args=[smart|region|windows|fullscreen] [slurp|copy|save] [--editor=<name>]
# omarchy:args=[smart|region|windows|fullscreen|scroll] [copy|save]
# omarchy:examples=omarchy screenshot | omarchy capture screenshot region
# omarchy:aliases=omarchy screenshot
[[ -f ~/.config/user-dirs.dirs ]] && source ~/.config/user-dirs.dirs
OUTPUT_DIR="${OMARCHY_SCREENSHOT_DIR:-${XDG_PICTURES_DIR:-$HOME/Pictures}}"
if [[ ! -d $OUTPUT_DIR ]]; then
mkdir -p "$OUTPUT_DIR"
omarchy-notification-send "Created screenshot directory: $OUTPUT_DIR" -t 2000
if [[ -n ${OMARCHY_SCREENSHOT_DIR:-} && -z ${OMASNAP_SCREENSHOT_DIR:-} ]]; then
export OMASNAP_SCREENSHOT_DIR="$OMARCHY_SCREENSHOT_DIR"
fi
pkill slurp && exit 0
SCREENSHOT_EDITOR="${OMARCHY_SCREENSHOT_EDITOR:-tensaku-edit}"
# Parse --editor flag from any position
ARGS=()
omasnap_args=()
for arg in "$@"; do
if [[ $arg == --editor=* ]]; then
SCREENSHOT_EDITOR="${arg#--editor=}"
else
ARGS+=("$arg")
fi
case "$arg" in
copy) omasnap_args+=(--copy) ;;
save) omasnap_args+=(--save) ;;
slurp) ;;
*) omasnap_args+=("$arg") ;;
esac
done
set -- "${ARGS[@]}"
MODE="${1:-smart}"
PROCESSING="${2:-slurp}"
# The picker leaves the screen freeze running (PID on its first output line)
# so grim captures the frozen overlay rather than live content shifting
# during teardown.
#
# Software-composited cursors (Hyprland's fallback on GPUs without working
# hardware cursors) are baked into the frames grim captures, so force
# hardware cursors until after grim runs and restore the setting on exit.
NO_HW_CURSORS=$(hyprctl getoption cursor:no_hardware_cursors -j | jq '.int')
set_no_hw_cursors() {
hyprctl eval "hl.config({ cursor = { no_hardware_cursors = $1 } })" &>/dev/null ||
hyprctl keyword cursor:no_hardware_cursors "$1" &>/dev/null
}
cleanup() {
[[ -n $FREEZE_PID ]] && kill $FREEZE_PID 2>/dev/null
set_no_hw_cursors "$NO_HW_CURSORS"
}
trap cleanup EXIT
set_no_hw_cursors 0
{ read -r FREEZE_PID; read -r SELECTION; } < <(omarchy-capture-region "$MODE" --keep-freeze)
[[ -z $SELECTION ]] && exit 0
FILENAME="screenshot-$(date +'%Y-%m-%d_%H-%M-%S').png"
FILEPATH="$OUTPUT_DIR/$FILENAME"
case "$PROCESSING" in
slurp)
grim -g "$SELECTION" "$FILEPATH" || exit 1
echo "$FILEPATH"
wl-copy --type image/png <"$FILEPATH"
# Best-effort: the screenshot is already saved and on the clipboard, so a
# notification outage must not report the capture itself as failed.
omarchy-notification-send "Screenshot saved to clipboard and file" "Edit with Super + Alt + , (or click this)" \
--image "$FILEPATH" \
--exec "$SCREENSHOT_EDITOR" "$FILEPATH" || true
;;
copy)
grim -g "$SELECTION" - | wl-copy --type image/png
;;
save)
grim -g "$SELECTION" "$FILEPATH" || exit 1
echo "$FILEPATH"
;;
esac
exec omasnap "${omasnap_args[@]}"
+1 -1
View File
@@ -23,4 +23,4 @@ TEXT=$(grim -g "$SELECTION" - | tesseract stdin stdout --oem 1 --psm 6 -l "${OMA
[[ -z $TEXT ]] && exit 1
printf "%s" "$TEXT" | wl-copy
omarchy-notification-send -g 󰴑 "Copied text from selection to clipboard"
omarchy-notification-send -g 󰴑 "$(omarchy-text "Copied text from selection to clipboard")"
+4 -1
View File
@@ -8,7 +8,10 @@
set -euo pipefail
readonly MARGIN=40
readonly REGION_FILE="${XDG_RUNTIME_DIR:-/tmp}/omarchy-screenrecord-region"
# Has to resolve to whatever omarchy-capture-screenrecording writes, fallback
# included, or a recording without a session runtime dir anchors the camera to
# the whole monitor instead of the region it recorded.
readonly REGION_FILE="${XDG_RUNTIME_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/omarchy}/omarchy-screenrecord-region"
usage() {
echo "Usage: omarchy-capture-webcam-resize <smaller|larger|reset|small|medium|large>" >&2
+89 -10
View File
@@ -1,10 +1,25 @@
#!/bin/bash
#!/bin/bash -p
# omarchy:summary=Set the Omarchy package channel.
# omarchy:args=<stable|rc|edge|dev>
# omarchy:requires-sudo=true
if [[ $- != *p* ]]; then
echo "Refusing an unsafe Bash startup for channel switching." >&2
exit 126
fi
security_entrypoint=$(/usr/bin/readlink -e -- "${BASH_SOURCE[0]}") || exit 126
source "${security_entrypoint%/*}/omarchy-security-functions" || exit 126
omarchy_security_require_privileged_bash_startup || exit 126
set -euo pipefail
omarchy_security_sanitize_bash_environment "$0" "$@"
omarchy_security_require_source_root "$0"
user_path=$PATH
# Traps first, so a signal or failure during the entry revocation still
# exits through the cleanup path.
omarchy_security_install_sudo_cleanup_traps
omarchy_security_revoke_sudo_timestamp || exit 1
omarchy_security_enable_no_update_sudo
usage() { echo "Usage: omarchy-channel-set [stable|rc|edge|dev]"; }
fail() { echo "Error: $*" >&2; exit 1; }
@@ -33,12 +48,57 @@ validate_dev_checkout() {
}
link_dev_checkout() {
local checkout="$1"
[[ -d $checkout/.git ]] || git clone https://github.com/basecamp/omarchy.git "$checkout"
local checkout="$1" required
[[ -d $checkout/.git ]] || git clone https://github.com/omacom/omarchy.git "$checkout"
# Check the destination before changing /etc/omarchy.conf or sudo's path.
# An existing checkout is not pulled automatically and may predate this policy.
for required in bin/omarchy-security-functions bin/omarchy-update bin/omarchy-refresh-pacman default/omarchy/sudo-no-update/sudo; do
if [[ ! -f $checkout/$required || ! -r $checkout/$required ||
( $required != "bin/omarchy-security-functions" && ! -x $checkout/$required ) ]]; then
fail "Update the checkout before switching to dev; missing required update support in $required."
fi
done
# The checkout's own channel refresh runs next, from its own templates.
if [[ ! -f $checkout/${templates#"$OMARCHY_PATH"/}/pacman-$pacman_channel.conf ||
! -f $checkout/${templates#"$OMARCHY_PATH"/}/mirrorlist-$pacman_channel ]]; then
fail "Update the checkout before switching to dev; it has no $pacman_channel templates for $platform."
fi
# On aarch64 one from before it had platform templates would replace this
# machine's repositories with x86's.
if [[ $platform != "x86" ]]; then
for required in bin/omarchy-hw-platform install/helpers/pacman.sh; do
if [[ ! -f $checkout/$required || ! -r $checkout/$required ||
( $required == "bin/omarchy-hw-platform" && ! -x $checkout/$required ) ]]; then
fail "Update the checkout before switching to dev; on $platform it needs $required for this machine's repositories."
fi
done
fi
omarchy-dev-link "$checkout" --no-reboot
}
# A packaged destination cannot be inspected before its package is installed,
# and a package transaction can replace the running tree with a release that
# predates the command-scoped wrapper. After that, a bare sudo would resolve to
# /usr/bin/sudo and publish a timestamp, and the destination's own updater
# authenticates the same way. Neither may run from a flow that has just run
# user hooks: stop at a consistent point and say how to finish from a fresh
# session.
stop_for_older_destination() {
cat >&2 <<EOF
The destination predates command-scoped sudo, so this switch stops before its update.
Packages are switched. Run 'omarchy update' from a new terminal to finish, then reboot if prompted.
EOF
exit 3
}
wrapper_present() {
[[ -f $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo && -x $OMARCHY_PATH/default/omarchy/sudo-no-update/sudo ]]
}
(( $# > 0 )) || { usage; exit 1; }
dev_checkout=""
@@ -59,9 +119,6 @@ case "$channel" in
packages=(omarchy-dev omarchy-settings-dev)
;;
dev)
confirm_dev || { echo "Cancelled."; exit 0; }
dev_checkout="$HOME/omarchy"
validate_dev_checkout "$dev_checkout"
pacman_channel=edge
packages=(omarchy-dev omarchy-settings-dev)
;;
@@ -72,6 +129,24 @@ case "$channel" in
;;
esac
# A channel this machine's platform has no templates for is refused before
# anything changes (install/helpers/pacman.sh).
source "$OMARCHY_PATH/install/helpers/pacman.sh"
platform=$(omarchy-hw-platform) || fail "Cannot tell this machine's platform; nothing changed."
templates=$(omarchy_pacman_templates "$platform") || exit 1
[[ -f $templates/pacman-$pacman_channel.conf && -f $templates/mirrorlist-$pacman_channel ]] ||
fail "Omarchy has no $pacman_channel channel for $platform; nothing changed."
if [[ $channel == "dev" ]]; then
confirm_dev || { echo "Cancelled."; exit 0; }
dev_checkout="$HOME/omarchy"
validate_dev_checkout "$dev_checkout"
fi
# A failure past this point leaves the channel switch half-applied, so say how
# to pick it back up rather than dying silently under set -e.
trap 'echo -e "\nThe channel switch did not complete. Review the error above, then rerun: omarchy-channel-set '"$channel"'" >&2' ERR
if [[ -z $dev_checkout && $OMARCHY_PATH != "/usr/share/omarchy" ]]; then
leaving_dev=1
fi
@@ -79,13 +154,16 @@ fi
if [[ -n $dev_checkout ]]; then
link_dev_checkout "$dev_checkout"
export OMARCHY_PATH="$dev_checkout"
export PATH="$OMARCHY_PATH/bin:$PATH"
omarchy_security_enable_no_update_sudo
omarchy-state set reboot-required
fi
omarchy-refresh-pacman "$pacman_channel"
OMARCHY_UPDATE_USER_PATH="$user_path" omarchy-refresh-pacman "$pacman_channel"
# Each transaction can have replaced this tree; check before the next sudo.
wrapper_present || stop_for_older_destination
# --ask 4 accepts omarchy <-> omarchy-dev replacement prompts without file overwrites.
sudo env OMARCHY_UPDATE_PACMAN=1 pacman -S --needed --noconfirm --ask 4 "${packages[@]}"
omarchy-update-pacman -S --needed --noconfirm --ask 4 "${packages[@]}"
wrapper_present || stop_for_older_destination
if [[ -z $dev_checkout ]]; then
omarchy-dev-unlink --no-reboot
@@ -94,6 +172,7 @@ if [[ -z $dev_checkout ]]; then
if (( leaving_dev )); then
omarchy-state set reboot-required
fi
omarchy_security_enable_no_update_sudo 2>/dev/null || stop_for_older_destination
fi
omarchy-update -y
OMARCHY_UPDATE_USER_PATH="$user_path" "$OMARCHY_PATH/bin/omarchy-update" -y
+1 -1
View File
@@ -18,7 +18,7 @@ copy_url() {
[[ -n $url ]] || return 1
printf '%s' "$url" | wl-copy --type text/plain
omarchy-notification-send -g 󰅍 "URL copied to clipboard"
omarchy-notification-send -g 󰅍 "$(omarchy-text "URL copied to clipboard")"
}
reply_copied() {
+3 -3
View File
@@ -87,7 +87,7 @@ download_url() {
# Don't show anything until yt-dlp confirms there's actually a video to grab.
if ! yt-dlp --no-playlist --simulate --quiet --no-warnings --no-exec --no-exec-before-download -- "$url" >/dev/null 2>&1; then
omarchy-notification-send -u critical -g 󰅖 "No video found for download" "$url"
omarchy-notification-send -u critical -g 󰅖 "$(omarchy-text "No video found for download")" "$url"
exit 0
fi
@@ -148,7 +148,7 @@ download_url() {
# toast would exit before the thumbnail cleanup below is ever scheduled.
# `--` keeps mpv from parsing a leading-dash filename as an option; the path
# is one discrete argument, so it never reaches a shell.
omarchy-notification-send -g 󰄬 "Download complete" "$title" \
omarchy-notification-send -g 󰄬 "$(omarchy-text "Download complete")" "$title" \
-t 10000 --image "${preview:-$filepath}" \
--exec mpv -- "$filepath" || true
@@ -160,7 +160,7 @@ download_url() {
rm -f "$preview"
) &
else
omarchy-notification-send -u critical -g 󰅖 "Download failed" "$url"
omarchy-notification-send -u critical -g 󰅖 "$(omarchy-text "Download failed")" "$url"
fi
exit 0
+1 -1
View File
@@ -30,7 +30,7 @@ open_image() {
local path="$1"
[[ -r $path ]] || exit 1
exec tensaku-edit "$path"
exec omasnap "$path"
}
open_text() {
+48
View File
@@ -0,0 +1,48 @@
#!/bin/bash
# omarchy:summary=Hand a command line to the running Chromium-based browser
# omarchy:args=<browser-command> [browser-args...]
# omarchy:hidden=true
# A second browser process spends ~165ms starting up only to pass its command
# line to the running one over the profile's singleton socket, then exits. Send
# it there directly, and fail when no browser acknowledges it so the caller can
# launch one instead.
config="${XDG_CONFIG_HOME:-$HOME/.config}"
# Only the browsers themselves: a wrapper in their place may pick a profile or
# add flags of its own, which only running it can honour.
case $(type -P "$1") in
/usr/bin/chromium) profile=chromium flags=chromium-flags.conf ;;
/usr/bin/google-chrome-stable) profile=google-chrome flags=chrome-flags.conf ;;
/usr/bin/brave) profile=BraveSoftware/Brave-Browser flags=brave-flags.conf ;;
/usr/bin/brave-origin) profile=BraveSoftware/Brave-Origin flags=brave-origin-flags.conf ;;
/usr/bin/microsoft-edge-stable) profile=microsoft-edge flags=microsoft-edge-stable-flags.conf ;;
*) exit 1 ;;
esac
shift
# The running browser already has the process flags Omarchy ships in the flags
# file. Any other flag there may be meant for each launch, and a data directory
# from the environment is another browser, so leave those to the launcher.
if [[ -n ${CHROME_USER_DATA_DIR:-}${CHROME_CONFIG_HOME:-} ]] ||
cat "/etc/$flags" "$config/$flags" 2>/dev/null | sed 's/#.*//' | tr -s '[:space:]' '\n' |
grep -vE '^(--(ozone-platform|ozone-platform-hint|password-store|enable-features|load-extension)=.*)?$' | grep -q .; then
exit 1
fi
for arg in "$@"; do
[[ $arg == --user-data-dir* ]] && exit 1
done
# The socket lives in a /tmp dir, which another user could recreate once a
# crashed browser's is cleaned away, so only a socket of our own gets the URL.
socket="$config/$profile/SingletonSocket"
[[ -S $socket && -O $socket ]] || exit 1
reply=$({
printf 'START\0%s\0browser' "$PWD"
printf '\0%s' "$@"
} | socat -t2 - "UNIX-CONNECT:$socket" 2>/dev/null)
[[ $reply == "ACK" ]]
+58
View File
@@ -0,0 +1,58 @@
#!/bin/bash
# omarchy:summary=Print the desktop entry ID of the default web browser
# omarchy:hidden=true
# Read the http handler from the config-dir mimeapps.list files the way xdg-mime
# does, desktop-specific list first, taking the first listed entry that is
# installed. xdg-settings reaches the same answer through several shell scripts
# in ~70ms, which every browser and web app launch would otherwise pay.
IFS=: read -ra data_dirs <<<"${XDG_DATA_HOME:-$HOME/.local/share}:${XDG_DATA_DIRS:-/usr/local/share:/usr/share}"
IFS=: read -ra config_dirs <<<"${XDG_CONFIG_HOME:-$HOME/.config}:${XDG_CONFIG_DIRS:-/etc/xdg}"
IFS=: read -ra desktops <<<"${XDG_CURRENT_DESKTOP:-}"
# Installed means the command of the entry the launchers will read, the first
# one found, still resolves: an entry of Omarchy's own can outlive the browser
# it launched.
installed() {
local data_dir command
for data_dir in "${data_dirs[@]}"; do
[[ -r $data_dir/applications/$1 ]] || continue
command=$(sed -n 's/^Exec=\([^ ]*\).*/\1/p' "$data_dir/applications/$1" | head -1)
[[ -n $command ]] && command -v "$command" >/dev/null
return
done
return 1
}
for config_dir in "${config_dirs[@]}"; do
for desktop in "${desktops[@]}" ""; do
list="$config_dir/${desktop:+${desktop,,}-}mimeapps.list"
[[ -f $list ]] || continue
entries=$(awk '
/^\[Default Applications\]/ { section = 1; next }
/^\[/ { section = 0 }
section && index($0, "x-scheme-handler/http=") == 1 { print substr($0, 23); exit }
' "$list")
IFS=';' read -ra entries <<<"$entries"
for entry in "${entries[@]}"; do
if [[ -n $entry ]] && installed "$entry"; then
echo "$entry"
exit 0
fi
done
done
done
# Nothing set in a config dir: let xdg-settings search the data dirs and caches.
# It consults $BROWSER too, which the session points back at omarchy-launch-browser.
browser=$(env -u BROWSER xdg-settings get default-web-browser 2>/dev/null)
if [[ -z $browser ]]; then
browser=$(xdg-mime query default x-scheme-handler/https 2>/dev/null)
fi
[[ -n $browser ]] && echo "$browser"
+1 -1
View File
@@ -3,7 +3,7 @@
# omarchy:summary=Check whether any required commands are missing
for cmd in "$@"; do
if ! command -v "$cmd" &>/dev/null; then
if ! command -v -- "$cmd" &>/dev/null; then
exit 0
fi
done
+1 -1
View File
@@ -3,7 +3,7 @@
# omarchy:summary=Check whether all required commands are available
for cmd in "$@"; do
command -v "$cmd" &>/dev/null || exit 1
command -v -- "$cmd" &>/dev/null || exit 1
done
exit 0
+12 -2
View File
@@ -1,9 +1,10 @@
#!/bin/bash
# omarchy:summary=Print the current working directory of the active terminal window
# omarchy:args=[terminal-pid]
# omarchy:hidden=true
terminal_pid=$(hyprctl activewindow | awk '/pid:/ {print $2}')
terminal_pid=${1:-$(hyprctl activewindow | awk '/pid:/ {print $2}')}
kitty_socket="$XDG_RUNTIME_DIR/omarchy-kitty-$terminal_pid"
cwd=""
@@ -11,7 +12,16 @@ if [[ -S $kitty_socket ]]; then
cwd=$(kitten @ --to "unix:$kitty_socket" ls --match "state:focused" 2>/dev/null |
jq -r '.[].tabs[].windows[].cwd // empty')
else
shell_pid=$(pgrep -P "$terminal_pid" | tail -n1)
# The newest child, as pgrep -P | tail -n1 found it, read from the kernel's
# child lists: pgrep scans all of /proc on every new terminal.
shell_pid=""
for children_file in /proc/"$terminal_pid"/task/*/children; do
[[ -r $children_file ]] || continue
read -ra children <"$children_file" || true
for child in "${children[@]}"; do
(( child > ${shell_pid:-0} )) && shell_pid=$child
done
done
if [[ -n $shell_pid ]]; then
cwd=$(readlink -f "/proc/$shell_pid/cwd" 2>/dev/null)
+2 -2
View File
@@ -40,8 +40,8 @@ announce() {
omarchy-notification-send \
--urgency critical \
--glyph "$CRASH_GLYPH" \
"Process crashed: $comm" \
"Click to diagnose with AI" \
"$(omarchy-text "Process crashed: %1" "$comm")" \
"$(omarchy-text "Click to diagnose with AI")" \
--exec omarchy-agent-crash "$pid" "$comm" "$exe" "$signal"
}
+44 -6
View File
@@ -1,7 +1,7 @@
#!/bin/bash
# omarchy:summary=Set and launch the default coding agent
# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|agy|copilot|crush]
# omarchy:args=[pi|omp|opencode|ori|claude|codex|grok|openclaw|agy|hermes|copilot|crush|cursor-agent|muse]
# omarchy:examples=omarchy default agent | omarchy default agent codex | omarchy default agent claude
installing=false
@@ -31,23 +31,57 @@ ori | openrouter) agent="ori"; name="Ori"; agent_package="github:OpenRouterLabs/
claude | claude-code) agent="claude"; name="Claude Code" ;;
codex) agent="codex"; name="Codex" ;;
crush) agent="crush"; name="Crush" ;;
grok) agent="grok"; name="Grok"; agent_package="npm:@xai-official/grok" ;;
grok) agent="grok"; name="Grok" ;;
openclaw) agent="openclaw"; name="OpenClaw"; agent_installer="omarchy-install-openclaw-cli" ;;
agy | antigravity | antigravity-cli | gemini | gemini-cli) agent="agy"; name="Antigravity"; agent_package="antigravity-cli" ;;
hermes) agent="hermes"; name="Hermes"; agent_installer="omarchy-install-hermes-cli" ;;
copilot | github-copilot) agent="copilot"; name="GitHub Copilot" ;;
muse | muse-code | musecode)
agent="muse"; name="Muse Code"
# Meta's launcher verifies and updates the native binary for this platform.
agent_package="http:muse[url=https://api.meta.ai/muse-launcher.sh,bin=muse,version_list_url=https://api.meta.ai/muse-code/channels/muse-stable,version_json_path=.version]"
;;
cursor | cursor-agent) agent="cursor-agent"; name="Cursor CLI" ;;
*)
echo "Usage: omarchy-default-agent <pi|omp|opencode|ori|claude|codex|grok|agy|copilot|crush>"
echo "Usage: omarchy-default-agent <pi|omp|opencode|ori|claude|codex|grok|openclaw|agy|hermes|copilot|crush|cursor-agent|muse>"
exit 1
;;
esac
agent_package=${agent_package:-$agent}
if [[ $installing == "false" ]] && ! mise where "$agent_package" &>/dev/null; then
# Hermes and OpenClaw are not mise tools: each is a self-updating runtime in
# the user's home, Hermes the desktop app's and OpenClaw the one its Control
# UI updates. Each has its own installer with the same --check/--now contract
# mise-backed agents get from mise; see omarchy-install-hermes-cli and
# omarchy-install-openclaw-cli.
if [[ -n ${agent_installer:-} ]]; then
# Not omarchy-cmd-present: a command on PATH says nothing about whether the
# install behind it finished or runs the sessions omarchy-agent starts.
# Treating it as installed would skip the floating terminal and run a
# minutes-long install inside the menu action instead.
agent_present() { "$agent_installer" --check; }
agent_install() { "$agent_installer" --now; }
else
# Anything at the wrapper's path other than the wrapper is the user's own
# install, such as the symlink Cursor's installer leaves. A mise copy would
# only shadow it, since the mise shims precede ~/.local/bin on PATH.
user_install() {
[[ -x $HOME/.local/bin/$agent ]] &&
{ [[ -L $HOME/.local/bin/$agent ]] || ! grep -q '^mise use -g' "$HOME/.local/bin/$agent"; }
}
agent_present() { user_install || mise where "$agent_package" &>/dev/null; }
agent_install() { user_install || mise use -g "$agent_package"; }
fi
if [[ $installing == "false" ]] && ! agent_present; then
exec omarchy-launch-floating-terminal-with-presentation omarchy-default-agent --install "$agent"
fi
if ! mise use -g "$agent_package"; then
if [[ $installing == "true" ]]; then
if ! agent_install; then
if [[ $installing == "true" && -n ${agent_installer:-} ]]; then
echo "Could not install $name" >&2
elif [[ $installing == "true" ]]; then
echo "Could not install $name with mise" >&2
else
echo "Could not set $name as the default coding agent" >&2
@@ -55,6 +89,10 @@ if ! mise use -g "$agent_package"; then
exit 1
fi
if [[ $agent == "claude" ]]; then
omarchy-install-chromium-claude 2>/dev/null || true
fi
mkdir -p "$(dirname "$agent_file")"
printf '%s\n' "$agent" >"$agent_file"
+1 -1
View File
@@ -48,4 +48,4 @@ fi
env -u BROWSER xdg-settings set default-web-browser "$desktop_id" || exit 1
omarchy-notification-send -g $glyph "$name is now the default browser"
omarchy-notification-send -g $glyph "$(omarchy-text "%1 is now the default browser" "$name")"
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# omarchy:summary=Get or set the default dictation backend
# omarchy:args=[backend]
# omarchy:examples=omarchy default dictation | omarchy default dictation superwhisper
if (($# > 1)); then
echo "Usage: omarchy default dictation [backend]" >&2
exit 1
fi
if (($# == 0)); then
config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation"
if [[ ! -s $config ]]; then
echo "No dictation backend configured. Select one in Setup > Defaults > Dictation." >&2
exit 1
fi
read -r backend < "$config"
else
backend=$1
fi
if [[ ! $backend =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
echo "Invalid dictation backend: $backend" >&2
exit 1
fi
if (($# == 0)); then
echo "$backend"
else
installer="omarchy-install-dictation-$backend"
if omarchy-cmd-missing "$installer"; then
echo "No installer available for dictation backend: $backend" >&2
exit 1
fi
omarchy-launch-floating-terminal-with-presentation "$installer"
fi
+6 -3
View File
@@ -17,6 +17,9 @@ if (($# == 0)); then
read -r editor <"$editor_file"
fi
# sublime-text-4 ships `subl`; older defaults stored `sublime_text`.
[[ $editor == sublime_text ]] && editor=subl
[[ -n $editor ]] && echo "$editor" || echo "nvim"
exit 0
fi
@@ -25,10 +28,10 @@ case "$1" in
code) selection="code"; editor="code"; name="VSCode"; glyph= ;;
cursor) selection="cursor"; editor="cursor"; name="Cursor"; glyph= ;;
zed | zeditor) selection="zed"; editor="zeditor"; name="Zed"; glyph= ;;
sublime_text) selection="sublime_text"; editor="sublime_text"; name="Sublime Text"; glyph= ;;
sublime_text | subl) selection="sublime_text"; editor="subl"; name="Sublime Text"; glyph= ;;
helix) selection="helix"; editor="helix"; name="Helix"; glyph= ;;
vim) selection="vim"; editor="vim"; name="Vim"; glyph= ;;
emacs) selection="emacs"; editor="emacs"; name="Emacs"; glyph= ;;
emacs) selection="emacs"; editor="emacs"; name="Emacs"; glyph= ;;
nvim) selection="nvim"; editor="nvim"; name="Neovim"; glyph= ;;
*)
echo "Usage: omarchy-default-editor <code|cursor|zed|sublime_text|helix|vim|emacs|nvim>"
@@ -56,4 +59,4 @@ fi
mkdir -p "$(dirname "$editor_file")"
printf '%s\n' "$editor" >"$editor_file"
omarchy-notification-send -g $glyph "$name is now the default editor"
omarchy-notification-send -g $glyph "$(omarchy-text "%1 is now the default editor" "$name")"
+1 -1
View File
@@ -48,4 +48,4 @@ cat >~/.config/xdg-terminals.list <<EOF
$desktop_id
EOF
omarchy-notification-send -g $glyph "$name is now the default terminal"
omarchy-notification-send -g $glyph "$(omarchy-text "%1 is now the default terminal" "$name")"
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# omarchy:summary=Record dictation with the configured backend
# omarchy:args=<start|stop|toggle>
# omarchy:examples=omarchy dictation start | omarchy dictation toggle
if (($# != 1)) || [[ $1 != "start" && $1 != "stop" && $1 != "toggle" ]]; then
echo "Usage: omarchy dictation <start|stop|toggle>" >&2
exit 1
fi
config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation"
mkdir -p "${config%/*}"
exec 9> "$config.lock"
if ! flock -w 5 9; then
echo "Dictation is busy; try again." >&2
exit 1
fi
backend=$(omarchy-default-dictation) || exit 1
adapter="omarchy-dictation-$backend"
if omarchy-cmd-missing "$adapter"; then
echo "The selected dictation backend ($backend) is not installed." >&2
exit 1
fi
"$adapter" "$1" 9>&-
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
# omarchy:summary=Record dictation using Superwhisper
# omarchy:args=<start|stop|toggle>
case "${1:-}" in
start) superwhisper start ;;
stop)
if omarchy-cmd-missing superwhisper || ! systemctl --user is-active --quiet superwhisper.service; then
exit 0
else
superwhisper stop
fi
;;
toggle) superwhisper record ;;
*) echo "Usage: omarchy dictation superwhisper <start|stop|toggle>" >&2; exit 1 ;;
esac
+33
View File
@@ -0,0 +1,33 @@
#!/bin/bash
# omarchy:hidden=true
# omarchy:summary=Save a configured dictation backend after stopping the previous backend
# omarchy:args=<backend>
set -e
if (($# != 1)) || [[ ! $1 =~ ^[a-z0-9][a-z0-9-]*$ ]]; then
echo "Usage: omarchy-dictation-use <backend>" >&2
exit 1
fi
config="${XDG_CONFIG_HOME:-$HOME/.config}/omarchy/defaults/dictation"
mkdir -p "${config%/*}"
exec 9> "$config.lock"
if ! flock -w 5 9; then
echo "Dictation is busy; try again." >&2
exit 1
fi
previous=$(omarchy-default-dictation 2>/dev/null) || previous=""
if [[ -n $previous && $previous != "$1" ]] && omarchy-cmd-present "omarchy-dictation-$previous"; then
if ! "omarchy-dictation-$previous" stop 9>&-; then
echo "Could not stop $previous; keeping the existing dictation backend." >&2
exit 1
fi
fi
temporary=$(mktemp "$config.XXXXXX")
trap 'rm -f "$temporary"' EXIT
printf '%s\n' "$1" > "$temporary"
mv -f "$temporary" "$config"
+16
View File
@@ -0,0 +1,16 @@
#!/bin/bash
# omarchy:summary=Record dictation using Voxtype
# omarchy:args=<start|stop|toggle>
case "${1:-}" in
start|toggle) voxtype record "$1" ;;
stop)
if omarchy-cmd-missing voxtype || [[ $(voxtype status) == "stopped" ]]; then
exit 0
else
voxtype record stop
fi
;;
*) echo "Usage: omarchy dictation voxtype <start|stop|toggle>" >&2; exit 1 ;;
esac
+15 -5
View File
@@ -129,7 +129,8 @@ term_pt_for() {
'BEGIN { printf "%d", int(s * p / b + 0.5) }'
}
# Set the font point size in every terminal config that exists. Family is left
# Set the font point size in terminal configs, creating Kitty overrides when
# it inherits its size from the system config. Family is left
# untouched — that is omarchy-font-set's job. Live-reload signals mirror
# omarchy-font-set; foot has no reload signal, so running instances are nudged.
set_terminal_size() {
@@ -139,8 +140,13 @@ set_terminal_size() {
sed -i -E "s/^size[[:space:]]*=.*/size = $pt/" ~/.config/alacritty/alacritty.toml
fi
if [[ -f ~/.config/kitty/kitty.conf ]]; then
sed -i -E "s/^font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf
if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then
mkdir -p ~/.config/kitty
if grep -qE '^[[:space:]]*font_size[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then
sed --follow-symlinks -i -E "s/^[[:space:]]*font_size[[:space:]]+.*/font_size $pt.0/" ~/.config/kitty/kitty.conf
else
printf '\nfont_size %s.0\n' "$pt" >>~/.config/kitty/kitty.conf
fi
pkill -USR1 kitty 2>/dev/null || true
fi
@@ -163,7 +169,7 @@ set_terminal_size() {
[[ $prev_id =~ ^[0-9]+$ ]] && replace=(-r "$prev_id")
local new_id
new_id="$(omarchy-notification-send \
"Restart Foot to apply the new terminal font size" \
"$(omarchy-text "Restart Foot to apply the new terminal font size")" \
"${replace[@]}" -p 2>/dev/null)" || true
[[ $new_id =~ ^[0-9]+$ ]] && printf '%s\n' "$new_id" >"$id_file"
fi
@@ -177,9 +183,13 @@ term_current_pt() {
elif [[ -f ~/.config/alacritty/alacritty.toml ]]; then
grep -oP '^size[[:space:]]*=[[:space:]]*\K[0-9.]+' ~/.config/alacritty/alacritty.toml | head -1
elif [[ -f ~/.config/kitty/kitty.conf ]]; then
grep -oP '^font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | head -1
local pt
pt=$(grep -oP '^[[:space:]]*font_size[[:space:]]+\K[0-9.]+' ~/.config/kitty/kitty.conf | tail -1)
echo "${pt:-$TERM_DEFAULT_PT}"
elif [[ -f ~/.config/foot/foot.ini ]]; then
grep -oP ':size=\K[0-9.]+' ~/.config/foot/foot.ini | head -1
elif omarchy-cmd-present kitty; then
echo "$TERM_DEFAULT_PT"
fi
}
+419 -19
View File
@@ -1,32 +1,432 @@
#!/bin/bash
# omarchy:summary=Set a new encryption password for a drive selected.
# omarchy:summary=Set a new password for an encrypted drive
# omarchy:requires-sudo=true
encrypted_drives=$(blkid -t TYPE=crypto_LUKS -o device)
# Changes a drive's LUKS key and nothing else. For the disk holding /, the new
# key must open the disk and the old one must not before the change counts. The
# journal records only the disk's UUID, key slots and phase, so a change that
# stops part way is finished by the next run. A platform whose boot checks prove
# the disk's key slots records the owner's new one before the run finishes.
# Passwords live in variables; never trace them.
set +x
REKEY_STATE=${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/drive-password.state
source "$OMARCHY_PATH/install/provisioning/luks-rekey.sh"
# The LUKS partition beneath /, or nothing when / is not encrypted. Fails when
# it cannot tell, including a crypt layer whose LUKS partition lsblk cannot name.
root_luks_device() {
local root_source ancestry device
root_source=$(findmnt -no SOURCE /) && [[ -n $root_source ]] || return 1
ancestry=$(lsblk -nsrpo NAME,TYPE,FSTYPE "${root_source%%[*}") || return 1
device=$(awk '$3 == "crypto_LUKS" { print $1; exit }' <<<"$ancestry")
if [[ -n $device ]]; then
readlink -f "$device"
elif awk '$2 == "crypt" { found = 1 } END { exit !found }' <<<"$ancestry"; then
return 1
fi
}
# Every LUKS device, from udev's probe as root_luks_device reads it. An
# unprivileged blkid only reads /run/blkid, empty until root runs blkid. lsblk
# repeats a device under each parent (RAID members), so each is listed once.
luks_drives() {
lsblk -nrpo NAME,FSTYPE | awk '$2 == "crypto_LUKS" && !seen[$1]++ { print $1 }'
}
# Every cryptsetup call holds the disk lock, so one left running by a run that
# was killed finishes before the next run looks at the drive.
# Print the key slot the password opens. Returns 2 when it opens none, and
# another failure when the drive cannot be checked. cryptsetup open tries
# enrolled tokens (TPM2, FIDO2, keyring) before the key and reports a token's
# slot whatever key it was given, so allow only a token type no token has.
drive_key_slot() {
local drive=$1 password=$2 out status=0
out=$(printf '%s' "$password" | sudo flock "$disk_lock" env LC_ALL=C cryptsetup open --test-passphrase --verbose --token-type passphrase-only --key-file - "$drive" 2>&1) || status=$?
(( status == 0 )) || return "$status"
grep -o 'Key slot [0-9]* unlocked' <<<"$out" | grep -o '[0-9]*' | head -1
}
# The enabled key slots, comma-separated. A luks2-keyring token under "Tokens:"
# looks like a keyslot, so read only the keyslot section.
drive_slots() {
local dump
dump=$(sudo flock "$disk_lock" cryptsetup luksDump "$1") || return 1
awk '
/^[^ \t]/ { keyslots = ($0 == "Keyslots:") }
keyslots && /^ +[0-9]+: luks2/ { sub(":", "", $1); print $1 }
/^Key Slot [0-9]+: ENABLED/ { sub(":", "", $3); print $3 }' <<<"$dump" | paste -sd, -
}
# The key slots other than FOUND that the password also opens, comma-separated,
# or nothing. Fails when a slot cannot be checked.
other_key_slots() {
local drive=$1 password=$2 found=$3 slots slot status
local -a others=()
slots=$(drive_slots "$drive") || return 1
for slot in ${slots//,/ }; do
[[ $slot != "$found" ]] || continue
status=0
printf '%s' "$password" | sudo flock "$disk_lock" cryptsetup open --test-passphrase --token-type passphrase-only --key-slot "$slot" --key-file - "$drive" >/dev/null 2>&1 || status=$?
if (( status == 0 )); then
others+=("$slot")
elif (( status != 2 )); then
return 1
fi
done
local IFS=,
printf '%s' "${others[*]}"
}
kill_slot() {
local drive=$1 password=$2 slot=$3
printf '%s' "$password" | sudo flock "$disk_lock" cryptsetup luksKillSlot -q --key-file - "$drive" "$slot"
}
# luksChangeKey writes the new key before it retires the old one (LUKS2 swaps
# them in one header update), so a failure that leaves the new key unable to
# open the drive changed nothing: that returns 2. One that stops in between
# leaves both keys, so the old key is retired here until it opens nothing.
# Sets new_slot.
change_luks_key() {
local drive=$1 current=$2 new=$3 old_slot=$4 remains attempt status
local -a pbkdf=(--iter-time 2000)
echo "Changing full-disk encryption password for $drive"
if sudo flock "$disk_lock" cryptsetup isLuks --type luks2 "$drive"; then
pbkdf+=(--pbkdf argon2id)
fi
# Both keys travel over stdin, the current one on the first line.
printf '%s\n%s' "$current" "$new" |
sudo flock "$disk_lock" bash -c 'IFS= read -r current; drive=$1; shift; exec cryptsetup luksChangeKey "$@" --key-file <(printf "%s" "$current") "$drive" <(cat)' bash "$drive" "${pbkdf[@]}"
new_slot=$(drive_key_slot "$drive" "$new") || return
for (( attempt = 0; attempt < 8; attempt++ )); do
status=0
remains=$(drive_key_slot "$drive" "$current") || status=$?
(( status == 2 )) && return 0
(( status == 0 )) && [[ $remains != "$new_slot" ]] || return 1
kill_slot "$drive" "$new" "$remains" || return 1
done
return 1
}
# The entrypoint that records the system disk's key slots for the platform's
# boot checks, or nothing where none does. A machine without its platform's
# boot package at all (dispatch exit 3) predates it, and nothing checks its
# slots. Fails with the dispatcher's message otherwise, such as a boot package
# too old to ship luks-slots.
slot_recorder() {
local out status=0
out=$("$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" --resolve luks-slots 2>&1) || status=$?
if (( status == 0 )); then
printf '%s' "$out"
elif (( status != 3 )); then
printf '%s\n' "$out" >&2
return 1
fi
}
# Refuse a current password that opens any slot but the owner's where the
# platform records the slots: on a disk an earlier setup gave a second key (a
# recovery key), changing that one would leave the owner's password as it was.
# luks-slots --owner prints the recorded slot (0), or exits 4 when no owner slot
# is recorded, as on a Mac set up before its boot package recorded slots; any
# other status is a failure the entrypoint explains, and stops the change.
# Where nothing is recorded, a password that opens the disk's only key slot is
# the owner's, and the change records its new slot (unrecorded_owner).
owner_slot_matches() {
local slot=$1 recorder owner errors reason status=0
recorder=$(slot_recorder) || return 1
[[ -n $recorder ]] || return 0
errors=$(mktemp) || return 1
owner=$(sudo "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" luks-slots --owner 2>"$errors") || status=$?
reason=$(<"$errors")
rm -f "$errors"
if (( status == 4 )); then
only_key_slot "$slot" || return 1
unrecorded_owner=1
return 0
elif (( status != 0 )); then
echo "Could not read which key slot holds the system disk password: ${reason:-luks-slots --owner exited $status}"
return 1
elif [[ ! $owner =~ ^[0-9]+$ ]]; then
echo "Could not read which key slot holds the system disk password: the platform's boot package named '$owner', which is not a key slot."
return 1
fi
if [[ $owner != "$slot" ]]; then
echo "That password opens another key slot on the system disk, such as a recovery key from an earlier setup. Enter the system disk password."
return 1
fi
}
# With no owner slot recorded, the password must open the system disk's one key
# slot: with more keys there is no telling which is the owner's, and recording
# only the owner's would fail the platform's boot check, which wants every kept
# slot recorded. Says how to settle it.
only_key_slot() {
local slot=$1 slots others
slots=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; }
[[ $slots != "$slot" ]] || return 0
others=$(tr ',' '\n' <<<"$slots" | grep -vx -- "$slot" | paste -sd, -)
echo "No key slot is recorded as the system disk password's, and $root_drive has keys in slots $slots, so which one is yours can't be told. The password you entered opens slot $slot."
if [[ $others =~ ^[0-9]+$ ]]; then
echo "If that password is yours and slot $others holds a recovery key you keep, record both: sudo omarchy-lifecycle-dispatch luks-slots owner=$slot recovery=$others"
echo "Otherwise remove the key you don't use: sudo cryptsetup luksKillSlot $root_drive <slot>"
else
echo "Remove the keys you don't use: sudo cryptsetup luksKillSlot $root_drive <slot>"
fi
echo "Then run omarchy-drive-password again."
return 1
}
# Record the slot the owner's password opens now; a no-op where nothing does.
record_owner_slot() {
local recorder
recorder=$(slot_recorder) || return 1
[[ -z $recorder ]] || sudo "$OMARCHY_PATH/bin/omarchy-lifecycle-dispatch" luks-slots "owner=$1"
}
# Phase luks: the key change may or may not have landed. Phase record: the new
# key alone opens the recorded slot, which the boot checks still need.
change_system_password() {
local current=$1 new=$2 old_slot=$3 slots status=0
slots=$(drive_slots "$root_drive")
if [[ ,$slots, != *",$old_slot,"* ]]; then
echo "Could not read the key slots of $root_drive."
return 1
fi
# A disk taken as the owner's for holding one key must still hold only it.
if (( unrecorded_owner )) && [[ $slots != "$old_slot" ]]; then
echo "The key slots of $root_drive changed while you entered the new password. Run omarchy-drive-password again."
return 1
fi
mkdir -p "${REKEY_STATE%/*}" &&
rekey_state_put uuid "$root_uuid" old_slot "$old_slot" slots "$slots" checked 1 phase luks || return 1
change_luks_key "$root_drive" "$current" "$new" "$old_slot" || status=$?
if (( status == 2 )); then
rm -f "$REKEY_STATE"
echo "The disk password did not change."
return 1
elif (( status != 0 )); then
echo "Could not confirm the disk password change. Run omarchy-drive-password again to finish."
return 1
fi
rekey_state_put slot "$new_slot" phase record || return 1
if ! record_owner_slot "$new_slot"; then
echo "Could not record the new key slot for the boot checks. Run omarchy-drive-password again to finish."
return 1
fi
rm -f "$REKEY_STATE"
}
# A journal left on this system's disk by a change that stopped part way.
change_pending() {
local phase uuid
phase=$(rekey_state_get phase) || return 1
uuid=$(rekey_state_get uuid)
if [[ $phase == "luks" || $phase == "record" || $phase == "accounts" ]] && [[ -n $uuid && $uuid == "$root_uuid" ]]; then
return 0
fi
rm -f "$REKEY_STATE"
return 1
}
# Settle the disk on the password it opens with now, then record its slot.
# Before the change was confirmed that is the old key (removing the slot the
# change added, if any) or the new key in the one slot the change added
# (retiring the old slot); after, only the confirmed slot. Any other key on the
# disk, such as a recovery passphrase, is refused, and so is a disk that gained
# more than one slot since.
finish_interrupted_change() {
local phase old_slot slots slot header password opened now added extra count=0 status=0
phase=$(rekey_state_get phase)
# Earlier versions named the confirmed phase "accounts".
[[ $phase != "accounts" ]] || phase=record
old_slot=$(rekey_state_get old_slot)
slots=$(rekey_state_get slots)
slot=$(rekey_state_get slot)
if [[ $phase == "luks" && ! ( $old_slot =~ ^[0-9]+$ && ,$slots, == *",$old_slot,"* ) ]] ||
[[ $phase == "record" && ! $slot =~ ^[0-9]+$ ]]; then
echo "$REKEY_STATE is unreadable. Remove it once the disk opens with only the password you want."
return 1
fi
# Earlier versions journaled the change without checking that the old
# password opened one slot alone (checked=1), so with other slots on the disk
# a copy of it may have outlived the change. No password entered now can show
# which slot was whose, so the owner settles that by hand.
if [[ -z $(rekey_state_get checked) ]]; then
now=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; }
if [[ $now == *,* ]]; then
echo "The last password change for $root_drive was started by an earlier version of omarchy-drive-password, and the disk has key slots $now, so it may still hold a copy of the old password."
echo "Settle it by hand, in this order:"
echo "1. Find the slot the password you want to keep opens, and keep it: sudo cryptsetup open --test-passphrase --token-type passphrase-only --verbose $root_drive names it. If only the old password opens the disk, that is the one you keep."
echo "2. If the password you keep is not the old one, check each other slot with the old password (sudo cryptsetup open --test-passphrase --token-type passphrase-only --key-slot <slot> $root_drive) and remove each one it opens (sudo cryptsetup luksKillSlot $root_drive <slot>). Never remove the slot from step 1."
if [[ -n $(slot_recorder 2>/dev/null) ]]; then
echo "3. Record the slots you kept for the boot checks: sudo omarchy-lifecycle-dispatch luks-slots owner=<slot> recovery=<slot>, with an empty recovery= when no recovery key is left."
fi
echo "Then remove $REKEY_STATE, and run omarchy-drive-password again if the disk should still take a new password."
return 1
fi
fi
echo "The last password change for $root_drive stopped part way."
if [[ $phase == "record" ]]; then
header="New encryption password"
else
header="Encryption password that opens $root_drive now (the new one if the change went through)"
fi
password=$(gum input --password --header "$header") || return 1
opened=$(drive_key_slot "$root_drive" "$password") || status=$?
if (( status != 0 && status != 2 )); then
echo "Could not check the password against $root_drive."
return 1
fi
if [[ $phase == "luks" && -n $opened ]]; then
now=$(drive_slots "$root_drive") || { echo "Could not read the key slots of $root_drive."; return 1; }
for extra in ${now//,/ }; do
if [[ ,$slots, != *",$extra,"* ]]; then
added=$extra
count=$(( count + 1 ))
fi
done
if (( count > 1 )); then
echo "The key slots of $root_drive changed since the interrupted change. Finish it by hand."
return 1
fi
if [[ $opened == "$old_slot" ]]; then
[[ -z $added ]] || kill_slot "$root_drive" "$password" "$added" || return 1
elif [[ -n $added && $opened == "$added" ]]; then
if [[ ,$now, == *",$old_slot,"* ]]; then
kill_slot "$root_drive" "$password" "$old_slot" || return 1
fi
else
opened=""
fi
[[ -z $opened ]] || rekey_state_put slot "$opened" phase record || return 1
elif [[ $phase == "record" && $opened != "$slot" ]]; then
opened=""
fi
if [[ -z $opened ]]; then
echo "That password does not finish the change on $root_drive. Run omarchy-drive-password again to finish."
return 1
fi
if ! record_owner_slot "$opened"; then
echo "Could not record the key slot for the boot checks. Run omarchy-drive-password again to finish."
return 1
fi
rm -f "$REKEY_STATE"
echo "$root_drive now opens with the password you entered, and the change is settled."
}
change_selected_drive() {
local encrypted_drives drive current old_slot others new_password confirmation status=0 system_drive=false
encrypted_drives=$(luks_drives)
if [[ -z $encrypted_drives ]]; then
echo "No encrypted drives available."
return 1
fi
if [[ -n $encrypted_drives ]]; then
if (( $(wc -l <<<"$encrypted_drives") == 1 )); then
drive_to_change="$encrypted_drives"
drive="$encrypted_drives"
else
drive_to_change="$(omarchy-drive-select "$encrypted_drives")"
drive="$(omarchy-drive-select "$encrypted_drives")"
fi
if [[ -n $drive_to_change ]]; then
new_password=$(gum input --password --header "New encryption password") || exit 1
[[ -n $new_password ]] || { echo "Password cannot be empty."; exit 1; }
confirmation=$(gum input --password --header "Confirm new encryption password") || exit 1
[[ $new_password == "$confirmation" ]] || { echo "Passwords do not match."; exit 1; }
echo "Changing full-disk encryption password for $drive_to_change"
# The new key travels over stdin and reaches cryptsetup as a keyfile via
# <(cat), leaving the tty free for the current-passphrase prompt.
printf "%s" "$new_password" | sudo bash -c 'exec cryptsetup luksChangeKey --pbkdf argon2id --iter-time 2000 "$1" <(cat) </dev/tty' bash "$drive_to_change"
else
if [[ -z $drive ]]; then
echo "No drive selected."
return 0
fi
if [[ -n $root_drive && $(readlink -f "$drive") == "$root_drive" ]]; then
system_drive=true
drive=$root_drive
fi
current=$(gum input --password --header "Current encryption password") || return 1
old_slot=$(drive_key_slot "$drive" "$current") || status=$?
if (( status == 2 )); then
echo "That password does not open $drive."
return 1
elif (( status != 0 )); then
echo "Could not check the password against $drive."
return 1
fi
if [[ $system_drive == "true" ]]; then
owner_slot_matches "$old_slot" || { echo "The system disk password did not change."; return 1; }
# An interrupted change is finished from the one slot the password opened;
# another copy of it would outlive the change.
others=$(other_key_slots "$drive" "$current" "$old_slot") || { echo "Could not check the key slots of $drive."; return 1; }
if [[ -n $others ]]; then
echo "That password also opens key slot $others on $drive. Remove the extra copy first (sudo cryptsetup luksKillSlot $drive <slot>), then run omarchy-drive-password again."
if [[ -n $(slot_recorder 2>/dev/null) ]]; then
echo "Then record the slots you keep for the boot checks: sudo omarchy-lifecycle-dispatch luks-slots owner=<slot> recovery=<slot>, with an empty recovery= when no recovery key is left."
fi
echo "The system disk password did not change."
return 1
fi
fi
new_password=$(gum input --password --header "New encryption password") || return 1
[[ -n $new_password ]] || { echo "Password cannot be empty."; return 1; }
confirmation=$(gum input --password --header "Confirm new encryption password") || return 1
[[ $new_password == "$confirmation" ]] || { echo "Passwords do not match."; return 1; }
[[ $new_password != "$current" ]] || { echo "The new password is the current one."; return 1; }
# Its slot is how the change is followed, so it must not open one already.
status=0
drive_key_slot "$drive" "$new_password" >/dev/null || status=$?
if (( status == 0 )); then
echo "That password already opens a key slot on $drive. Choose another."
return 1
elif (( status != 2 )); then
echo "Could not check the password against $drive."
return 1
fi
status=0
if [[ $system_drive == "true" ]]; then
change_system_password "$current" "$new_password" "$old_slot"
else
change_luks_key "$drive" "$current" "$new_password" "$old_slot" || status=$?
if (( status == 2 )); then
echo "The password of $drive did not change."
return 1
elif (( status != 0 )); then
echo "Could not confirm the password change on $drive."
return 1
fi
fi
}
disk_lock=${REKEY_STATE%/*}/drive-password.disk.lock
mkdir -p "${REKEY_STATE%/*}" && touch "$disk_lock" && exec {lock_fd}>"${REKEY_STATE%/*}/drive-password.lock" && flock -n "$lock_fd" ||
{ echo "Another drive password change is running."; exit 1; }
unrecorded_owner=0
root_drive=$(root_luks_device) || { echo "Could not tell which drive holds the system."; exit 1; }
root_uuid=""
if [[ -n $root_drive ]]; then
root_uuid=$(lsblk -dno UUID "$root_drive") && [[ -n $root_uuid ]] || { echo "Could not read the UUID of $root_drive."; exit 1; }
fi
if change_pending; then
finish_interrupted_change
else
echo "No encrypted drives available."
exit 1
change_selected_drive
fi
+10 -4
View File
@@ -30,8 +30,14 @@ if [[ -f ~/.config/alacritty/alacritty.toml ]]; then
sed -i "s/family = \".*\"/family = \"$font_name\"/g" ~/.config/alacritty/alacritty.toml
fi
if [[ -f ~/.config/kitty/kitty.conf ]]; then
sed -i "s/^font_family .*/font_family $font_name/g" ~/.config/kitty/kitty.conf
if [[ -f ~/.config/kitty/kitty.conf ]] || omarchy-cmd-present kitty; then
mkdir -p ~/.config/kitty
if grep -qE '^[[:space:]]*font_family[[:space:]]+' ~/.config/kitty/kitty.conf 2>/dev/null; then
kitty_font_name=$(printf '%s' "$font_name" | sed 's/[\\&/]/\\&/g')
sed --follow-symlinks -i -E "s/^[[:space:]]*font_family[[:space:]]+.*/font_family $kitty_font_name/" ~/.config/kitty/kitty.conf
else
printf '\nfont_family %s\n' "$font_name" >>~/.config/kitty/kitty.conf
fi
pkill -USR1 kitty
fi
@@ -68,11 +74,11 @@ XML
omarchy-restart-shell
if pgrep -x ghostty; then
omarchy-notification-send -g "You must restart Ghostty to see font change"
omarchy-notification-send -g "$(omarchy-text "You must restart Ghostty to see font change")"
fi
if pgrep -x foot; then
omarchy-notification-send -g "You must restart Foot to see font change"
omarchy-notification-send -g "$(omarchy-text "You must restart Foot to see font change")"
fi
omarchy-hook font-set "$font_name"
+2 -2
View File
@@ -10,7 +10,7 @@ if (( $# == 0 )); then
mapfile -t cores < <(omarchy-games-retro-cores)
if (( ${#cores[@]} == 0 )); then
omarchy-notification-send -g 󰯉 "No RetroArch cores found" "/usr/lib/libretro"
omarchy-notification-send -g 󰯉 "$(omarchy-text "No RetroArch cores found")" "/usr/lib/libretro"
exit 1
fi
@@ -69,4 +69,4 @@ EOF
chmod +x "$desktop_file"
update-desktop-database "$desktop_dir" &>/dev/null || true
omarchy-notification-send -g 󰯉 "$game_name installed" "Start it with Super + Space"
omarchy-notification-send -g 󰯉 "$(omarchy-text "%1 installed" "$game_name")" "$(omarchy-text "Start it with Super + Space")"
+44 -3
View File
@@ -13,6 +13,12 @@ for arg in "$@"; do
esac
done
# Resume is Limine-oriented and hibernate is unreliable on Asahi.
if omarchy-hw-aarch64-apple; then
echo "Skipping hibernation setup (not supported on Apple Silicon)"
exit 0
fi
if [[ ! -f /sys/power/image_size ]]; then
echo -e "Hibernation is not supported on your system" >&2
exit 0
@@ -30,6 +36,35 @@ MKINITCPIO_CONF="/etc/mkinitcpio.conf.d/omarchy_resume.conf"
SWAP_FILE="/swap/swapfile"
RESUME_DROP_IN="/etc/limine-entry-tool.d/resume.conf"
install_root_file() {
local source="$1"
local destination="$2"
local mode="$3"
local stage
stage=$(sudo /usr/bin/mktemp -- "${destination%/*}/.${destination##*/}.omarchy.XXXXXX") || return 1
safe_stage_path "$stage" "$destination" || return 1
if sudo /usr/bin/install -m "$mode" -o root -g root -T "$source" "$stage" &&
sudo /usr/bin/mv -Tf -- "$stage" "$destination"; then
return 0
else
safe_stage_path "$stage" "$destination" && sudo /usr/bin/rm -f -- "$stage"
return 1
fi
}
safe_stage_path() {
local stage="$1"
local destination="$2"
local prefix suffix
prefix="${destination%/*}/.${destination##*/}.omarchy."
[[ $stage == "$prefix"* ]] || return 1
suffix=${stage#"$prefix"}
[[ $suffix =~ ^[[:alnum:]]{6}$ ]]
}
# Check if hibernation is already configured
if [[ -f $MKINITCPIO_CONF ]] && grep -q "^HOOKS+=(resume)$" "$MKINITCPIO_CONF"; then
# Fix empty resume_offset if btrfs map-swapfile failed during initial setup
@@ -83,14 +118,20 @@ if ! swapon --show | grep -q "$SWAP_FILE"; then
sudo swapon -p 0 "$SWAP_FILE"
fi
# Ensure keyboard backlight doesn't prevent sleep
# Install this before writing the resume marker so a failed install remains
# retryable through the normal setup command.
if ! install_root_file "$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" \
/usr/lib/systemd/system-sleep/keyboard-backlight 0755; then
echo "Could not install the keyboard-backlight system-sleep hook" >&2
exit 1
fi
# Add resume hook to mkinitcpio
sudo mkdir -p /etc/mkinitcpio.conf.d
echo "Adding resume hook to $MKINITCPIO_CONF"
echo "HOOKS+=(resume)" | sudo tee "$MKINITCPIO_CONF" >/dev/null
# Ensure keyboard backlight doesn't prevent sleep
sudo cp -p "$OMARCHY_PATH/default/systemd/system-sleep/keyboard-backlight" /usr/lib/systemd/system-sleep/
# Add resume= kernel parameters so the initramfs resume hook knows where to find the
# hibernation image. Without these, resume happens late (after GPU drivers load) and fails.
if [[ ! -f $RESUME_DROP_IN ]]; then
+12
View File
@@ -11,6 +11,18 @@ if (( $# < 1 )); then
fi
HOOK=$1
# Hook names are fixed labels chosen by Omarchy code (post-update, theme-set,
# font-set). The name becomes a filename under the hooks directory. A slash
# would turn it into directory levels, and a bare `.` or `..` would point bash
# at the directory itself or its parent. Refuse those rather than follow them.
# Dots inside a name (a..b) are fine; once slashes are out, only the whole
# name being `.` or `..` can leave the directory.
if [[ -z $HOOK || $HOOK == */* || $HOOK == "." || $HOOK == ".." ]]; then
echo "Invalid hook name: $HOOK" >&2
exit 2
fi
HOOK_PATH="$HOME/.config/omarchy/hooks/$1"
HOOK_DIR="$HOOK_PATH.d"
shift
+13 -2
View File
@@ -15,8 +15,19 @@ fi
HOOK_TYPE=$1
HOOK_FILE=$2
# Hook types are the same labels omarchy-hook runs (post-update, theme-set).
# The type becomes a directory under the hooks directory. A slash would turn
# it into directory levels. A bare `.` or `..` is a name the runner already
# refuses, so installing under it would write a hook nothing can run. Refuse
# those rather than mkdir/cp into them. Dots inside a name (a..b) are fine.
if [[ -z $HOOK_TYPE || $HOOK_TYPE == */* || $HOOK_TYPE == "." || $HOOK_TYPE == ".." ]]; then
echo "Invalid hook name: $HOOK_TYPE" >&2
exit 2
fi
HOOK_DIR="$HOME/.config/omarchy/hooks/$HOOK_TYPE.d"
HOOK_NAME=$(basename "$HOOK_FILE")
HOOK_NAME=$(basename -- "$HOOK_FILE")
HOOK_PATH="$HOOK_DIR/$HOOK_NAME"
if [[ ! -f $HOOK_FILE ]]; then
@@ -25,7 +36,7 @@ if [[ ! -f $HOOK_FILE ]]; then
fi
mkdir -p "$HOOK_DIR"
cp "$HOOK_FILE" "$HOOK_PATH"
cp -- "$HOOK_FILE" "$HOOK_PATH"
chmod 755 "$HOOK_PATH"
echo "Installed $HOOK_TYPE hook: $HOOK_PATH"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# omarchy:summary=Detect whether the CPU is aarch64 (every ARM machine, Apple Silicon included).
[[ $(uname -m) == "aarch64" ]]
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash -p
# omarchy:summary=Detect whether the computer is an Apple Silicon Mac.
# Callers run this by absolute path, from systemd and from the shell, so use the
# detector shipped beside it rather than whichever one PATH finds. -p keeps a
# root caller's exported functions out, so a launch without it is refused, and
# nothing here searches PATH.
if [[ $- != *p* ]]; then
echo "Refusing an unsafe Bash startup." >&2
exit 126
fi
dir=${BASH_SOURCE[0]%/*}
[[ $dir != "${BASH_SOURCE[0]}" ]] || dir=.
platform=$("$dir/omarchy-hw-platform") || exit 1
[[ $platform == "aarch64-apple" ]]
+15
View File
@@ -0,0 +1,15 @@
#!/bin/bash -p
# omarchy:summary=Detect whether the computer is an Apple Silicon Mac (the old name of omarchy-hw-aarch64-apple).
# omarchy:hidden=true
# Kept while omarchy-mac's packages and units still call it by this name. Like
# the predicate it runs, it refuses a launch without -p and searches no PATH.
if [[ $- != *p* ]]; then
echo "Refusing an unsafe Bash startup." >&2
exit 126
fi
dir=${BASH_SOURCE[0]%/*}
[[ $dir != "${BASH_SOURCE[0]}" ]] || dir=.
exec "$dir/omarchy-hw-aarch64-apple"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# omarchy:summary=Match the Dell XPS 13 DX13260 with an Intel Panther Lake GPU.
omarchy-hw-match "DX13260" && omarchy-hw-intel-ptl
+40 -6
View File
@@ -5,14 +5,48 @@
backlight_path="${OMARCHY_BACKLIGHT_PATH:-/sys/class/backlight}"
# The platform package may name backlights to skip and to prefer (see
# docs/file-layout.md).
displays_conf=/usr/share/omarchy-platform/displays.conf
skips=()
prefers=()
if [[ -r $displays_conf ]]; then
# \r in IFS: a file saved with CRLF line ends reads the same.
while IFS=$' \t\r' read -r directive argument extra || [[ -n $directive ]]; do
[[ -n $argument && -z $extra && $argument != */* && $argument != . && $argument != .. ]] || continue
case $directive in
backlight-skip) skips+=("$argument") ;;
backlight-prefer) prefers+=("$argument") ;;
esac
done <"$displays_conf"
fi
usable() {
local skip
# The Touch Bar on T2 Macs registers a backlight that never drives the display panel.
[[ -n $1 && $1 != appletb_backlight ]] || return 1
for skip in "${skips[@]}"; do
# shellcheck disable=SC2053 # a skip is a glob
[[ $1 == $skip ]] && return 1
done
return 0
}
# Start with the first possible output, then refine to the most likely given an order heuristic.
# Glob the candidates in the loop list: [[ ]] does not do pathname expansion.
# The Touch Bar on T2 Macs registers a backlight that never drives the display panel.
device="$(ls -1 "$backlight_path" 2>/dev/null | grep -vx appletb_backlight | head -n1)"
device=""
while IFS= read -r name; do
if usable "$name"; then
device="$name"
break
fi
done < <(ls -1 "$backlight_path" 2>/dev/null)
# gmux comes first: apple-gmux only registers when the kernel has already picked it, and on
# dual-GPU Macs the GPU's own PWM stops driving the panel once that GPU suspends.
for candidate in "$backlight_path"/gmux_backlight "$backlight_path"/amdgpu_bl* "$backlight_path"/intel_backlight "$backlight_path"/acpi_video*; do
if [[ -e $candidate ]]; then
# dual-GPU Macs the GPU's own PWM stops driving the panel once that GPU suspends. The
# platform's preferred backlights follow, then the GPU and ACPI ones.
# Glob the candidates in the loop list: [[ ]] does not do pathname expansion.
for candidate in "$backlight_path"/gmux_backlight "${prefers[@]/#/$backlight_path/}" "$backlight_path"/amdgpu_bl* "$backlight_path"/intel_backlight "$backlight_path"/acpi_video*; do
if [[ -e $candidate ]] && usable "${candidate##*/}"; then
device="${candidate##*/}"
break
fi
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# omarchy:summary=Detect whether an Elgato Cam Link 4K is plugged in.
grep -qsx "Cam Link 4K" /sys/bus/usb/devices/*/product
+26 -2
View File
@@ -2,15 +2,39 @@
# omarchy:summary=Returns true when running on a laptop (has a lid or laptop chassis).
acpi_lid="${OMARCHY_ACPI_LID_PATH:-/proc/acpi/button/lid}"
input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}"
dmi_chassis="${OMARCHY_DMI_CHASSIS_TYPE_PATH:-/sys/class/dmi/id/chassis_type}"
shopt -s nullglob
acpi_states=("$acpi_lid"/*/state)
input_switches=("$input_class"/*/capabilities/sw)
shopt -u nullglob
# A lid switch is the definitive signal for clamshell-capable hardware.
for state in /proc/acpi/button/lid/*/state; do
for state in "${acpi_states[@]}"; do
[[ -e $state ]] && exit 0
done
# Without an ACPI lid (Apple Silicon, for one), any input device that reports a
# lid switch (SW_LID, bit 0 of its switch capabilities, the last word of the
# bitmap) counts. The switch's name varies: Apple's SMC input device carries
# the same name on a Mac mini, where it reports no lid.
for switches in "${input_switches[@]}"; do
[[ -r $switches ]] || continue
read -r -a words <"$switches" || true
(( ${#words[@]} )) || continue
word=${words[-1]}
[[ $word =~ ^[0-9a-fA-F]+$ ]] || continue
(( 16#$word & 1 )) && exit 0
done
# Fall back to the DMI chassis type for laptops that don't expose an ACPI lid
# button. 8=Portable 9=Laptop 10=Notebook 14=Sub Notebook 30=Tablet
# 31=Convertible 32=Detachable.
case $(< /sys/class/dmi/id/chassis_type 2>/dev/null) in
chassis_type=""
read -r chassis_type <"$dmi_chassis" 2>/dev/null || true
case $chassis_type in
8 | 9 | 10 | 14 | 30 | 31 | 32) exit 0 ;;
esac
+40 -4
View File
@@ -3,9 +3,45 @@
# omarchy:summary=Returns true when the laptop lid is closed
# omarchy:hidden=true
for state in /proc/acpi/button/lid/*/state; do
[[ -r $state ]] || continue
[[ $(< "$state") == *"closed"* ]] && exit 0
done
acpi_lid="${OMARCHY_ACPI_LID_PATH:-/proc/acpi/button/lid}"
input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}"
shopt -s nullglob
acpi_states=("$acpi_lid"/*/state)
input_switches=("$input_class"/*/capabilities/sw)
shopt -u nullglob
# An input device that reports a lid switch: SW_LID, bit 0 of the last word
# of its switch bitmap.
has_lid_switch() {
local switches word
local -a words
for switches in "${input_switches[@]}"; do
[[ -r $switches ]] || continue
read -r -a words <"$switches" || true
(( ${#words[@]} )) || continue
word=${words[-1]}
[[ $word =~ ^[0-9a-fA-F]+$ ]] || continue
(( 16#$word & 1 )) && return 0
done
return 1
}
if (( ${#acpi_states[@]} )); then
# An ACPI lid reports its own state.
for state in "${acpi_states[@]}"; do
[[ -r $state ]] || continue
[[ $(<"$state") == *"closed"* ]] && exit 0
done
elif has_lid_switch; then
# Elsewhere (Apple Silicon's SMC, for one) only logind reads the switch. Ask
# it only then: it answers "open" when it tracks no lid. This runs in sudo's
# and polkit's PAM stack where fingerprint login is set up, so a stalled
# logind must not hold up every sudo.
lid_state=$(busctl --timeout=2 get-property org.freedesktop.login1 \
/org/freedesktop/login1 org.freedesktop.login1.Manager LidClosed 2>/dev/null)
[[ $lid_state == "b true" ]] && exit 0
fi
exit 1
+2 -2
View File
@@ -3,5 +3,5 @@
# omarchy:summary=Match against the computer's DMI product name or product family (case-insensitive).
# omarchy:args=<pattern>
grep -qi "$1" /sys/class/dmi/id/product_name 2>/dev/null ||
grep -qi "$1" /sys/class/dmi/id/product_family 2>/dev/null
grep -qi -- "$1" /sys/class/dmi/id/product_name 2>/dev/null ||
grep -qi -- "$1" /sys/class/dmi/id/product_family 2>/dev/null
+45
View File
@@ -0,0 +1,45 @@
#!/bin/bash
# omarchy:summary=Detect whether NVIDIA drives the display (rather than a hybrid iGPU).
# On hybrid laptops the display is usually wired to the integrated GPU while
# the NVIDIA card only handles offloaded rendering. Forcing NVIDIA's VA-API and
# GLX drivers globally then breaks Chromium-based browsers (black video with
# audio still playing), so the Hyprland NVIDIA env only applies when NVIDIA is
# the display GPU.
#
# sysfs exposes which VGA device drives the display via boot_vga. When no
# device claims it (attribute missing or unreadable), assume NVIDIA is the
# display GPU to preserve the current behavior on machines we cannot tell.
#
# Read the cached sysfs IDs rather than lspci, which reads PCI config space and
# resumes runtime-suspended GPUs.
pci_devices_path="${OMARCHY_PCI_DEVICES_PATH:-/sys/bus/pci/devices}"
shopt -s nullglob
nvidia_boot=0
other_boot=0
for device in "$pci_devices_path"/*; do
[[ $(< "$device/class") == 0x03* ]] || continue
boot_vga=0
if [[ -f $device/boot_vga ]]; then
boot_vga=$(< "$device/boot_vga")
fi
if [[ $(< "$device/vendor") == "0x10de" ]]; then
if (( boot_vga == 1 )); then
nvidia_boot=1
fi
else
if (( boot_vga == 1 )); then
other_boot=1
fi
fi
done
if (( other_boot == 1 && nvidia_boot == 0 )); then
exit 1
else
exit 0
fi
+202
View File
@@ -0,0 +1,202 @@
#!/bin/bash -p
# omarchy:summary=Print the hardware platform: x86, aarch64 or aarch64-apple.
# Platform names are the CPU architecture, then the hardware family when it
# needs handling of its own: x86, aarch64 (every ARM machine without one) and
# aarch64-apple (Apple Silicon). A later family is aarch64-<family>, so
# "aarch64*" always means ARM (AGENTS.md, Platforms).
#
# ARM laptops have no DMI, so the board identity is the root node of the device
# tree the firmware hands the kernel: "apple,<model>" from Asahi's m1n1 on every
# Apple Silicon Mac, "qcom,<soc>" on Snapdragon laptops, which are plain aarch64
# here and must never pass for a Mac. Each compatible string is a NUL-separated
# list, and only a token's vendor prefix counts.
#
# An image is built away from the machine it will run on, so while this root is
# being built rather than booted, the image-target manifest the builder wrote
# names the platform and the build host's device tree is never read.
#
# Fixture roots let unprivileged tests fake the device tree, PID 1 and the image
# root. Naming any of them makes the whole view a fixture: nothing live is read,
# not even the device tree when only the image root is named. A privileged
# caller must not let its environment pick the platform at all.
# Privileged mode (-p) keeps exported functions and BASH_ENV out of this shell,
# so a launch without it is refused. Root then starts over in an empty
# environment and reads only the live paths with a fixed PATH.
if [[ $- != *p* ]]; then
echo "Refusing an unsafe Bash startup." >&2
exit 126
fi
if (( EUID == 0 )) && [[ ${1:-} != --clean-environment ]]; then
exec /usr/bin/env -i PATH=/usr/bin /bin/bash -p -- "${BASH_SOURCE[0]}" --clean-environment "$@"
fi
if (( EUID == 0 )); then
export PATH=/usr/bin
fixtures=""
else
fixtures=${OMARCHY_PROC_ROOT:-}${OMARCHY_SYS_ROOT:-}${OMARCHY_IMAGE_ROOT:-}
fi
if [[ -z $fixtures ]]; then
proc=/proc
sources=(/proc/device-tree/compatible /sys/firmware/devicetree/base/compatible)
image_root=""
image_dir=/var/lib/omarchy/image
image_owner=0
else
proc=${OMARCHY_PROC_ROOT:-}
sources=()
[[ -z ${OMARCHY_PROC_ROOT:-} ]] || sources+=("$OMARCHY_PROC_ROOT/device-tree/compatible")
[[ -z ${OMARCHY_SYS_ROOT:-} ]] || sources+=("$OMARCHY_SYS_ROOT/firmware/devicetree/base/compatible")
image_root=${OMARCHY_IMAGE_ROOT:-}
image_dir=${image_root:+$image_root/var/lib/omarchy/image}
image_owner=$EUID
fi
manifest=$image_dir/target
contradiction() {
echo "Error: contradictory platform identity: $1" >&2
exit 1
}
if ! machine=$(uname -m) || [[ -z $machine ]]; then
echo "Error: cannot read the CPU architecture" >&2
exit 1
fi
# A booted system is one systemd runs as PID 1 from this root. An image build
# is told apart by what it shows: no /run/systemd/system (a root with a /run of
# its own), PID 1's root being another (a chroot on the build host, even with
# the host's /run bound in, as arch-chroot does), or PID 1 being something other
# than systemd (a PID namespace whose PID 1 is the build). What cannot be seen,
# such as PID 1's root to a normal user or /proc/1 behind hidepid, never makes a
# build: a booted system always answers from its hardware, so a build runs as
# root. A caller in a private PID namespace looks like a build too, so nothing
# that asks the detector runs with PrivatePIDs=.
booted() {
local comm
[[ -d $image_root/run/systemd/system ]] || return 1
if [[ -n $proc && -e $proc/1/root ]]; then
[[ $proc/1/root -ef $image_root/ ]] || return 1
fi
if [[ -n $proc && -r $proc/1/comm ]]; then
read -r comm <"$proc/1/comm" && [[ $comm == "systemd" ]] || return 1
fi
}
# Owned by root (the test's own user for a fixture), not a symlink, and not
# writable by anyone else.
trusted() {
local owner mode
[[ -e $1 && ! -L $1 ]] || return 1
read -r owner mode < <(stat -c '%u %a' -- "$1") || return 1
[[ $owner =~ ^[0-9]+$ && $mode =~ ^[0-7]+$ ]] && (( owner == image_owner && (8#$mode & 8#022) == 0 ))
}
# The manifest: format=1, platform= one of the platform names, comments and
# unknown keys ignored, and any other line refused. Image builders written before
# the names settled say apple-silicon, generic-aarch64 or generic, which still
# read as aarch64-apple, aarch64 and x86. The detector reads it itself, so it
# stands alone wherever a package ships a copy of it.
image_platform() {
local line key value format="" platform=""
if [[ ! -d $image_dir || ! -f $manifest ]] || ! trusted "$image_dir" || ! trusted "$manifest"; then
echo "Error: the image manifest $manifest is not a root-owned regular file" >&2
return 1
fi
while IFS= read -r line || [[ -n $line ]]; do
[[ -n $line && $line != \#* ]] || continue
if [[ $line != *=* ]]; then
echo "Error: the image manifest $manifest is malformed: $line" >&2
return 1
fi
key=${line%%=*}
value=${line#*=}
case $key in
format) format=$value ;;
platform) platform=$value ;;
esac
done <"$manifest"
if [[ $format != "1" ]]; then
echo "Error: the image manifest $manifest is not format=1" >&2
return 1
fi
case $platform in
apple-silicon) platform=aarch64-apple ;;
generic-aarch64) platform=aarch64 ;;
generic) platform=x86 ;;
esac
if [[ ! $platform =~ ^(x86|aarch64|aarch64-apple)$ ]]; then
echo "Error: the image manifest $manifest names no known platform: ${platform:-none}" >&2
return 1
fi
echo "$platform"
}
if [[ -n $image_dir ]] && [[ -e $manifest || -L $manifest ]] && ! booted; then
platform=$(image_platform) || exit 1
if [[ $platform == "x86" ]]; then
[[ $machine != "aarch64" ]] || contradiction "$manifest names $platform hardware but the CPU is $machine"
else
[[ $machine == "aarch64" ]] || contradiction "$manifest names $platform hardware but the CPU is $machine"
fi
echo "$platform"
exit 0
fi
vendor=""
vendor_source=""
for source in "${sources[@]}"; do
[[ -r $source ]] || continue
apple=0
qcom=0
mapfile -d '' -t tokens <"$source"
for token in "${tokens[@]}"; do
[[ $token == "apple,"* ]] && apple=1
[[ $token == "qcom,"* ]] && qcom=1
done
if (( apple && qcom )); then
contradiction "$source names both Apple and Qualcomm hardware"
elif (( apple )); then
found=apple
elif (( qcom )); then
found=qcom
else
found=none
fi
if [[ -n $vendor && $vendor != "$found" ]]; then
contradiction "$vendor_source says $vendor but $source says $found"
fi
vendor=$found
vendor_source=$source
done
case $vendor in
apple | qcom)
[[ $machine == "aarch64" ]] || contradiction "$vendor_source names $vendor hardware but the CPU is $machine"
if [[ $vendor == "apple" ]]; then
echo aarch64-apple
else
echo aarch64
fi
;;
*)
if [[ $machine == "aarch64" ]]; then
echo aarch64
else
echo x86
fi
;;
esac
+26 -1
View File
@@ -2,5 +2,30 @@
# omarchy:summary=Print the detected Hyprland touchpad or trackpad device name
device=$(hyprctl devices -j | jq -r '[.mice[] | .name | select(test("touchpad|trackpad"; "i"))] | first // empty')
mice=$(hyprctl devices -j | jq -r '.mice[].name')
device=$(grep -iE -m1 'touchpad|trackpad' <<<"$mice")
# Some touchpads carry neither word (Apple Silicon's is "apple-mtp-multi-touch"),
# so ask udev which mouse is one. The name alone can't say: some touchscreens'
# mouse interfaces are called multi-touch too.
if [[ -z $device ]]; then
input_class="${OMARCHY_INPUT_CLASS_PATH:-/sys/class/input}"
for event in "$input_class"/event*; do
[[ -r $event/device/name ]] || continue
# Hyprland's name for a device: the kernel's, lowercased, with spaces,
# commas and newlines as dashes, and -1, -2 and so on after a name another
# device already took.
name=$(<"$event/device/name")
name=${name,,}
name=${name//[ ,$'\n']/-}
udevadm info --query=property "$event" 2>/dev/null | grep -Fxq 'ID_INPUT_TOUCHPAD=1' || continue
while IFS= read -r mouse; do
if [[ $mouse == "$name" || ( $mouse == "$name"-* && ${mouse#"$name"-} =~ ^[0-9]+$ ) ]]; then
device=$mouse
break 2
fi
done <<<"$mice"
done
fi
[[ -n $device ]] && echo "$device"
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# omarchy:summary=Returns true when running in a virtual machine.
systemd-detect-virt --vm --quiet
+5
View File
@@ -0,0 +1,5 @@
#!/bin/bash
# omarchy:summary=Detect whether the CPU is x86_64.
[[ $(uname -m) == "x86_64" ]]
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# omarchy:summary=Show or set window corners: theme, sharp, or rounded
# omarchy:args=[theme|sharp|rounded]
# omarchy:examples=omarchy hyprland corners | omarchy hyprland corners rounded
TOGGLES_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/toggles/hypr"
# Sharp and rounded are toggle files loaded after every theme, so they override
# the corners of whichever theme is current. Theme removes both.
if (( $# == 0 )); then
if [[ -f $TOGGLES_DIR/corners-sharp.lua ]]; then
echo "sharp"
elif [[ -f $TOGGLES_DIR/corners-rounded.lua ]]; then
echo "rounded"
else
echo "theme"
fi
exit 0
fi
case $1 in
theme) LABEL=$(omarchy-text "Corners follow the theme") ;;
sharp) LABEL=$(omarchy-text "Sharp corners for all themes") ;;
rounded) LABEL=$(omarchy-text "Rounded corners for all themes") ;;
*)
echo "Usage: omarchy-hyprland-corners [theme|sharp|rounded]" >&2
exit 1
;;
esac
# Save the new choice before dropping the old one, so a failed save keeps it.
if [[ $1 != "theme" ]]; then
mkdir -p "$TOGGLES_DIR" && cp "$OMARCHY_PATH/default/hypr/toggles/corners-$1.lua" "$TOGGLES_DIR/corners-$1.lua" || exit 1
fi
for mode in sharp rounded; do
[[ $mode == "$1" ]] || rm -f "$TOGGLES_DIR/corners-$mode.lua" || exit 1
done
hyprctl reload >/dev/null || exit 1
omarchy-notification-send -g 󰘇 "$LABEL"
+5 -5
View File
@@ -16,7 +16,7 @@ wake() {
on() {
if omarchy-hyprland-toggle-enabled $TOGGLE; then
omarchy-hyprland-toggle $TOGGLE off
omarchy-notification-send -g 󰍹 "Laptop display enabled"
omarchy-notification-send -g 󰍹 "$(omarchy-text "Laptop display enabled")"
fi
wake
@@ -24,25 +24,25 @@ on() {
off() {
if [[ -z $INTERNAL ]]; then
omarchy-notification-send -g 󰍹 "No laptop display found"
omarchy-notification-send -g 󰍹 "$(omarchy-text "No laptop display found")"
exit 1
fi
# The name is written into generated Lua below, so only a plain connector
# name may pass; anything else could execute on the next reload.
if [[ ! $INTERNAL =~ ^[A-Za-z0-9._-]+$ ]]; then
omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name"
omarchy-notification-send -g 󰍹 "$(omarchy-text "Refusing unsafe monitor name")"
exit 1
fi
if ! omarchy-hyprland-monitor-external-active; then
omarchy-notification-send -g 󰍹 "Can't disable the only active display"
omarchy-notification-send -g 󰍹 "$(omarchy-text "Can't disable the only active display")"
exit 1
fi
if omarchy-hyprland-toggle-disabled $TOGGLE && omarchy-hyprland-toggle-disabled $MIRROR_TOGGLE; then
printf 'hl.monitor({ output = "%s", disabled = true })\n' "$INTERNAL" >"$TOGGLE_FLAG"
omarchy-notification-send -g 󰍹 "Laptop display disabled"
omarchy-notification-send -g 󰍹 "$(omarchy-text "Laptop display disabled")"
hyprctl reload
fi
}
+5 -5
View File
@@ -13,12 +13,12 @@ EXTERNAL=$(hyprctl monitors -j | jq -r '.[] | select(.name | test("^(eDP|LVDS|DS
on() {
if [[ -z $EXTERNAL ]]; then
omarchy-notification-send -g 󰍹 "No external monitors found for mirror"
omarchy-notification-send -g 󰍹 "$(omarchy-text "No external monitors found for mirror")"
exit 1
fi
if [[ -z $INTERNAL ]]; then
omarchy-notification-send -g 󰍹 "No laptop monitor found to mirror"
omarchy-notification-send -g 󰍹 "$(omarchy-text "No laptop monitor found to mirror")"
exit 1
fi
@@ -26,7 +26,7 @@ on() {
# names may pass; a user-created headless output can carry any name.
for output in "$INTERNAL" "$EXTERNAL"; do
if [[ ! $output =~ ^[A-Za-z0-9._-]+$ ]]; then
omarchy-notification-send -g 󰍹 "Refusing unsafe monitor name"
omarchy-notification-send -g 󰍹 "$(omarchy-text "Refusing unsafe monitor name")"
exit 1
fi
done
@@ -35,7 +35,7 @@ on() {
if omarchy-hyprland-toggle-disabled $TOGGLE; then
printf 'hl.monitor({ output = "%s", mode = "preferred", position = "auto", scale = 1, mirror = "%s" })\n' "$EXTERNAL" "$INTERNAL" >"$TOGGLE_FLAG"
omarchy-notification-send -g 󰍹 "Mirroring enabled ($EXTERNAL)"
omarchy-notification-send -g 󰍹 "$(omarchy-text "Mirroring enabled (%1)" "$EXTERNAL")"
hyprctl reload
fi
}
@@ -43,7 +43,7 @@ on() {
off() {
if omarchy-hyprland-toggle-enabled $TOGGLE; then
omarchy-hyprland-toggle $TOGGLE off
omarchy-notification-send -g 󰍹 "Extended mode restored"
omarchy-notification-send -g 󰍹 "$(omarchy-text "Extended mode restored")"
fi
}
Loaded 100 of 1149 files, more files were not shown because too many files have changed in this diff. Show more