Session control: restart the session, leave the seat

src/desktop/scripts/session-control.mix is the session-control citizen. It registers the Bus service desktop-session and answers two verbs:

verbwhat it does
desktop.session.restartrestarts the desktop unit (cosmix-desktop.service), then types a resume line into a terminal pane for each agent (Claude Code) session that was running
desktop.session.leaveswitches to another VT (chvt 1 by default); the desktop keeps running on its own VT, so you can switch back to it

Both verbs are open to mesh callers and need no confirmation: agents drive them directly. The human confirm step belongs to Quoin: its "Restart session…" and "Leave seat…" corner-menu entries (menu_items with confirm) and the shell.session.confirm {action} verb that global chords bind to both open a Confirm/Cancel step first. See Quoin.

Requests and replies

The body is empty, or a JSON object with optional fields:

  • args: a list of strings. This is the shape a Quoin corner-menu extra sends ({"args":[]}). The only string accepted is --dry-run.
  • dry_run: true or false.

A dry run replies with the plan (the argv it would run, the captured sessions, the state file) and runs nothing. COSMIX_SESSION_DRY_RUN=1 in the citizen's environment makes every request a dry run.

send "desktop-session" desktop.session.restart body='{"dry_run":true}'
send "desktop-session" desktop.session.leave
  • restart replies at once: {accepted:true, action:"restart", dry_run, unit, state_file, sessions}. sessions lists the captured session ids; a dry run adds argv. The restart itself happens after the reply (see below).
  • leave replies {accepted:true, action:"leave", dry_run, vt} after chvt returns; a dry run adds argv.
  • Refusals are rc 10 {error_code, message}:
    • INVALID_ARGUMENT: a malformed body, an unknown field, or an unknown arg.
    • SESSION_CONFIG: a missing COSMIX_SESSION_USER, an unknown user or home, or a bad COSMIX_LEAVE_VT.
    • RESTART_IN_PROGRESS: the restart unit is already running.
    • STATE_DIR_UNSAFE: a level of the state directory is a symlink, is not a directory, is owned by someone other than root, or is writable by others.
    • STATE_WRITE: the state file could not be written (a name already taken included).
    • RESTART_START: systemd-run failed; the state file is removed again.
    • CHVT_FAILED: chvt failed.

How a restart survives itself

A restart closes every desktop window, including the terminal the request came from. So nothing that the desktop owns can carry the restart out.

  1. The citizen runs as root from its own system unit. The unit is not PartOf= or BindsTo= the desktop, so the restart does not stop it. The citizen reads the desktop user's live interactive sessions from ~/.claude/sessions/<pid>.json (live means /proc/<pid> exists). It writes them to /var/lib/cosmix/session-resume/sessions-<time>.json: a root-owned directory outside the user's home, checked level by level (no symlinks, root-owned, not writable by others) before anything is written, so no path the user controls can steer what root writes. The state file is mode 0640, root and the user's group (session ids and directories are private); the user owns only its empty .done beside it. State older than 7 days is pruned on each restart. It then starts the worker with systemd-run --unit=cosmix-session-restart --collect and replies. That unit name is the single name every restart uses, so a second restart while one runs is refused.
  2. The worker (session-resume.mix --phase2) runs in that transient unit, outside both the desktop's cgroup and the citizen's, so neither going down can stop it. It:
    • restarts the desktop unit;
    • waits until the unit is active AND its boot terminal (cosmix-boot-term.service) is a NEW process. An old terminal still on the Bus would take the keys into whatever runs in its active tab;
    • runs --resume-fresh STATE PID as the desktop user, PID being the new boot term's MainPID.
  3. --resume-fresh waits for the terminal on the Bus and types only into the term PROCESS whose pid is PID. Before every keystroke batch it reads term.panes (term-core 0.8.1 adds pid= to each pane line), and the pane it types into must come from a line naming PID; the instance sent with the keys is that same line's, so term.type's own instance check refuses the keys in any other term process. A genuine term that outlived the restart reports its own pid and gets nothing: the worker fails closed with the recovery command. The pid is SELF-REPORTED by whatever holds the Bus name term. A program running in a pane cannot set it (pane lines carry no free text), but a process that registers term itself could claim any pid. The real fix is a broker-attested owner pid (noded recording the registering connection's SO_PEERCRED), filed as a noded TODO. For each session it opens a pane: the boot tab's for the first session, a new tab for each later one. It then sends a term.type with {pane, instance, text, request_id} of cd <cwd> && claude [--dangerously-skip-permissions] --resume <id>.
    • Sessions resume by id, because claude -c reopens the most recent conversation of a directory, and two sessions in one directory would both reopen the same one.
    • The request_ids are stable (keyed on the state file and the session) and every resumed id is appended to <state>.done. A rerun after a lost reply replays; it never types the same keys twice.
    • A directory or id that would need shell quoting, or a flags value other than empty or --dangerously-skip-permissions, is not typed. The worker logs it for a by-hand resume.

The worker logs to journalctl -u cosmix-session-restart. Its first line names the recovery command:

mix /opt/cosmix/share/desktop/session-resume.mix --resume-only STATE

Run it as the desktop user. It skips <state>.done and opens a new tab for each remaining session.

Chords

Global chords bind to Quoin's shell.session.confirm {action} (through inputd), never to the session verbs, so a keypress only ever opens the question. The chords are Ctrl+Alt+Backspace (restart) and Ctrl+Alt+End (leave).

Ctrl+Alt+Delete is deliberately NOT bound. It is the kernel's reboot key, and holding a VT does not stop it:

  • seatd and logind put the desktop's VT in keyboard mode K_OFF.
  • The kernel still handles KT_SPEC keys in that mode (drivers/tty/vt/keyboard.c, kbd_keycode(): the early return is (raw_mode || kbd->kbdmode == VC_OFF) && type != KT_SPEC && type != KT_SHIFT).
  • Ctrl+Alt+Delete's Boot keysym is KT_SPEC: ctrl_alt_del() sends SIGINT to PID 1, which starts ctrl-alt-del.target, an alias of reboot.target.

So Ctrl+Alt+Delete reboots the machine, while the desktop is running just as on a text console, whenever the kernel sees the keystroke. The kernel sees it unless inputd holds an exclusive grab on the keyboard and swallows the stroke as a bound row. A binding would make it safe only while inputd runs and grabs, which is why the leave chord is Ctrl+Alt+End instead. ctrl-alt-del.target is left as it is.

Settings (environment)

variabledefaultmeaning
COSMIX_SESSION_USER(required for restart)the desktop user whose sessions are resumed; never guessed
COSMIX_SESSION_HOMEfrom getent passwdthat user's home
COSMIX_DESKTOP_UNITcosmix-desktop.servicethe unit a restart restarts
COSMIX_BOOT_TERM_UNITcosmix-boot-term.servicethe boot terminal that must come back as a new process
COSMIX_SESSION_TERM_SERVICEtermthe terminal's Bus service
COSMIX_LEAVE_VT1the VT leave switches to (1–63)
COSMIX_SESSION_WORKER_UNITcosmix-session-restartthe transient unit's name (a .service suffix is dropped)
COSMIX_SESSION_STATE_DIR/var/lib/cosmix/session-resumewhere state files live (root-owned, checked before every write)
COSMIX_SESSION_TERM_WAIT90seconds the worker waits for the new boot term on the Bus
COSMIX_SESSION_DRY_RUNunset1: every request is a dry run

Install

The citizen needs lib/session.mix and session-resume.mix beside it:

install -D -m 0644 src/desktop/scripts/session-control.mix /opt/cosmix/share/desktop/session-control.mix
install -D -m 0644 src/desktop/scripts/session-resume.mix /opt/cosmix/share/desktop/session-resume.mix
install -D -m 0644 src/desktop/scripts/lib/session.mix /opt/cosmix/share/desktop/lib/session.mix

Copy src/desktop/scripts/cosmix-session-control.service to /etc/systemd/system/ and set COSMIX_SESSION_USER. Then run:

systemctl daemon-reload
systemctl enable --now cosmix-session-control.service

Check it with a dry run before trusting it with the live desktop.

Tests

The tests never restart or switch anything:

  • mix src/desktop/scripts/tests/session-test.mix: request handling, plans, session capture and resume lines, using pure functions against fixtures.
  • COSMIX=$PWD mix src/desktop/scripts/tests/session-bus-test.mix: the production citizen over a private broker in forced dry-run mode.
  • COSMIX=$PWD mix src/desktop/scripts/tests/session-resume-test.mix: the worker against a fake term over a private broker. A term with the wrong pid gets no keys and the worker fails closed; the right one gets the resume line.