Linux port: theme-aware TUI, switch preflight, session control

Four changes to the POSIX build, found while getting it working on Omarchy.

Colour follows the desktop theme. The sixteen ANSI slots carry no guarantee
about relative brightness and monochrome themes exploit that: under Omarchy's
Solitude, slot 36 (headings) resolves to #707070 and slot 31 (FAIL) to #565d60,
which against #cacccc body text on a #101315 ground is 3.8:1 and 2.8:1 where the
body text is 11.6:1. Headings rendered as fine print and errors as the quietest
thing on screen. The palette is now derived from the theme's own colors.toml,
with each role measured against the background it will actually be drawn on and
lifted toward the foreground when it falls short - hue kept where the theme has
any, weight substituted where it does not. Headings go 3.8:1 -> 9.4:1 and FAIL
2.8:1 -> 5.2:1. Falls back to the ANSI slots off Omarchy, with the two roles the
slots get wrong corrected.

health.json was only ever written by a switch, so a fresh install had none at
all and any reader had to guess. It is now refreshed by `status` and seeded at
install, and `claude-mode health` forces it. The installer also copies VERSION,
which cm_version() has always read and nothing ever wrote - every health.json
until now reported 0.0.0.

Preflight, because a switch that cannot work does not fail loudly: it succeeds,
and every session started afterwards breaks in a way that points at Claude Code
rather than at here. Keys, the helper, the preset's provider and the server are
all checked before the write. LM Studio is the sharp case - its token is an
inline placeholder, so nothing about the switch needs the server to exist.

Session control, because Claude Code reads settings.json once at startup: a
switch leaves running sessions on the old provider until they are restarted, and
one mid-request can lose that turn outright. `sessions` lists them, `--stop` and
`--restart` act on them behind a confirmation, `--dry-run` shows the plan.

Sessions are found through /proc/<pid>/exe rather than by process name, which
would sweep up every shell that merely mentions claude - including the one this
runs from. Two exclusions: the calling session, and forks of a session. A busy
session spawns children off its own binary that inherit the same exe, and
without filtering those the count climbed and fell with load - it read 2, 5, 11
and 40 for the same two sessions before the parent check went in.

LM Studio is no longer assumed to be on this machine. `preset url` and
`preset auth` move it to a LAN box, a tunnel or a proxy and turn authentication
on, and the probe distinguishes ok / auth / notfound / refused, because "start
the server" and "your key is wrong" are opposite remedies. It is probed wherever
it lives - a sleeping LAN box is exactly as absent as an empty loopback port -
while remote gateways are not, since those being briefly unreachable is the
network's problem and a missing key never fixes itself.
This commit is contained in:
smoido
2026-08-30 21:06:41 +03:00
parent 112068314c
commit da50a3c7e5
3 changed files with 857 additions and 22 deletions
+130
View File
@@ -450,11 +450,139 @@ def cmd_health(argv):
h["staleModelIds"] = entries(i for i in ids if is_anthropic_model(i))
h["taggedModelIds"] = tagged
# The switchable catalogue, so a reader (the Omarchy bar widget) can offer
# every preset without re-walking the presets directory itself. Names,
# providers, descriptions and model maps only - the same shape already
# published for the active preset, and no key material rides along.
catalogue = []
pdir = os.path.join(root, "presets")
if os.path.isdir(pdir):
for fname in sorted(os.listdir(pdir)):
if not fname.endswith(".json"):
continue
pp = load(os.path.join(pdir, fname), {})
if not isinstance(pp, dict):
continue
pauth = pp.get("auth") or {}
pmode = pauth.get("mode", "vault")
catalogue.append({
"name": fname[:-5],
"provider": pp.get("provider", "openrouter"),
"description": pp.get("description", ""),
"contextTokens": int(pp["contextTokens"]) if pp.get("contextTokens") else None,
"models": {t: pp.get("models", {}).get(t, "")
for t in TIERS if pp.get("models", {}).get(t)},
# Enough for a reader to render an editing form without opening
# the preset file: where the server is, and whether it is set to
# send a real credential. The credential itself never appears -
# only which named slot it would come from.
"baseUrl": pp.get("baseUrl", ""),
"authMode": pmode,
"keyRef": pauth.get("keyRef", "") if pmode == "vault" else "",
})
h["presets"] = catalogue
save(os.path.join(root, "health.json"), h)
def cmd_preflight_json(argv):
"""Emit a preflight verdict as JSON.
usage: preflight-json <ok|blocked> <mode> <preset> <code> <title> <detail>
<remedy> <remedyKind> <keyRef> <baseUrl>
The shell side does the checking; this exists so the strings reach a reader
correctly quoted rather than through hand-rolled escaping in bash.
"""
(status, mode, preset, code, title, detail,
remedy, kind, key_ref, base_url) = (argv + [""] * 10)[:10]
out = {
"ok": status == "ok",
"mode": mode,
"preset": preset,
"code": code or ("ok" if status == "ok" else "blocked"),
"title": title,
"detail": detail,
"remedy": remedy,
"remedyKind": kind,
"keyRef": key_ref,
"baseUrl": base_url,
}
print(json.dumps(out))
def cmd_sessions_json(argv):
"""Convert the session TSV on stdin to JSON.
Columns, in order: pid, ppid, tty, busy, cwd, self, parentCmd. `busy` is a
sampled-CPU heuristic, not a promise, and is reported as such.
"""
rows = []
for line in sys.stdin.read().splitlines():
if not line.strip():
continue
parts = line.split("\t")
parts += [""] * (7 - len(parts))
pid, ppid, tty, busy, cwd, is_self, pcmd = parts[:7]
try:
pid_i = int(pid)
except ValueError:
continue
rows.append({
"pid": pid_i,
"ppid": int(ppid) if ppid.isdigit() else 0,
"tty": tty,
"busy": busy == "yes",
"cwd": cwd,
"self": is_self == "yes",
"parentCmd": pcmd,
})
print(json.dumps({"count": len(rows), "busy": sum(1 for r in rows if r["busy"]),
"sessions": rows}))
def cmd_set_url(argv):
"""set-url <preset> <baseUrl>
LM Studio is not necessarily on this machine. It can be another box on the
LAN, or something reached through a tunnel or a reverse proxy, so the base
URL is editable rather than fixed at the loopback address it ships with.
"""
path, url = argv[0], argv[1].strip().rstrip("/")
if not re.match(r"^https?://[^\s/]+", url):
raise SystemExit("base URL must start with http:// or https://")
p = load(path)
p["baseUrl"] = url
save(path, p)
print(url)
def cmd_set_auth(argv):
"""set-auth <preset> none|key [keyRef]
`none` is LM Studio's out-of-the-box state: it accepts any token, so the
literal placeholder is written inline and is explicitly not a secret. `key`
is for a server with authentication switched on, where the token is a real
credential and belongs in the vault like every other one - settings.json
never sees it either way.
"""
path, mode = argv[0], argv[1]
key_ref = argv[2] if len(argv) > 2 and argv[2] else "lmstudio"
p = load(path)
if mode == "none":
p["auth"] = {"mode": "literal", "token": "lmstudio"}
elif mode == "key":
p["auth"] = {"mode": "vault", "keyRef": key_ref}
else:
raise SystemExit("auth mode must be 'none' or 'key'")
save(path, p)
print(json.dumps(p["auth"]))
COMMANDS = {
"health": cmd_health,
"preflight-json": cmd_preflight_json,
"sessions-json": cmd_sessions_json,
"stale-models": cmd_stale_models,
"strip-tags": cmd_strip_tags,
"or-models": cmd_or_models,
@@ -463,6 +591,8 @@ COMMANDS = {
"summary": cmd_summary,
"models": cmd_models,
"set-tier": cmd_set_tier,
"set-url": cmd_set_url,
"set-auth": cmd_set_auth,
"scaffold": cmd_scaffold,
"get": cmd_get,
"presets": cmd_presets,