Skip to content

Watch every luvus session, not one of them - #2

Open
johnkattenhorn wants to merge 2 commits into
riclib:masterfrom
johnkattenhorn:feat/multi-session
Open

johnkattenhorn wants to merge 2 commits into
riclib:masterfrom
johnkattenhorn:feat/multi-session

Conversation

@johnkattenhorn

Copy link
Copy Markdown

Closes #1.

Option 2 from the issue: one widget watches every session, the bar keeps one number, and the panel groups its rows by session. Option 1's shape is in here too — session takes a comma-separated list — but * is the one that makes a per-project session layout usable.

An existing installation does not change: session left empty still means the default session and only that.

What it looks like

{ "id": "riclib.luvus", "session": "*",
  "windowClassTemplate": "org.omarchy.luvus.{session}" }

The bar still answers one question across the whole fleet — blocked, then working, then a count. The panel grows a heading per session, ordered the way the rows are, so a session holding a blocked agent comes first. A session with nothing in it keeps its heading and says "no agents", because one that quietly disappears reads as one with nothing waiting in it.

The shape

Service stays single-session, and there is now one per session. Its process lifecycle — _closing, the backoff, the declarative spawnWatch — is the part of this plugin that took the most work to get right, and teaching one Process to multiplex several subscriptions would put all of it back in play. BarSlot owns an Instantiator over the resolved session list, collects their states on a zero-interval timer, and merges them in Model.mergeStates. A new Sessions.qml reads luvus session list --json on the same 30s cadence as the agent poll, so a session started later is picked up without a restart.

The cost multiplies with monitors — sessions × monitors subscriptions — so Model.MAX_SESSIONS is 16, which keeps a two-monitor machine at half of luvus's 64-subscriber cap. Measured here with five sessions on two monitors: ten luvus events processes and nothing else at rest.

Two things about luvus that are now encoded rather than assumed

The default session is addressed by passing no --session flag at all. luvus session list --json calls it default and marks it "default": true, but that name is a label, not an argument. On 0.14.1, against a default session holding one agent, --session default agent list answered with zero agents and a revision eighteen hundred behind — a different server. Model.sessionKey maps that entry to "", and there's a test for it.

A session name reaches argv, so it is guarded exactly like a pane id. luvus parses --session -x as one of its own global flags rather than rejecting it, so Model.sessionName refuses anything that is not plainly a name and the session is dropped instead of being watched under a name that means something else.

Jumping

Both halves are per-session now. The pane focus goes through that session's own Service, because pane 2 exists in all of them and luvus pane focus 2 addresses whichever server the argv names. For the window, windowClassTemplate puts the session into the class — org.omarchy.luvus.{session} against terminals launched with omarchy-launch-or-focus-tui --app-id=org.omarchy.luvus.<session>. Hyprland's class: is a regular expression, so the session name is escaped when it is substituted. focusWindowClass is untouched and still right for a single window.

Checks

  • node --test tests/model.test.js — 53 pass, 26 of them new: session parsing, the default-session key, the argv guard, resolution of "" / "*" / a list, merging and its counts, one dead session among live ones, group ordering, the cap, and the class template.
  • omarchy plugin validate . clean, the textFormat sweep clean, no secrets in the tree.
  • Run live on Omarchy with luvus 0.14.1, five sessions across two monitors: the panel's per-session counts match agent list per session, sampled in the same second.

One testing note worth having in CLAUDE.md, which it now does: a shell running inside a luvus pane exports LUVUS_SOCKET_PATH and LUVUS_SESSION, and the CLI honours them — so a hand-check typed into that terminal answers for that session and can make the widget look wrong about the default one. Quickshell has none of those variables. Unset them before comparing.

Happy to split this, change the setting's spelling, or drop the window template if you'd rather keep that out of the plugin.

johnkattenhorn and others added 2 commits September 14, 2026 19:14
luvus scopes its orchestration ledger, leases and task board to the
session, so the working practice it pushes you towards is one session per
project. A widget that watches a single session then answers "is anything
waiting for me?" for one project and stays silent about the rest, which is
worse than not asking: an honest zero reads as nothing waiting.

`session` now takes `*` for every running session, or a comma-separated
list for the ones you name; empty still means the default session and only
that, so an existing installation does not change. The bar keeps one
number across the fleet — blocked, then working, then a count — and the
panel grows a heading per session, ordered the way the rows are, so a
session with a blocked agent comes first. A session with nothing in it
still says so rather than disappearing.

Service stays single-session and there is now one per session. Its process
lifecycle is the hard-won part of this plugin, and teaching one Process to
multiplex several subscriptions would put all of it back in play; an
Instantiator and a merge in Model cost nothing by comparison. The two
factors multiply with monitors, so MAX_SESSIONS caps the first well below
luvus's 64-subscriber limit.

Two things about luvus itself are now encoded rather than assumed. The
default session is addressed by passing no --session flag at all:
`--session default` is accepted, answers from somewhere else, and reported
zero agents against a default session holding one. And a session name
reaches argv, so it is guarded exactly like a pane id, since luvus reads a
leading-dash name as one of its own flags.

Jumping is per-session on both halves: the pane focus goes through that
session's own Service, because pane 2 exists in all of them, and
`windowClassTemplate` puts the session name into the window class so one
terminal per project can be raised. The name is regex-escaped on the way
in, since Hyprland's class: is a pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBh3oDkHe62bNbDPbDg42R
Hyprland 0.56 takes a Lua expression where hyprctl dispatch used to take
a dispatcher and its arguments, so `focuswindow class:X` is a syntax
error there:

    error: [string "return hl.dispatch(focuswindow class:org.o..."]:1:
           ')' expected near 'class'

The window half of every jump has therefore done nothing on 0.56 —
silently, because the dispatch is fire-and-forget and the pane focus
still works, which makes it look like a compositor ignoring a raise
rather than a command that never parsed. Found while testing something
else; nobody would have reported it as more than "it does not raise the
window".

Both forms are now issued, newest first. No release understands both, so
exactly one lands and the other fails on its own stderr. Omarchy's own
omarchy-launch-or-focus chains the same pair with `||`; this uses two
argv vectors instead, because a shell string carrying a user setting is
the injection sink this plugin already refuses elsewhere.

The class now lands inside a Lua string literal, so it is escaped as one:
backslashes doubled, since a class is a regex and needs them; quotes
escaped; control characters dropped. Tests cover a class that tries to
close the literal and run os.execute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBh3oDkHe62bNbDPbDg42R
@johnkattenhorn

Copy link
Copy Markdown
Author

Pushed 872881d — a fix for something that turned out to be broken before this PR, and is worth knowing about whether or not you take the multi-session change.

hyprctl dispatch focuswindow class:X does not work on Hyprland 0.56. It takes a Lua expression now, so the form this plugin has always used is a syntax error:

$ hyprctl dispatch focuswindow class:org.omarchy.luvus.fitness
error: [string "return hl.dispatch(focuswindow class:org.omar..."]:1: ')' expected near 'class'

The window half of every jump therefore does nothing on 0.56 — silently, because the dispatch is fire-and-forget and the pane focus still works. It reads as Hyprland ignoring a raise rather than a command that never parsed. I only found it because I was testing something unrelated on this machine.

Omarchy's own omarchy-launch-or-focus already handles it:

hyprctl dispatch "hl.dsp.focus({ window = \"address:$ADDR\" })" || hyprctl dispatch focuswindow "address:$ADDR"

The commit issues both forms, newest first, rather than chaining them in a shell — no release understands both, so exactly one lands and the other fails on its own stderr, and Util.execArgv keeps the class out of a shell string. The class now goes inside a Lua string literal, so it is escaped as one: backslashes doubled (a class is a regex and needs them), quotes escaped, control characters dropped. Tests cover a class that tries to close the literal and call os.execute.

Verified on Hyprland 0.56.2: the Lua form parses and a missing class is a harmless hl.focus: window not found warning rather than an error.

Worth cherry-picking on its own if you would rather not take the multi-session change — it is independent of everything else here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Multi-session support: one widget can only watch one luvus session, but the working practice is one session per project

1 participant