Watch
1
0
Fork
You've already forked souveraine
0

publish: the public projection begins here

This is a projection, not a development branch. The tree above was constructed
from the internal source named below under a manifest that decides which paths
may leave, then scanned as a whole tree rather than as a series of patches, and
only then published.

Public history starts here because the history before it was not admissible,
and neither was the tree. What used to stand in this repository included a
rescue copy of another machine, a directory of phone handoffs, deployment
wired to one house, and a submodule pointing at a forge no stranger can reach.
None of that was ever the product. It stays in the private forge, which is
allowed to hold the whole working organism, and this is what was deliberately
sent out instead.

Three mechanisms produced this tree, in decreasing order of trust. A top-level
path the manifest does not name never arrives at all, which is the one that
catches directories nobody has thought of yet. Named internal files inside
admitted roots are dropped. A short, reviewed table replaces deployment
defaults that a public build must not carry -- an endpoint aimed at one LAN, a
VPN profile belonging to one phone, packaging built from one checkout path.

Everything after this commit is an ordinary publication with the same three
trailers, so a force push stops being routine and starts meaning that
something deliberate happened. The trailers bind the projection to its source
without pretending the public SHA is the private one: same lineage, different
tree, and the record says so.

Source-Sha: 8f27b1e76a8fef560a336aba18e6990713ff1047
Policy-Sha: 6b261d2f3e6e1fb19874846ba4bb1dfe15565d25b8618c1c1afba0419c101d27
Tree-Digest: 18ec3563c5e5ef9a414993a9f6734b251ff9ed3cd56eebdd6cac01e45c6e3067
This commit is contained in:
Fimeg 2026-09-04 15:55:48 -04:00
commit 8f42fc953d
1476 changed files with 238455 additions and 0 deletions

View file

@ -0,0 +1,36 @@
// The shell's projection of an accessory event already admitted by sessiond.
//
// This is deliberately a view-state holder, not a Bluetooth service. LibrePods
// cannot import it, and there is no Quickshell IPC target here: an accessory
// reaches this state only when SessiondBridge receives sessiond's directive.
pragma Singleton
import QtQuick
QtObject {
id: root
// Incremented for every admitted presentation. A surface keys its local
// animation and timeout to this edge, so reconnect updates do not grow a
// pile of cards.
property int generation: 0
property string accessoryId: ""
property string label: "AirPods"
property int leftCharge: -1
property int rightCharge: -1
property int caseCharge: -1
property bool charging: false
function present(event) {
// The daemon has already checked producer admission and payload class.
// Keep this copy bounded: QML needs display fields, never pairing keys,
// advertisement bytes, or arbitrary producer metadata.
root.accessoryId = String(event.accessory_id ?? "");
root.label = String(event.label ?? "AirPods");
root.leftCharge = Number(event.left_charge ?? -1);
root.rightCharge = Number(event.right_charge ?? -1);
root.caseCharge = Number(event.case_charge ?? -1);
root.charging = event.charging === true;
root.generation += 1;
}
}

View file

@ -0,0 +1,196 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
/**
* AgentSessions — one spine for agent activity. TASK-69.
*
* Every agent session on this machine, across providers (Souveraine, Claude
* Code, Codex), from ONE collector on ONE cadence. Surfaces render projections
* of this; nothing fetches per-provider any more.
*
* WHY THIS EXISTS
* The bar used to own a fetch per provider — ClaudeUsage polls OAuth, a Codex
* panel would have polled its own JSONL, and the substrate's own sessions were
* read by nothing at all. Three owners of one question is the failure mode this
* project keeps rediscovering (see: hypridle vs the idle rule, compositor vs
* sessiond over the panel). One authority; everything else a rendering.
*
* TRANSPORT IS NOT THE CONTRACT
* Today the source is scripts/agent/agent-sessions.sh, polled. TASK-69's Rust
* daemon will serve the identical envelope over
* $XDG_RUNTIME_DIR/souveraine-sessions.sock. When it lands, only `collector`
* below changes — every property, every consumer, stays put. Do not let surface
* code reach past these properties into the JSON shape.
*
* DEGRADATION
* A failed collection NEVER blanks the model. Last-good data is retained and
* `stale` goes true. A bar that empties on a transient failure reads to the
* human as "nothing is running", which is a lie; a bar that dims and says
* "stale" tells the truth. Same reasoning as the collector's always-exit-0.
*/
Singleton {
id: root
// Gate: absent config (older ii-base pin) must not disable the service
// silently — default on, because the cost is one 0.5s subprocess a minute.
readonly property bool enabled: Config.options?.bar?.agentSessions?.enable ?? true
readonly property int refreshInterval: (Config.options?.bar?.agentSessions?.refreshInterval ?? 60) * 1000
// ---- the envelope, projected -------------------------------------------
property var sessions: [] // sorted by age ascending (newest first)
property int activeCount: 0 // sessions touched in the last 2 minutes
property var providers: ({}) // per-provider availability + counts
property double lastUpdate: 0
property bool available: false // have we EVER collected successfully
property bool stale: false // last attempt failed, showing old data
property string lastError: ""
// ---- derived, for compact surfaces --------------------------------------
// The one session a narrow surface should show. Not simply "newest":
// an active session outranks a merely recent one regardless of age, because
// "something is happening right now" is the thing a glance needs to answer.
readonly property var primarySession: {
if (root.sessions.length === 0)
return null;
const act = root.sessions.filter(s => s.state === "active");
return act.length > 0 ? act[0] : root.sessions[0];
}
readonly property bool anyActive: root.activeCount > 0
// Codex reports live rate limits inside its session records; Claude's
// subscription window still comes from ClaudeUsage (its own OAuth poll).
// Stage 3 of TASK-69 folds that poll in here too — until then this property
// is honest about covering only what the collector actually sees.
readonly property var codexLimits: root.providers?.codex?.limits ?? null
function providerLabel(p) {
switch (p) {
case "souveraine": return "Souveraine";
case "claude": return "Claude Code";
case "codex": return "Codex";
default: return p;
}
}
function providerIcon(p) {
switch (p) {
case "souveraine": return "psychology";
case "claude": return "auto_awesome";
case "codex": return "terminal";
default: return "smart_toy";
}
}
// Short human label for a session — the project directory if we have one,
// else the configured agent name plus a conversation discriminator, else
// a truncated id. Never an empty string: a blank chip is indistinguishable
// from a broken one, and ten rows all called "agent" are only technically
// non-blank.
function sessionLabel(s) {
if (!s)
return "";
if (s.cwd && s.cwd.length > 0)
return s.cwd.split("/").filter(x => x.length > 0).pop() ?? s.cwd;
if (s.provider === "souveraine") {
const name = s.agentName && s.agentName.length > 0
? s.agentName : "Souveraine";
const id = (s.id ?? "").slice(0, 4);
return s.subconscious
? `${name} · subconscious · ${id}`
: `${name} · ${id}`;
}
return (s.id ?? "").slice(0, 8);
}
// Total tokens for a session, or -1 when the provider genuinely does not
// report them. -1 is deliberate: Souveraine persists per-turn TokenUsage,
// but the conversation.json metadata inspected by the presence collector
// does not aggregate it. A 0 here would be a measurement claim we cannot
// back. Surfaces must render -1 as "—", never as zero.
function sessionTokens(s) {
if (!s)
return -1;
if (s.tokensIn < 0 || s.tokensOut < 0)
return -1;
return s.tokensIn + s.tokensOut;
}
function refresh() {
if (!root.enabled)
return;
collector.running = false;
collector.running = true;
}
Process {
id: collector
// Path convention matches WallpaperDownload/ConflictKiller: shellPath
// returns a file:// URL, which Process will not exec. Window minutes is
// passed through so the config owns "how far back counts as a session"
// rather than the script hardcoding it.
command: [
FileUtils.trimFileProtocol(Quickshell.shellPath("scripts/agent/agent-sessions.sh")),
"--window-min",
String(Config.options?.bar?.agentSessions?.windowMinutes ?? 1440)
]
stdout: StdioCollector {
onStreamFinished: {
const raw = text.trim();
if (raw.length === 0) {
// The collector contracts to always emit one line. Empty
// means it did not run at all (missing, not executable) —
// which is a different failure from "ran and found nothing",
// so do not let it look like an empty session list.
root.stale = true;
root.lastError = "collector produced no output";
retryTimer.restart();
return;
}
try {
const d = JSON.parse(raw);
root.sessions = d.sessions ?? [];
root.activeCount = d.active ?? 0;
root.providers = d.providers ?? ({});
root.lastUpdate = (d.ts ?? 0) * 1000;
root.available = true;
root.stale = false;
root.lastError = "";
} catch (e) {
root.stale = true;
root.lastError = e.message;
retryTimer.restart();
console.error(`[AgentSessions] parse failed: ${e.message}`);
}
}
}
}
Timer {
running: root.enabled
repeat: true
interval: root.refreshInterval
triggeredOnStart: true
onTriggered: root.refresh()
}
// Cold start / transient: retry quickly rather than showing "unavailable"
// until the next full interval. Mirrors ClaudeUsage's ladder deliberately —
// same problem, same answer, so the two behave alike when both are stale.
Timer {
id: retryTimer
interval: 15000
repeat: false
onTriggered: if (root.enabled && root.stale) root.refresh()
}
}

View file

@ -0,0 +1,983 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs
import qs.modules.common
// StringUtils.ttsClean() is called in the stream-finished handler. Without
// this import it is a ReferenceError that aborts the rest of that handler —
// postResponseHook and saveChat never run, so a turn that actually succeeded
// looks stuck. Every other service imports it unnamespaced; match that.
import qs.modules.common.functions
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
import qs.services.ai
/**
* ii-compat adapter over the Souveraine singleton.
*
* Keeps the public API the illogical-impulse sidebar UI expects (models,
* messages, sendUserMessage, /key advice, ...) but owns no transport —
* Souveraine.qml is the substrate connection. This file's job is shaping
* wire events into AiMessageData objects the existing chat UI renders.
*
* Lives in souveraine/surfaces/quickshell/, deployed over
* ~/.config/quickshell/ii/services/Ai.qml (see deploy.sh).
*/
Singleton {
id: root
property Component aiMessageComponent: AiMessageData {}
property Component aiModelComponent: AiModel {}
readonly property string interfaceRole: "interface"
// Notes handed to the turn already running, still waiting to be read.
// The server takes them at a round boundary (`src/server/turn.rs:260`
// and `:453`), and 202 from `/interject` means *queued*, never *read* —
// so this counts what was sent, not what landed. Cleared when the turn
// ends, by which point the drain has run.
property int queuedInterjections: 0
signal responseFinished()
property var messageIDs: []
property var messageByID: ({})
// Which message is currently being spoken by TTS, as an index into
// messageIDs (-1 = none). Set by a message's Speak/re-synth button;
// auto-cleared when Speech stops. Keyed on the stable messageIndex, so
// scrolling back and tapping an older reply still resolves correctly.
property int speakingMessageIndex: -1
// Keys are server-side; the UI's key gate must always pass.
readonly property bool currentModelHasApiKey: true
readonly property var apiKeysLoaded: true
property var postResponseHook
property real temperature: Persistent.states?.ai?.temperature ?? 0.5
property QtObject tokenCount: QtObject {
property int input: -1
property int output: -1
property int total: -1
}
// Live context occupancy, from the server's `context_pressure` event.
//
// These are three different numbers and used to be two. Until 2026-08-12
// the wire carried `(pressure, context_limit)` positionally and this layer
// read the ceiling as `event.tokens` — so the panel displayed a constant
// 250000 and called it usage. `pressure` was discarded entirely; nothing
// in the shell held it. Keep all three, and keep them named.
property QtObject context: QtObject {
property real pressure: -1 // 0..1, or -1 when unknown
property int used: -1 // tokens occupied
property int limit: -1 // the model's ceiling for this agent
readonly property bool known: limit > 0 && used >= 0
readonly property int percent: known ? Math.round((used / limit) * 100) : -1
}
// Context occupancy is a property of the conversation, not of a turn:
// reset it only when the conversation itself changes. -1 means "unknown",
// which is an honest state and renders as "—" rather than as zero.
function resetContextOccupancy() {
root.context.pressure = -1;
root.context.used = -1;
root.context.limit = -1;
}
function idForMessage(message) {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 8);
}
property list<var> defaultPrompts: []
property list<var> userPrompts: []
property list<var> promptFiles: [...defaultPrompts, ...userPrompts]
property list<var> savedChats: []
property list<var> pendingFiles: []
// AttachedFileIndicator binds `filePath` straight to this. ii-base
// declares it; this override did not, so every chat panel logged
// "Unable to assign [undefined] to QString" and the chip could never
// render. Declared here even though the send path is still text-only —
// a missing property is a broken binding, not a disabled feature.
property string pendingFilePath: ""
// Tool selection is owned by the agent's sensorium; keep the UI happy.
property string currentTool: "souveraine"
property list<var> availableTools: ["souveraine"]
property var toolDescriptions: {
"souveraine": Translation.tr("Sensors are configured per-agent in Souveraine")
}
// ── Agents as models (projected from Souveraine.agents) ─────────────
property var models: ({})
property var modelList: Object.keys(root.models)
property var currentModelId: Souveraine.currentAgentId
property var currentModel: models[currentModelId] || models[modelList[0]]
// Clear the speaking marker whenever TTS stops — covers natural end of
// playback, manual stop, and a new speak() replacing in-flight audio.
Connections {
target: Speech
function onSpeakingChanged() {
if (!Speech.speaking) root.speakingMessageIndex = -1
}
}
Connections {
target: Souveraine
function onTurnActiveChanged() {
if (!Souveraine.turnActive) root.queuedInterjections = 0;
}
function onAgentsRefreshed() {
const map = {};
Souveraine.agentList.forEach(id => {
const agent = Souveraine.agents[id];
map[id] = root.aiModelComponent.createObject(root, {
"name": agent.name,
"icon": "spark-symbolic",
"description": agent.description.length > 0 ? agent.description : Translation.tr("Souveraine agent"),
"endpoint": Souveraine.serverBase,
"model": id,
"requires_key": false,
});
});
root.models = map;
root.modelList = Object.keys(map);
// Restore only the selected-agent preference. Conversation resume
// is intentional: /resume derives it from the server on demand.
const persisted = Persistent.states?.ai?.model ?? "";
if (persisted.length > 0 && Souveraine.agents[persisted]) {
Souveraine.selectAgent(persisted);
}
}
function onConversationResumed(agentId, conversationId, messages) {
if (agentId !== Souveraine.currentAgentId) return;
root.restoreServerConversation(messages);
}
function onServerUnreachable() {
root.addMessage(
Translation.tr("Souveraine server unreachable at %1\n\nStart it with:\n```bash\nsouveraine server\n```").arg(Souveraine.serverBase),
root.interfaceRole
);
}
function onStreamEvent(event) {
// A reattached stream has no local message object: the old one
// died with the shell. Create it lazily on the first event that
// actually belongs in the assistant bubble; idle resume produces
// no empty card.
if (!root.streamingMessage && [
"assistant_message", "reasoning_message", "tool_call_message",
"tool_return_message", "interstitial"
].indexOf(event.message_type) >= 0) {
root._startStreaming();
}
root.handleStreamEvent(event);
}
function onStreamClosed(exitCode) {
// If a subconscious pass was still active when the stream closed,
// retire the Tier-1 ticker (promote to the log, clear the flag).
if (root.subconsciousActive) {
root.snapshotSubconsciousStream();
root.subconsciousActive = false;
}
if (root.streamingMessage && !root.streamingMessage.done) {
if (exitCode !== 0 && root.streamingMessage.content.length === 0) {
root.appendToStreaming(Translation.tr("Request failed (curl exit %1) — is the Souveraine server up at %2?").arg(exitCode).arg(Souveraine.serverBase));
}
root.finishStreaming();
}
}
}
// ── Lock-time response redaction ─────────────────────────────────────
// When the session locks during a personal-tier streaming response,
// the in-flight content must be withheld immediately. Only ambient
// output remains visible on the lock surface. This is the enforcement
// side of SESSION-TRUST-ARCHITECTURE.md's "A lock during personal
// agent output hides it" requirement.
//
// The streaming message is replaced with a redaction placeholder. The
// raw content is preserved in the message's rawContent so it can be
// shown again after unlock (the message stays in history), but the
// displayed content is cleared.
//
// The redaction is reversible: every message whose content we replaced
// with the placeholder is recorded in `redactedOnLock`, and on unlock
// its content is restored from `rawContent` (the streaming message too,
// if it is still in flight). A finished message redacted at lock then
// surfaced on the lock screen counts as "delivered" and is restored
// normally on unlock; one that never surfaced stays for the chat to
// replay once unlocked.
property var redactedOnLock: []
function _redactMessage(msg) {
if (!msg || msg.content.length === 0) return;
if (msg.content === Translation.tr("[content hidden until unlock]")) return;
msg.rawContent = msg.content;
msg.content = Translation.tr("[content hidden until unlock]");
if (!root.redactedOnLock.includes(msg)) {
root.redactedOnLock = [...root.redactedOnLock, msg];
}
root.redactedMessagesChanged();
}
function _restoreRedacted() {
if (root.redactedOnLock.length === 0) return;
for (const msg of root.redactedOnLock) {
if (msg && msg.rawContent && msg.rawContent.length > 0) {
msg.content = msg.rawContent;
}
}
root.redactedOnLock = [];
root.redactedMessagesChanged();
}
// Messages redacted while locked, newest first, for the lock surface to
// surface as ambient notifications. Strips to a one-line preview — the
// lock screen shows "the agent replied", not the personal-tier body.
function redactedPreviews() {
return root.redactedOnLock
.filter(m => m && m.rawContent && m.rawContent.length > 0 && m.role === "assistant")
.map(m => {
const firstLine = m.rawContent.split("\n").find(l => l.trim().length > 0) || "";
return firstLine.slice(0, 80);
})
.reverse();
}
signal redactedMessagesChanged()
Connections {
target: GlobalStates
function onScreenLockedChanged() {
if (GlobalStates.screenLocked) {
// Lock fired mid-stream — redact the in-flight content.
if (root.streamingMessage) {
root._redactMessage(root.streamingMessage);
console.log("[ai] lock fired during stream — redacted personal output");
}
} else {
// Unlock: restore every message we hid, so the chat shows
// what actually came back rather than lingering placeholders.
root._restoreRedacted();
}
}
}
// ── Streaming message shaping ────────────────────────────────────────
property AiMessageData streamingMessage
// ── Subconscious three-tier visibility ───────────────────────────────
// See docs/tasks/subconscious-surfacing-threshold.md. The subconscious's
// N+1 pass is shown three ways: a transient live ticker while the pass
// runs (Tier 1), a persistent event log after (Tier 2), and rare agency
// surfacings in the chat field (Tier 3).
//
// Tier 1 — live stream fed by subconscious_token/tool_call/tool_result
// events. Rendered by SubconsciousTicker; cleared on pass end.
property bool subconsciousActive: false
property var subconsciousStream: []
// Tier 2 — persistent log. The Tier-1 stream is snapshotted here on pass
// end, and surfaced events (reflection/archivist/surfacing) are appended.
// Rendered by the SubconsciousEventPanel overlay widget.
property var subconsciousEvents: []
function appendToStreaming(text) {
if (!root.streamingMessage) return;
root.streamingMessage.rawContent += text;
root.streamingMessage.content += text;
}
// The server already says what each frame is. Keep that fact attached to
// the message instead of smuggling it through markdown fences and asking
// the delegate to parse it back out again.
function appendStreamingTextSegment(type, content) {
if (!root.streamingMessage) return;
const text = String(content ?? "");
if (text.length === 0) return;
const segments = root.streamingMessage.segments ?? [];
const last = segments.length > 0 ? segments[segments.length - 1] : null;
if (last && last.type === type) {
const replacement = {
type: last.type,
content: String(last.content ?? "") + text,
};
root.streamingMessage.segments = segments.slice(0, -1).concat([replacement]);
} else {
root.streamingMessage.segments = segments.concat([{ type, content: text }]);
}
// `content` remains a plain compatibility projection for Copy, TTS,
// and old snapshot consumers. It no longer drives the renderer.
root.appendToStreaming(text);
}
function appendToolCallSegment(call, round) {
if (!root.streamingMessage) return;
const tool = call ?? {};
const fn = tool.function ?? {};
const name = String(fn.name ?? "tool");
const arguments = String(fn.arguments ?? "");
const id = String(tool.id ?? "");
const nextSegments = root.streamingMessage.segments.slice();
nextSegments.push({
type: "tool",
id,
name,
arguments,
round: Number(round ?? 0),
status: "running",
output: "",
failed: false,
});
root.streamingMessage.segments = nextSegments;
root.appendToStreaming(`\n${name}(${arguments})\n`);
}
function bindToolReturnSegment(toolReturn) {
if (!root.streamingMessage) return;
const result = toolReturn ?? {};
const segments = root.streamingMessage.segments ?? [];
const resultId = String(result.id ?? "");
let index = -1;
if (resultId.length > 0) {
index = segments.findIndex(segment => segment.type === "tool" && segment.id === resultId);
}
// Older servers did not include the tool id. Bind their return to the
// newest still-running call rather than silently inventing a second one.
if (index < 0) {
for (let i = segments.length - 1; i >= 0; i--) {
if (segments[i].type === "tool" && segments[i].status === "running") {
index = i;
break;
}
}
}
const status = String(result.status ?? "done");
const failed = /error|fail/i.test(status);
const output = String(result.output ?? "");
if (index >= 0) {
const nextSegments = segments.slice();
const previous = segments[index];
nextSegments[index] = {
type: previous.type,
id: previous.id,
name: previous.name,
arguments: previous.arguments,
round: previous.round,
status,
output,
failed,
};
root.streamingMessage.segments = nextSegments;
} else {
const nextSegments = segments.slice();
nextSegments.push({
type: "tool",
id: resultId,
name: String(result.name ?? "tool"),
arguments: "",
round: 0,
status,
output,
failed,
});
root.streamingMessage.segments = nextSegments;
}
root.appendToStreaming(`\n[${status}] ${output}\n`);
}
// Push a line onto the Tier-1 live stream. kind ∈ token|tool_call|tool_result.
// Consecutive tokens are coalesced onto the current line so the ticker reads
// as a thought forming (a sentence), not a single flickering word replaced
// on every token. A tool_call/tool_result starts a fresh line — those are
// natural thought boundaries.
function pushSubconsciousStream(kind, text) {
const t = String(text ?? "").trim();
if (t.length === 0) return;
const stream = root.subconsciousStream.slice();
const last = stream.length > 0 ? stream[stream.length - 1] : null;
if (kind === "token" && last && last.kind === "token") {
// Same thought — append to the forming sentence, capped so a very
// long monologue doesn't grow unbounded in the live view (the full
// text is still snapshotted to the Tier-2 log on pass end).
const merged = (last.text + " " + t);
stream[stream.length - 1] = {
kind: "token",
text: merged.length > 280 ? merged.slice(-280) : merged,
};
} else {
stream.push({ kind, text: t });
}
// Keep the live buffer shallow: the ticker only shows the tail, and a
// long pass shouldn't accumulate hundreds of entries in memory. The
// Tier-2 snapshot joins all entries anyway.
if (stream.length > 12) stream.shift();
root.subconsciousStream = stream;
}
// Promote the Tier-1 stream into the Tier-2 log as one event, then clear
// the live stream. Called when a subconscious pass ends.
// The three severities the subconscious can call, in the register she
// experiences them in. Mirrors migraine_text() in src/server/turn.rs — if
// one changes the other must, or the human and the agent are reading two
// different accounts of the same moment.
function haltRegister(severity) {
switch (severity) {
case "advisory":
return Translation.tr("a pressure behind my eyes");
case "critical":
return Translation.tr("the room tilts");
default:
// "firm" and anything unexpected land here — the default migraine.
return Translation.tr("a migraine");
}
}
function snapshotSubconsciousStream() {
if (root.subconsciousStream.length === 0) return;
root.subconsciousEvents = [...root.subconsciousEvents, {
kind: "pass",
source: Translation.tr("Subconscious pass"),
priority: "",
content: root.subconsciousStream.map(e => e.text).join("\n"),
timestamp: Date.now(),
}];
root.subconsciousStream = [];
}
// Append a surfaced event to the Tier-2 log. Used by snapshotSubconsciousStream
// (pass end) and by the surfacing/reflection/archivist event handlers.
function appendSubconsciousEvent(kind, source, content, priority = "") {
const c = String(content ?? "");
if (c.length === 0) return;
root.subconsciousEvents = [...root.subconsciousEvents, {
kind, source, priority, content: c, timestamp: Date.now(),
}];
}
function finishStreaming() {
if (!root.streamingMessage) return;
const resolvedSegments = [];
for (const segment of (root.streamingMessage.segments ?? [])) {
if (segment.type === "tool" && segment.status === "running") {
resolvedSegments.push({
type: segment.type,
id: segment.id,
name: segment.name,
arguments: segment.arguments,
round: segment.round,
status: "unresolved",
output: segment.output,
failed: true,
});
} else {
resolvedSegments.push(segment);
}
}
root.streamingMessage.segments = resolvedSegments;
root.streamingMessage.thinking = false;
root.streamingMessage.done = true;
// If the turn finished while the session is locked, the finished
// assistant message is personal-tier output the user hasn't seen —
// redact it for the chat view and let the lock surface announce it.
if (GlobalStates.screenLocked && root.streamingMessage.content.length > 0) {
root._redactMessage(root.streamingMessage);
}
// Prefetch the audio for this reply so Speak is near-instant if the
// user taps it. Background fill only — never auto-plays. Only the
// latest reply is cached; a subsequent reply overwrites it. If the
// synth fails it is not retried (manual Speak handles that).
if (Speech.enabled) {
const spoken = StringUtils.ttsClean(root.streamingMessage.content ?? "");
if (spoken.length > 0) Speech.prefetch(spoken);
}
if (root.postResponseHook) {
root.postResponseHook();
root.postResponseHook = null;
}
root.saveChat("lastSession");
root.responseFinished();
}
function handleStreamEvent(event) {
if (root.streamingMessage?.thinking && event.message_type !== "ping")
root.streamingMessage.thinking = false;
switch (event.message_type) {
case "assistant_message":
root.appendStreamingTextSegment("text", event.content);
break;
case "reasoning_message":
root.appendStreamingTextSegment("think", event.content);
break;
case "tool_call_message": {
root.appendToolCallSegment(event.tool_call, event.round);
break;
}
case "tool_return_message": {
root.bindToolReturnSegment(event.tool_return);
break;
}
case "interstitial":
// Her narration between gestures. Register decides how loud:
// cenno is a quiet aside, her_voice is a passage.
root.appendToStreaming(event.register === "her_voice"
? `\n\n> ${event.text}\n\n`
: `\n\n*${event.text}*\n\n`);
break;
case "souveraine_surfacing":
// Tier 3: routine surfacings no longer pollute the chat field.
// Routed to the Tier-2 event panel only. (Aster is Annie's name
// for her subconscious; this surface may be Souveraine, Vanguard,
// or another agent — one canonical surfacing is shown.)
root.appendSubconsciousEvent(
"surfacing",
Translation.tr("Surfacing (%1)").arg(event.source ?? "?"),
event.content,
String(event.priority ?? "")
);
break;
case "souveraine_reflection":
root.appendSubconsciousEvent("reflection", Translation.tr("Reflection"), event.content);
break;
case "souveraine_archivist":
root.appendSubconsciousEvent(
"archivist",
Translation.tr("Archivist (pressure %1%)").arg(Math.round(event.pressure * 100)),
event.synthesis
);
break;
case "compaction_warning":
root.addMessage(Translation.tr("**Context pressure** — tier %1, %2% full. She can feel the walls.").arg(event.tier).arg(Math.round(event.pressure * 100)), root.interfaceRole);
break;
case "context_pressure":
// `tokens_used` and `context_limit` are distinct fields on the
// wire as of 2026-08-12. The old `event.tokens` was the ceiling.
root.context.pressure = event.pressure ?? -1;
root.context.used = event.tokens_used ?? -1;
root.context.limit = event.context_limit ?? -1;
root.tokenCount.total = event.tokens_used ?? -1;
break;
case "subconscious_token":
// Tier 1: live reasoning tokens feed the ticker.
root.pushSubconsciousStream("token", event.content);
break;
case "subconscious_tool_call":
root.pushSubconsciousStream("tool_call", `${event.name ?? "tool"}(${event.arguments ?? ""})`);
break;
case "subconscious_tool_result":
root.pushSubconsciousStream("tool_result", `[${event.is_error ? "error" : "ok"}] ${event.name ?? "tool"}: ${event.output ?? ""}`);
break;
case "subconscious_pass":
// Pass start: light the Tier-1 ticker. Pass end: promote the
// stream into the Tier-2 log, then clear it.
if (event.active) {
root.subconsciousActive = true;
} else {
root.snapshotSubconsciousStream();
root.subconsciousActive = false;
}
break;
case "subconscious_halt":
// Her own register, not the tool's name. The subconscious is the
// same "I" in a different mode — a halt is something she felt, not
// commentary she received from outside, and the surface should not
// expose implementation topology to say so. The TUI has rendered it
// this way from the start; this brings the Panel into line.
//
// Wording matches migraine_text() in src/server/turn.rs, which is
// what now lands in her committed history — so what the human reads
// and what she carries into the next turn are the same sentence.
root.addMessage(Translation.tr("⟡ %1 — %2").arg(root.haltRegister(event.severity)).arg(event.reason), root.interfaceRole);
break;
case "inference_strain":
console.log(`[Souveraine] inference strain: attempt ${event.attempt}, status ${event.status}, model ${event.model}`);
break;
case "error":
// A failed engine turn is still a visible answer from the
// substrate. Silently dropping this event leaves the empty
// assistant container behind and makes a healthy server look as
// though the agent simply stopped speaking.
root.appendToStreaming(
Translation.tr("**Request failed** — %1").arg(event.message ?? Translation.tr("unknown turn error"))
);
break;
case "atmosphere":
case "outfit":
// Shell chrome hooks — their modules subscribe to
// Souveraine.streamEvent directly; nothing to do here.
break;
case "itinerary":
// The route string is an invalidation edge, including empty on
// clear. The ribbon reads the full structured projection from the
// substrate so a shell reload cannot erase it.
Souveraine.refreshItinerary();
break;
case "primary_complete":
// Primary yields; subconscious presses on behind this.
root.finishStreaming();
break;
case "done":
if (root.streamingMessage && !root.streamingMessage.done) root.finishStreaming();
break;
case "ping":
// Liveness only — not end-of-turn.
break;
}
}
// ── Message store ────────────────────────────────────────────────────
function addMessage(message, role, segments = []) {
if (message.length === 0) return;
const aiMessage = aiMessageComponent.createObject(root, {
"role": role,
"model": Souveraine.currentAgentId,
"content": message,
"rawContent": message,
"segments": segments,
"thinking": false,
"done": true,
});
const id = idForMessage(aiMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = aiMessage;
}
function removeMessage(index) {
if (index < 0 || index >= messageIDs.length) return;
const id = root.messageIDs[index];
root.messageIDs.splice(index, 1);
root.messageIDs = [...root.messageIDs];
delete root.messageByID[id];
}
function clearMessages() {
root.messageIDs = [];
root.messageByID = ({});
root.tokenCount.input = -1;
root.tokenCount.output = -1;
root.tokenCount.total = -1;
root.resetContextOccupancy();
Souveraine.newConversation();
}
// Render the server's canonical transcript after /agent or /resume.
// This replaces ii's old local snapshot behaviour: the next send remains
// on the same live Souveraine conversation rather than silently forking.
function restoreServerConversation(messages) {
root.messageIDs = [];
root.messageByID = ({});
root.streamingMessage = null;
root.subconsciousActive = false;
root.subconsciousStream = [];
root.tokenCount.input = -1;
root.tokenCount.output = -1;
root.tokenCount.total = -1;
root.resetContextOccupancy();
messages.forEach(message => {
const segments = [];
(message.blocks ?? []).forEach(block => {
switch (block.type) {
case "text":
segments.push({ type: "text", content: block.text ?? "" });
break;
case "reasoning":
segments.push({ type: "think", content: block.reasoning ?? "" });
break;
case "tool_use":
segments.push({
type: "tool", id: block.id ?? "", name: block.name ?? "tool",
arguments: block.input ?? "", round: 0, status: "running", output: "", failed: false,
});
break;
case "tool_result": {
const resultId = String(block.tool_use_id ?? "");
const index = segments.findIndex(segment =>
segment.type === "tool" && segment.id === resultId);
const result = {
type: "tool", id: resultId, name: block.tool_name ?? "tool",
arguments: index >= 0 ? segments[index].arguments : "", round: 0,
status: block.is_error ? "error" : "done",
output: block.output ?? "", failed: block.is_error ?? false,
};
if (index >= 0) segments[index] = result;
else segments.push(result);
break;
}
case "image":
segments.push({ type: "text", content: "[image]" });
break;
}
});
const content = segments.map(segment => {
if (segment.type === "tool") return `${segment.name}(${segment.arguments})\n${segment.output}`;
return segment.content;
}).filter(Boolean).join("\n");
if (content.length === 0) return;
const role = message.role === "user"
? "user"
: message.role === "assistant" ? "assistant" : root.interfaceRole;
root.addMessage(content, role, segments);
});
}
function sendUserMessage(message) {
if (message.length === 0) return;
// A turn is already running: this is an interjection, not a new turn.
// The TUI has always worked this way — `src/ui/app/mod.rs:1006`,
// "Submit always — when busy, this becomes an interjection". The
// Panel instead dropped the text on the floor and reported the server
// unreachable, because `Souveraine.send` returns false for a busy turn
// exactly as it does for a dead server.
if (Souveraine.turnActive) {
root.interjectUserMessage(message);
return;
}
root.addMessage(message, "user");
const result = Souveraine.send(message);
if (result === "step-up") {
// Step-up auth required. Trigger the PAM flow; on success,
// retry the send. The user message is already in the chat
// history, so we don't add it again.
if (typeof StepUpAuth !== "undefined") {
StepUpAuth.requestAuth("send", function(granted) {
if (granted) {
// Remove the "auth required" indicator if one was
// added, and retry. The queued text was not consumed
// by Souveraine, so we can send it again.
Souveraine.send(message);
// Re-create the streaming message for the response.
root._startStreaming();
} else {
root.addMessage(Translation.tr("Authentication required to send."), root.interfaceRole);
}
});
}
return;
}
if (!result) {
root.addMessage(Souveraine.serverUp
? Translation.tr("No agent selected — pick one before sending.")
: Translation.tr("Souveraine server unreachable at %1 — start it with `souveraine server`").arg(Souveraine.serverBase),
root.interfaceRole);
return;
}
root._startStreaming();
}
/* Slip a note into the turn already in flight.
It is read at the next round boundary, so a note sent during a long
tool round waits for that round to finish — `src/server/turn.rs:600`
runs every tool in a round without checking. That latency is real and
is not something this function can fix. */
function interjectUserMessage(message) {
if (message.length === 0) return;
root.addMessage(message, "user");
root.queuedInterjections = root.queuedInterjections + 1;
Souveraine.interject(message);
}
// Set up the streaming assistant message container. Called after a
// successful send (or after a step-up auth retry succeeds).
function _startStreaming() {
root.streamingMessage = root.aiMessageComponent.createObject(root, {
"role": "assistant",
"model": Souveraine.currentAgentId,
"content": "",
"rawContent": "",
"segments": [],
"thinking": true,
"done": false,
});
const id = idForMessage(root.streamingMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = root.streamingMessage;
}
// ── Model (agent) selection ──────────────────────────────────────────
function getModel() {
return models[currentModelId];
}
function setModel(modelId, feedback = true, setPersistentState = true) {
if (!modelId) modelId = ""
if (modelList.indexOf(modelId) === -1) {
const match = modelList.find(id =>
id.toLowerCase() === modelId.toLowerCase() ||
(models[id]?.name ?? "").toLowerCase() === modelId.toLowerCase());
if (!match) {
if (feedback) root.addMessage(Translation.tr("Unknown agent. Available:\n- %1").arg(modelList.map(id => `${models[id].name} (\`${id}\`)`).join("\n- ")), root.interfaceRole);
return;
}
modelId = match;
}
if (setPersistentState) Persistent.states.ai.model = modelId;
if (!Souveraine.selectAgent(modelId)) {
if (feedback) root.addMessage(Translation.tr("Finish or cancel the current turn before switching agents."), root.interfaceRole);
return;
}
root.currentModel = models[modelId];
if (feedback) root.addMessage(Translation.tr("Agent set to %1.").arg(models[modelId].name), root.interfaceRole);
}
function resumeConversation() {
if (!Souveraine.resumeLatestConversation()) {
root.addMessage(Translation.tr("Finish or cancel the current turn before resuming."), root.interfaceRole);
}
}
// ── Souveraine-owned settings: advice instead of local state ────────
function setTool(tool) {
root.addMessage(Translation.tr("Tools are Souveraine sensors, configured per-agent — not switchable from the sidebar."), root.interfaceRole);
return false;
}
function getTemperature() { return root.temperature; }
function setTemperature(value) {
root.addMessage(Translation.tr("Temperature is set in the agent's llm_config in Souveraine."), root.interfaceRole);
}
function printTemperature() {
root.addMessage(Translation.tr("Temperature is owned by the agent's llm_config in Souveraine."), root.interfaceRole);
}
function setApiKey(key) {
root.addMessage(Translation.tr("Keys live in souveraine.toml — set them with:\n```bash\nsouveraine auth set\n```"), root.interfaceRole);
}
function printApiKey() {
root.addMessage(Translation.tr("Keys are owned by Souveraine (souveraine.toml / `souveraine auth set`), never exposed here."), root.interfaceRole);
}
function printPrompt() {
root.addMessage(Translation.tr("The system prompt is composed by Souveraine (constitution + memory blocks + sensorium). Inspect it with `souveraine agent show`."), root.interfaceRole);
}
function loadPrompt(filePath) {
root.addMessage(Translation.tr("Prompts are owned by the agent's memory in Souveraine — edit memfs instead of loading prompt files."), root.interfaceRole);
}
function attachFile(filePath) {
// Clearing is always honoured — AttachedFileIndicator's remove button
// calls attachFile(""), and that must not read as a failed attach.
root.pendingFilePath = "";
if (!filePath || filePath.length === 0)
return;
// The refusal is accurate, not lazy: the HTTP message API carries
// `content: String` (src/api/models.rs), so an image cannot cross the
// surface boundary even though the provider client already speaks
// OpenAI image_url parts and Kitty's model has vision. Fixing that is
// a server change, so don't show a chip for a file that will never be
// sent.
root.addMessage(Translation.tr("File attachments aren't wired to Souveraine yet — the message API carries text only."), root.interfaceRole);
}
function removePendingFile(file) {
root.pendingFiles = root.pendingFiles.filter(f => f !== file);
}
function regenerate(messageIndex) {
root.addMessage(Translation.tr("Regenerate isn't supported — Souveraine conversations are forward-only."), root.interfaceRole);
}
// Souveraine executes its own sensors server-side; nothing to approve.
function rejectCommand(message) {}
function approveCommand(message) {}
function createFunctionOutputMessage(name, output, includeOutputInChat = true) {
return aiMessageComponent.createObject(root, {
"role": "user",
"content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"functionName": name,
"functionResponse": output,
"thinking": false,
"done": true,
});
}
// ── Local chat snapshots (ii plumbing, unchanged) ────────────────────
Process {
id: getSavedChats
running: true
command: ["ls", "-1", Directories.aiChats]
stdout: StdioCollector {
onStreamFinished: {
if (text.length === 0) return;
root.savedChats = text.split("\n")
.filter(fileName => fileName.endsWith(".json"))
.map(fileName => `${Directories.aiChats}/${fileName}`)
}
}
}
function chatToJson() {
return root.messageIDs.map(id => {
const message = root.messageByID[id]
return ({
"role": message.role,
"rawContent": message.rawContent,
"model": message.model,
"thinking": false,
"done": true,
})
})
}
FileView {
id: chatSaveFile
property string chatName: ""
path: chatName.length > 0 ? `${Directories.aiChats}/${chatName}.json` : ""
blockLoading: true
}
FileView {
id: chatWriter
}
function saveChat(chatName) {
const filePath = `${Directories.aiChats}/${chatName.trim()}.json`
chatWriter.path = filePath
chatWriter.setText(JSON.stringify(root.chatToJson()))
getSavedChats.running = true;
}
function loadChat(chatName) {
try {
chatSaveFile.chatName = chatName.trim()
chatSaveFile.reload()
const saveData = JSON.parse(chatSaveFile.text())
root.clearMessages()
root.messageIDs = saveData.map((_, i) => i)
for (let i = 0; i < saveData.length; i++) {
const message = saveData[i];
root.messageByID[i] = root.aiMessageComponent.createObject(root, {
"role": message.role,
"rawContent": message.rawContent,
"content": message.rawContent,
"model": message.model,
"thinking": false,
"done": true,
});
}
root.addMessage(Translation.tr("Loaded a local snapshot. Note: this restores the transcript view only — the live Souveraine conversation starts fresh on the next message."), root.interfaceRole);
} catch (e) {
console.log("[Souveraine] Could not load chat: ", e);
} finally {
getSavedChats.running = true;
}
}
}

View file

@ -0,0 +1,244 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
/*
* PulseAudio-backed replacement for ii's PipeWire Audio singleton.
*
* The Pixel 3 uses native PulseAudio because its Q6 PCM needs the patched
* module-alsa-sink fallback. Quickshell has a PipeWire service but no native
* PulseAudio service, so keep ii's public Audio API and mirror pactl's default
* sink into the small node-shaped object the existing controls consume.
*/
Singleton {
id: root
property bool ready: false
property bool sourceReady: false
property bool syncing: false
property bool autoMuted: false
property bool micActive: false
readonly property real hardMaxValue: 1.00
property string audioTheme: Config.options.sounds.theme
readonly property real value: sink.audio.volume
property QtObject sink: QtObject {
id: sinkNode
property string id: name
property string name: ""
property string description: Translation.tr("Internal speakers")
property string nickname: description
property bool isSink: true
property var properties: ({ "node.name": name })
property QtObject audio: QtObject {
property real volume: 0
property bool muted: false
onVolumeChanged: {
if (root.ready && !root.syncing)
root.setVolume(volume)
}
onMutedChanged: {
if (root.ready && !root.syncing)
root.setMuted(muted)
}
}
}
// Mirror PulseAudio's default source into the same node-shaped API used
// for output. This is the handset UCM capture endpoint (hw:0,1), not a
// placeholder: QS must be able to control and report the real microphone
// even while the lower-level zero-sample fault is being diagnosed.
property QtObject source: QtObject {
id: sourceNode
property string id: name
property string name: ""
property string description: Translation.tr("Internal microphone")
property string nickname: description
property bool isSink: false
property var properties: ({ "node.name": name })
property QtObject audio: QtObject {
property real volume: 0
property bool muted: false
onVolumeChanged: {
if (root.sourceReady && !root.syncing)
root.setSourceVolume(volume)
}
onMutedChanged: {
if (root.sourceReady && !root.syncing)
root.setSourceMuted(muted)
}
}
}
readonly property list<var> outputDevices: sink.name.length > 0 ? [sink] : []
readonly property list<var> inputDevices: source.name.length > 0 ? [source] : []
readonly property list<var> outputAppNodes: []
readonly property list<var> inputAppNodes: []
signal sinkProtectionTriggered(string reason)
function friendlyDeviceName(node) {
return node?.nickname || node?.description || Translation.tr("Unknown")
}
function appNodeDisplayName(node) {
return node?.properties?.["application.name"] || node?.description || node?.name || Translation.tr("Unknown")
}
function refresh() {
if (!statusProcess.running)
statusProcess.running = true
}
function toggleMute() {
if (!ready)
return
autoMuted = false
setMuted(!sink.audio.muted)
}
function toggleMicMute() {
if (!sourceReady)
return
setSourceMuted(!source.audio.muted)
}
function incrementVolume() {
setVolume(Math.min(hardMaxValue, value + (value < 0.1 ? 0.01 : 0.02)))
}
function decrementVolume() {
setVolume(Math.max(0, value - (value < 0.1 ? 0.01 : 0.02)))
}
function setVolume(nextVolume) {
if (!ready || !isFinite(nextVolume))
return
const bounded = Math.max(0, Math.min(hardMaxValue, Number(nextVolume)))
volumeProcess.exec(["pactl", "set-sink-volume", "@DEFAULT_SINK@", `${Math.round(bounded * 100)}%`])
refreshSoon.restart()
}
function setMuted(muted) {
if (!ready)
return
muteProcess.exec(["pactl", "set-sink-mute", "@DEFAULT_SINK@", muted ? "1" : "0"])
refreshSoon.restart()
}
function setSourceVolume(nextVolume) {
if (!sourceReady || !isFinite(nextVolume))
return
const bounded = Math.max(0, Math.min(hardMaxValue, Number(nextVolume)))
sourceVolumeProcess.exec(["pactl", "set-source-volume", "@DEFAULT_SOURCE@", `${Math.round(bounded * 100)}%`])
refreshSoon.restart()
}
function setSourceMuted(muted) {
if (!sourceReady)
return
sourceMuteProcess.exec(["pactl", "set-source-mute", "@DEFAULT_SOURCE@", muted ? "1" : "0"])
refreshSoon.restart()
}
function setDefaultSink(node) {
if (!node?.name)
return
defaultSinkProcess.exec(["pactl", "set-default-sink", node.name])
refreshSoon.restart()
}
function setDefaultSource(node) {
if (!node?.name)
return
defaultSourceProcess.exec(["pactl", "set-default-source", node.name])
refreshSoon.restart()
}
// Read the default sink and source in one transaction so the UI never
// mixes state from different PulseAudio generations.
// The tagged output avoids relying on locale-sensitive pactl labels.
Process {
id: statusProcess
command: ["sh", "-c", "printf 'sink_name='; pactl get-default-sink; printf 'sink_volume='; pactl get-sink-volume @DEFAULT_SINK@; printf 'sink_mute='; pactl get-sink-mute @DEFAULT_SINK@; printf 'source_name='; pactl get-default-source; printf 'source_volume='; pactl get-source-volume @DEFAULT_SOURCE@; printf 'source_mute='; pactl get-source-mute @DEFAULT_SOURCE@; printf 'source_outputs='; pactl list short source-outputs | wc -l"]
environment: ({ LANG: "C", LC_ALL: "C" })
stdout: StdioCollector {
onStreamFinished: {
const text = this.text
const sinkName = /^sink_name=(.+)$/m.exec(text)?.[1]?.trim()
const sinkVolume = /^sink_volume=.*?(\d+)%/m.exec(text)?.[1]
const sinkMuted = /^sink_mute=Mute:\s*(yes|no)$/m.exec(text)?.[1]
const sourceName = /^source_name=(.+)$/m.exec(text)?.[1]?.trim()
const sourceVolume = /^source_volume=.*?(\d+)%/m.exec(text)?.[1]
const sourceMuted = /^source_mute=Mute:\s*(yes|no)$/m.exec(text)?.[1]
const sourceOutputs = /^source_outputs=(\d+)$/m.exec(text)?.[1]
if (!sinkName || sinkVolume === undefined || sinkMuted === undefined) {
root.ready = false
} else {
root.syncing = true
sinkNode.name = sinkName
sinkNode.description = (sinkName.includes("hw_0_0") || sinkName.includes("Speaker__sink"))
? Translation.tr("Internal speakers") : sinkName
sinkNode.audio.volume = Math.max(0, Math.min(root.hardMaxValue, Number(sinkVolume) / 100))
sinkNode.audio.muted = sinkMuted === "yes"
root.syncing = false
root.ready = true
}
if (!sourceName || sourceVolume === undefined || sourceMuted === undefined) {
root.sourceReady = false
root.micActive = false
} else {
root.syncing = true
sourceNode.name = sourceName
sourceNode.description = (sourceName === "blueline_mic" || sourceName.includes("hw_0_1") || sourceName.includes("Mic__source"))
? Translation.tr("Internal microphone") : sourceName
sourceNode.audio.volume = Math.max(0, Math.min(root.hardMaxValue, Number(sourceVolume) / 100))
sourceNode.audio.muted = sourceMuted === "yes"
root.syncing = false
root.sourceReady = true
root.micActive = Number(sourceOutputs ?? 0) > 0
}
}
}
onExited: exitCode => {
if (exitCode !== 0) {
root.ready = false
root.sourceReady = false
root.micActive = false
}
}
}
Process { id: volumeProcess }
Process { id: muteProcess }
Process { id: sourceVolumeProcess }
Process { id: sourceMuteProcess }
Process { id: defaultSinkProcess }
Process { id: defaultSourceProcess }
Timer {
id: refreshSoon
interval: 120
repeat: false
onTriggered: root.refresh()
}
Timer {
interval: 1500
repeat: true
running: true
triggeredOnStart: true
onTriggered: root.refresh()
}
function playSystemSound(soundName) {
const base = `/usr/share/sounds/${audioTheme}/stereo/${soundName}`
Quickshell.execDetached(["sh", "-c", `ffplay -nodisp -autoexit \"${base}.oga\" 2>/dev/null || ffplay -nodisp -autoexit \"${base}.ogg\" 2>/dev/null`])
}
}

View file

@ -0,0 +1,169 @@
pragma Singleton
pragma ComponentBehavior: Bound
// Cellular/modem status via mmcli (ModemManager CLI), following the same
// process-based-service pattern as services/Network.qml (nmcli). No
// existing service in this shell talks to ModemManager — this is new,
// added for Pixel 3 (blueline) telephony support.
//
// Pattern sources (see Pixel3Arch/references/shells/):
// - Marathon-Image's CellularManager.qml/NetworkManager.qml gave the
// property shape (operatorName, networkType, signalStrength, roaming)
// but proxy a native ModemManagerCpp backend that doesn't exist in that
// repo — not directly portable.
// - Phosh's src/wwan/phosh-wwan-mm.c is the real, shipping reference: it
// reads ModemManager's base `Modem` interface (SignalQuality,
// AccessTechnologies, State) and the separate `Modem.Modem3gpp`
// interface (OperatorName) as two distinct D-Bus interfaces on the same
// modem object path, and updates via PropertiesChanged signals rather
// than polling. mmcli -J surfaces both interfaces' fields in one call,
// so we replicate the *signal-driven refresh*, not the polling
// Marathon-Image's own comments call out as a placeholder.
import Quickshell
import Quickshell.Io
import QtQuick
Singleton {
id: root
property bool available: false
property string modemPath: ""
property string state: "unknown" // registered, searching, denied, unknown, disabled
property int signalQuality: 0 // 0-100
property string operatorName: ""
property string accessTech: "" // GSM, EDGE, 3G, HSPA, LTE, 5G (Phosh's user-friendly mapping)
property bool roaming: false
// "has service" = registered or better. A modem with an active data
// bearer reports state "connected" (not "registered"), so matching only
// the exact string "registered" made a working 4G data link render the
// "connected, no internet" error glyph. Phosh's phosh_wwan_mm treats
// registered/connecting/connected identically — mirror that.
readonly property bool hasService: ["registered", "connecting", "connected"].indexOf(root.state) >= 0
readonly property string materialSymbol: !root.available
? "signal_cellular_off"
: !root.hasService
? "signal_cellular_0_bar" // searching / denied / disabled
: (
root.signalQuality > 80 ? "signal_cellular_4_bar" :
root.signalQuality > 60 ? "signal_cellular_3_bar" :
root.signalQuality > 40 ? "signal_cellular_2_bar" :
root.signalQuality > 20 ? "signal_cellular_1_bar" :
"signal_cellular_0_bar"
)
// MMModemAccessTechnology bitmask -> label, mirrors Phosh's
// phosh_wwan_mm_user_friendly_access_tec() threshold order
// (5GNR > LTE > HSPA+ > HSPA > UMTS/3G > EDGE > GSM), applied to the
// string mmcli already prints for `generic.access-technologies[0]`.
function friendlyAccessTech(raw) {
if (!raw)
return "";
const t = raw.toLowerCase();
if (t.includes("5gnr"))
return "5G";
if (t.includes("lte"))
return "LTE";
if (t.includes("hspa+"))
return "H+";
if (t.includes("hspa"))
return "H";
if (t.includes("umts"))
return "3G";
if (t.includes("edge"))
return "E";
if (t.includes("gsm"))
return "G";
return raw.toUpperCase();
}
function update() {
findModem.running = true;
}
Process {
id: findModem
command: ["mmcli", "-L", "-J"]
stdout: StdioCollector {
onStreamFinished: {
try {
const data = JSON.parse(text);
const modems = data["modem-list"] || [];
if (modems.length === 0) {
root.available = false;
root.modemPath = "";
return;
}
root.modemPath = modems[0];
modemDetail.command = ["mmcli", "-m", modems[0], "-J"];
modemDetail.running = true;
} catch (e) {
root.available = false;
}
}
}
}
Process {
id: modemDetail
stdout: StdioCollector {
onStreamFinished: {
try {
const data = JSON.parse(text);
const modem = data.modem;
const generic = modem["generic"];
const threegpp = modem["3gpp"];
root.available = true;
root.state = generic["state"] || "unknown";
root.signalQuality = parseInt((generic["signal-quality"] && generic["signal-quality"]["value"]) || "0", 10);
const techs = generic["access-technologies"] || [];
root.accessTech = techs.length > 0 ? root.friendlyAccessTech(techs[0]) : "";
root.operatorName = (threegpp && threegpp["operator-name"]) ? threegpp["operator-name"] : "";
root.roaming = !!(threegpp && threegpp["registration-state"] === "roaming");
} catch (e) {
root.available = false;
}
}
}
}
// Signal-driven refresh: subscribe to ModemManager's PropertiesChanged
// on both interfaces it actually emits changes on (base Modem +
// Modem.Modem3gpp — same two-interface split Phosh's real code uses),
// re-running the mmcli read on any change instead of polling on a
// fixed timer. This is the equivalent of Network.qml's long-running
// `nmcli monitor` subscriber process.
Process {
id: subscriber
running: true
// gdbus (not dbus-monitor): unprivileged users can't get monitor
// rights on the system bus, and dbus-monitor's eavesdrop fallback
// silently receives nothing there. gdbus subscribes with normal
// match rules, which broadcast signals like PropertiesChanged
// always reach.
command: ["gdbus", "monitor", "-y", "-d", "org.freedesktop.ModemManager1"]
stdout: SplitParser {
onRead: line => {
if (line.includes("PropertiesChanged"))
root.update();
}
}
}
// Fallback: the initial update can race ModemManager's own startup
// (modem appears seconds after the shell), and a missed signal would
// otherwise stick forever. Slow re-read, not the primary mechanism.
Timer {
interval: 30000
running: true
repeat: true
onTriggered: root.update()
}
Component.onCompleted: update()
}

View file

@ -0,0 +1,137 @@
// How fast the battery is actually charging — fast, standard, or trickle.
//
// Android surfaced this and the phone did not, so a charger that had quietly
// fallen back to trickle looked exactly like one that hadn't: "Charging 41%"
// either way, for hours.
//
// The value comes from the kernel's `charge_type`, which on SDM845 lives on
// the *charger* (`pmi8998-charger`), not the fuel gauge (`qcom-battery`) —
// the fuel gauge has no such attribute at all. Our UPower fork walks the
// supplier device link to find it and publishes it as the `ChargeType`
// property on the battery device (see packaging/upower-souveraine,
// up_device_supply_get_supplier_charge_type_str).
//
// Quickshell's UPowerDevice binds a fixed set of properties in C++ and
// `ChargeType` is not among them, so this reads D-Bus directly: one initial
// get-property, then gdbus monitor for changes (the property is declared
// emits-change, so the signal is real, not a poll). Same pattern as the
// squeekboard visibility monitor in OnScreenKeyboard.qml.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.UPower
Singleton {
id: root
// UpDeviceChargeType, from libupower-glib/up-types.h. Kept as plain ints
// because the enum crosses as a uint32 and Quickshell has no binding for
// it — if the fork ever renumbers, this list is the one place to fix.
readonly property int unknown: 0
readonly property int none: 1
readonly property int trickle: 2
readonly property int fast: 3
readonly property int standard: 4
readonly property int adaptive: 5
readonly property int custom: 6
readonly property int longlife: 7
readonly property int bypass: 8
// Raw charge type as UPower last reported it. -1 until the first read
// lands, so "not asked yet" is distinguishable from "driver says unknown".
property int chargeType: -1
readonly property bool charging:
UPower.displayDevice?.state === UPowerDeviceState.Charging
// The cable is in and the charger has stopped anyway. This is normal
// charge-termination hysteresis, not a fault: the charger terminates at
// full, the pack self-discharges to its recharge threshold, and the
// charger starts again. UPower reports `discharging` throughout, so a
// surface that trusts state alone shows "Battery 94%" ticking down with
// the cable plugged in — which reads as a failing charger and isn't one.
readonly property bool pluggedNotCharging:
!UPower.onBattery && !root.charging
&& UPower.displayDevice?.state !== UPowerDeviceState.FullyCharged
// True when the rate is something worth saying out loud. `standard` is
// the unremarkable case and gets the plain verb; none/unknown mean the
// driver told us nothing and must not be dressed up as information.
readonly property bool rateKnown: root.charging
&& root.chargeType !== root.none
&& root.chargeType !== root.unknown
&& root.chargeType !== -1
// The phrase a surface shows in place of "Charging". Empty when the
// device is not charging — a charge *rate* while discharging is a stale
// reading of the last session, not a fact about now.
readonly property string label: {
if (!root.charging) return "";
switch (root.chargeType) {
case root.fast: return "Fast charging";
case root.trickle: return "Slow charging";
case root.adaptive: return "Adaptive charging";
case root.longlife: return "Charging (long life)";
case root.bypass: return "Bypass charging";
// standard, custom, none, unknown, and not-yet-read all fall
// through to the verb with no adverb attached.
default: return "Charging";
}
}
// Initial value. The monitor below only carries *changes*, so without
// this a session that starts already plugged in shows nothing until the
// charger next shifts gear — which on a topped-off pack may be never.
Process {
id: initialRead
running: true
command: ["sh", "-c",
"p=$(upower -e | grep -m1 -i batt) || exit 0; " +
"busctl get-property org.freedesktop.UPower \"$p\" " +
"org.freedesktop.UPower.Device ChargeType"]
stdout: SplitParser {
// `busctl get-property` prints "u 4".
onRead: line => {
const m = line.match(/^u\s+(\d+)/);
if (m) root.chargeType = parseInt(m[1], 10);
}
}
}
// PropertiesChanged carries the new value inline:
// /org/…/battery_qcom_battery: org.freedesktop.DBus.Properties
// ::PropertiesChanged ('org.freedesktop.UPower.Device',
// {'ChargeType': <uint32 3>}, @as [])
// Broadcast signals are receivable without eavesdrop privileges, so this
// needs no root and no polling.
Process {
id: chargeTypeMonitor
running: true
command: ["gdbus", "monitor", "--system",
"--dest", "org.freedesktop.UPower"]
stdout: SplitParser {
onRead: line => {
const m = line.match(/'ChargeType':\s*<uint32\s+(\d+)>/);
if (!m) return;
const next = parseInt(m[1], 10);
if (next === root.chargeType) return;
console.log("[charge-rate] charge type " + root.chargeType
+ " -> " + next);
root.chargeType = next;
}
}
}
// A charger swap can change the rate without UPower re-emitting if the
// battery device is re-added rather than updated. Re-read on every
// plug/unplug edge; it is one busctl call, not a poll.
Connections {
target: UPower.displayDevice
function onStateChanged() {
initialRead.running = false;
initialRead.running = true;
}
}
}

View file

@ -0,0 +1,47 @@
// Souveraine patch to ii's stock ConflictKiller.qml.
//
// Stock ii flags kded6 as a "conflicting tray". On this phone kded6 is not
// a competing Plasma tray — it is the StatusNotifierWatcher that ii's OWN
// tray (Quickshell.Services.SystemTray) registers as a host with. Killing
// it just makes D-Bus re-activate it on ii's next tray call, and leaving
// autoKillTrays off pops the kill dialog on every shell start. So: drop
// the kded6 check entirely; keep the notification-daemon check unchanged.
pragma Singleton
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property string killDialogQmlPath: FileUtils.trimFileProtocol(Quickshell.shellPath("killDialog.qml"))
function load() {
// dummy to force init
}
Connections {
target: Config
function onReadyChanged() {
if (Config.ready) checkConflictsProc.running = true
}
}
Process {
id: checkConflictsProc
command: ["bash", "-c", `pidof mako dunst`]
stdout: StdioCollector {
onStreamFinished: {
const conflictingNotifications = this.text.trim().length > 0;
if (!conflictingNotifications) return;
if (Config.options.conflictKiller.autoKillNotificationDaemons)
Quickshell.execDetached(["killall", "mako", "dunst"])
else
Quickshell.execDetached(["qs", "-p", root.killDialogQmlPath])
}
}
}
}

View file

@ -0,0 +1,193 @@
// Crash surfacing (TASK-05) — the other half of the supervision work in
// souveraine-shell.service. Supervision journals and restarts; this makes a
// crash *reported*: a notification through the org.freedesktop.Notifications
// pipe, which fans to banner unlocked / lock card locked / NotifyEvents.
//
// This runs inside the shell on purpose. When qs dies, systemd resurrects it
// and the fresh instance finds the new crashes.log line and announces its own
// recovery. The 8-retry give-up case has no shell to banner from — sessiond's
// fail-closed lock is that surface, not us.
//
// Emission is a real notify-send, not a shortcut into the Notifications
// singleton: the crash report exercises the same D-Bus path every other
// client uses, so a broken pipe is itself detected.
//
// Sources watched:
// 1. ~/.local/state/souveraine/crashes.log — inotify via FileView, no poll.
// 2. systemctl --user --failed — 30s timer (doc: no tight loops).
// 3. coredumpctl list tail — same 30s tick.
// Dedupe state persists in crash-reporter.state so a crash is reported once
// across shell restarts, not once per resurrection.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property string stateDir: Quickshell.env("HOME") + "/.local/state/souveraine"
property bool started: false
// Persisted dedupe cursors.
property int crashLinesSeen: -1 // -1 = state not loaded yet
property var knownFailedUnits: []
property string lastCoredumpLine: ""
function start() {
if (root.started) return;
root.started = true;
stateFile.reload();
}
function notify(summary, body, urgency) {
Quickshell.execDetached(["notify-send", "-a", "souveraine",
"-u", urgency ?? "critical", summary, body ?? ""]);
}
function saveState() {
stateFile.setText(JSON.stringify({
crashLinesSeen: root.crashLinesSeen,
knownFailedUnits: root.knownFailedUnits,
lastCoredumpLine: root.lastCoredumpLine,
}));
}
FileView {
id: stateFile
path: root.stateDir + "/crash-reporter.state"
onLoaded: {
try {
const s = JSON.parse(text());
root.crashLinesSeen = s.crashLinesSeen ?? 0;
root.knownFailedUnits = s.knownFailedUnits ?? [];
root.lastCoredumpLine = s.lastCoredumpLine ?? "";
} catch (e) {
root.crashLinesSeen = 0;
}
crashLog.reload();
}
onLoadFailed: {
// First run: baseline everything as seen so a fresh deploy does
// not storm about history; only new events report.
root.crashLinesSeen = -2;
crashLog.reload();
}
}
// Set once we have successfully read the log, so a later failure is
// distinguishable from "it has never existed".
property bool _crashLogSeen: false
property bool _crashLogFailureReported: false
FileView {
id: crashLog
path: root.stateDir + "/crashes.log"
watchChanges: true
// No crashes.log yet — nothing has ever crashed. The watcher can't
// watch a nonexistent file, so the 30s tick retries the reload, and
// each retry printed a "Read of ... failed" warning. On a healthy
// 16h session that was 1293 lines — 78% of everything in the shell
// log, drowning the file we read to verify our own changes.
//
// Absence is the *expected* state here, so it is silenced. But it is
// not silenced blind: onLoadFailed still fires, and a failure after
// we have once read the file successfully is a real fault and says
// so — once, not every 30 seconds.
printErrors: false
onFileChanged: reload()
onLoaded: {
root._crashLogSeen = true;
root._crashLogFailureReported = false;
root.consumeCrashLog();
}
onLoadFailed: {
if (root._crashLogSeen && !root._crashLogFailureReported) {
root._crashLogFailureReported = true;
console.log("[CrashReporter] crashes.log became unreadable at",
crashLog.path, "— crash reporting is blind until it returns");
}
}
}
function consumeCrashLog() {
if (root.crashLinesSeen === -1) return; // state not loaded yet
const lines = crashLog.text().split("\n").filter(l => l.trim().length > 0);
if (root.crashLinesSeen === -2) { // first-run baseline
root.crashLinesSeen = lines.length;
root.saveState();
return;
}
if (lines.length < root.crashLinesSeen) root.crashLinesSeen = 0; // rotated
if (lines.length === root.crashLinesSeen) return;
const fresh = lines.slice(root.crashLinesSeen);
root.crashLinesSeen = lines.length;
root.saveState();
// "$(date -Is) souveraine-shell result=exit-code exit=255"
const last = fresh[fresh.length - 1];
const detail = last.replace(/^\S+\s+/, "");
root.notify(
fresh.length > 1
? qsTr("Shell crashed ×%1 — recovered").arg(fresh.length)
: qsTr("Shell crashed — recovered"),
detail);
}
Timer {
interval: 30000
running: root.started
repeat: true
triggeredOnStart: true
onTriggered: {
if (crashLog.path && !crashLog.loaded) crashLog.reload();
failedUnits.running = true;
coredumps.running = true;
}
}
Process {
id: failedUnits
command: ["systemctl", "--user", "--failed", "--plain", "--no-legend"]
stdout: StdioCollector {
onStreamFinished: {
const units = text.split("\n")
.map(l => l.trim().split(/\s+/)[0])
.filter(u => u.length > 0)
// Our own unit's crashes come from crashes.log with detail;
// it also can't be in --failed while we are running.
.filter(u => u !== "souveraine-shell.service");
const fresh = units.filter(u => !root.knownFailedUnits.includes(u));
root.knownFailedUnits = units;
if (root.crashLinesSeen === -1) return; // still booting state
root.saveState();
if (fresh.length > 0) {
root.notify(
qsTr("Service failed: %1").arg(fresh.join(", ")),
qsTr("systemctl --user status for details"));
}
}
}
}
Process {
id: coredumps
command: ["sh", "-c", "coredumpctl list --no-legend 2>/dev/null | tail -1"]
stdout: StdioCollector {
onStreamFinished: {
const line = text.trim();
if (line.length === 0) return;
if (root.crashLinesSeen === -1) return;
if (line === root.lastCoredumpLine) return;
const first = root.lastCoredumpLine.length === 0;
root.lastCoredumpLine = line;
root.saveState();
if (first) return; // baseline, don't report history
// "... TIME PID UID GID SIG COREFILE EXE SIZE"
const cols = line.split(/\s+/);
const exe = cols.length >= 2 ? cols[cols.length - 2] : "?";
root.notify(qsTr("Process dumped core: %1").arg(exe), line, "normal");
}
}
}
}

View file

@ -0,0 +1,255 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
/**
* DeviceEvidence — the shell's ingress to the device state machine.
*
* DEVICE-STATE-MACHINE §1 is a list of seven actors that each saw one facet of
* the device and could not see the others. Every shell surface that takes user
* input is a candidate for becoming an eighth. This singleton exists so that
* "report that the user did something" is one line, and a new surface has no
* excuse to be an isolated unknown.
*
* The wire is `Request::Input { trigger }` (souveraine/src/sessiond/protocol.rs
* — that file is the contract): `{"op":"input","trigger":"touch"}`. It resets
* the idle budget the lock/blank rules count against, which is what lets the
* machine distinguish "the user is looking at this" from "this has been lit for
* ten minutes."
*
* `intent` rides along in the Envelope. protocol.rs is explicit that it is
* "declared, never verified... evidence in exactly the sense doctrine §9 means —
* useful for reconstruction, never a basis for a decision. Nothing branches on
* it." So it is safe to be honest in, and it is what §11 wants recorded: the
* intent, not only the leaf. Never put user content in it — a surface name, not
* what the surface was showing.
*
* No sessiond on the socket (laptop, or bring-up) = every call no-ops quietly.
* This is evidence, not an authority: a dropped report must never be an error
* the user sees.
*/
Singleton {
id: root
// Reports are coalesced: a keyboard would otherwise emit one request per
// keystroke to reset a budget measured in tens of seconds. The machine only
// needs to know the user is still there.
readonly property int _throttleMs: 2000
property double _lastSentAt: 0
property string _pendingTrigger: ""
property string _pendingIntent: ""
/**
* Report real user input.
*
* trigger: "touch" | "key" | "power_button" | "double_tap_to_wake"
* | "squeeze" | "unknown" (InputTrigger, snake_case)
* intent: short surface label, e.g. "selection-menu". No user content.
*/
function report(trigger, intent) {
const t = String(trigger ?? "unknown");
const now = Date.now();
if (now - root._lastSentAt < root._throttleMs) {
// Keep the newest label; the budget reset is idempotent so dropping
// the intervening reports costs nothing.
root._pendingTrigger = t;
root._pendingIntent = String(intent ?? "");
flushTimer.running = true;
return;
}
root._send(t, String(intent ?? ""));
}
/** Convenience for the common case: a tap on one of our own surfaces. */
function touched(intent) {
root.report("touch", intent);
}
Timer {
id: flushTimer
interval: root._throttleMs
repeat: false
onTriggered: {
if (root._pendingTrigger.length === 0) return;
root._send(root._pendingTrigger, root._pendingIntent);
root._pendingTrigger = "";
root._pendingIntent = "";
}
}
property var _queued: null
function _send(trigger, intent) {
root._lastSentAt = Date.now();
const msg = { op: "input", trigger: trigger };
if (intent.length > 0) msg.intent = intent;
if (sock.connected) {
sock.write(JSON.stringify(msg) + "\n");
return;
}
root._queued = msg;
sock.connected = true;
}
// ── Ingress: the machine's own account of itself ─────────────────────
//
// Everything above is egress — the shell telling sessiond that something
// happened. This half is the other direction, and until now it did not
// exist: the state machine computes its state, its evidence, its
// confidence and its per-source health, and **no surface could see any of
// it** (TASK-08(f), TASK-19). The trail knew and the glass did not.
//
// Strictly a projection. It reads `device_state`, holds nothing the
// protocol owns, and decides nothing — DEVICE-STATE-MACHINE §1's whole
// complaint is actors that saw one facet and acted on it, and a readout
// that started branching would be the eighth. Doctrine §4: read the
// authority, never mirror it into a second source of truth.
//
// Polled only while a surface is actually looking (watch/unwatch). A
// settings page open on the desk should not cost a request per second for
// the rest of the day.
/// True once sessiond has answered at least once. False on the laptop,
/// where there is no daemon — surfaces must render that as "unavailable",
/// never as healthy-looking zeroes.
property bool available: false
/// The last `device_state` reply, verbatim. Read-only to every consumer.
property var state: ({})
/// Recent forensic entries (the decision trail), newest last.
property var recentDecisions: []
/// ms epoch of the last successful read; 0 = never.
property double lastReadAt: 0
property int _watchers: 0
/** Begin polling. Pair every call with unwatch(). */
function watch() {
root._watchers += 1;
if (root._watchers === 1) {
readTimer.running = true;
root._query();
}
}
function unwatch() {
root._watchers = Math.max(0, root._watchers - 1);
if (root._watchers === 0) {
readTimer.running = false;
readSock.connected = false;
}
}
/** One-shot refresh, whether or not anything is watching. */
function refresh() {
root._query();
}
property bool _queryPending: false
function _query() {
if (readSock.connected) {
readSock.write(JSON.stringify({ op: "device_state" }) + "\n");
readSock.write(JSON.stringify({ op: "forensic_log", count: 20 }) + "\n");
return;
}
root._queryPending = true;
readSock.connected = true;
}
Timer {
id: readTimer
interval: 2000
repeat: true
running: false
onTriggered: root._query()
}
// A SECOND connection, deliberately. The egress socket above is
// fire-and-forget and throttled; interleaving request/response traffic on
// it would mean correlating replies to writes that may never come. This
// one only ever asks questions. It does NOT register shell authority —
// that is SessiondBridge's job, and a second registration is what
// deadlocks the lease.
Socket {
id: readSock
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
onConnectionStateChanged: {
if (connected && root._queryPending) {
root._queryPending = false;
readSock.write(JSON.stringify({ op: "device_state" }) + "\n");
readSock.write(JSON.stringify({ op: "forensic_log", count: 20 }) + "\n");
} else if (!connected) {
root._queryPending = false;
// No daemon is the laptop's normal state. Say unavailable and
// let the surface show that, rather than leaving stale values
// on screen that look current.
root.available = false;
}
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
let reply;
try {
reply = JSON.parse(message);
} catch (e) {
return;
}
if (reply.ok !== true) {
console.log("[device-evidence] read refused:",
reply.code ?? "?", reply.reason ?? "");
return;
}
// device_state carries the state field; forensic_log carries
// entries. One parser, two shapes, told apart by content
// rather than by a correlation id the protocol does not have.
if (reply.device_state !== undefined) {
root.state = reply;
root.available = true;
root.lastReadAt = Date.now();
} else if (reply.entries !== undefined) {
root.recentDecisions = reply.entries;
}
}
}
}
Socket {
id: sock
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
onConnectionStateChanged: {
if (connected && root._queued) {
const m = root._queued;
root._queued = null;
sock.write(JSON.stringify(m) + "\n");
} else if (!connected && root._queued) {
// Quiet on purpose. Evidence is best-effort; a missing daemon is
// the laptop's normal state and must not look like a fault.
root._queued = null;
}
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
// Nothing to do with a reply — this is fire-and-forget. Only a
// refusal is worth a line, so a protocol drift is not silent.
try {
const reply = JSON.parse(message);
if (reply.ok !== true)
console.log("[device-evidence] refused:",
reply.code ?? "?", reply.reason ?? "");
} catch (e) {
// Malformed reply is not worth escalating for a fire-and-forget.
}
}
}
}
}

View file

@ -0,0 +1,613 @@
// Her face on the glass — TASK-59.
//
// The face is a *limb*, not a second client. `Souveraine.qml` is the one
// connection to the server and stays that way: this owns a `souveraine-web`
// process, feeds it what she is already saying on the sidebar's stream, and
// sends what the user says to it back through the same `Souveraine.send()`.
// Casey, 2026-08-05: *"I will want it to be in sync with the sidebar — meaning
// if we 'resume' it's resumed."* Two transports could not promise that; one
// does by construction.
//
// USB Hands joins the same limb rather than opening a controller app beside
// her. Its agent field, explicit microphone and trackpad occupy the room the
// compositor already left below her. The HID reports still belong to
// HidController; this service only routes page intent.
//
// ## Turning her on is joining
//
// `joined` is the whole state. While it is true she is on the glass **and** the
// expression vocabulary rides in the per-send ambient block, so she has a
// syntax for shifting expression. While it is false neither happens — and the
// second half is the point: that prompt is context nobody asked for when the
// face is closed. Casey, 2026-08-05: *"it'll be like a loadable/unloadable
// skill… we might have times where we just don't want that extra prompt added
// to context."*
//
// The sidebar ignores the tags it sees, which is why they are safe to leave in
// the stream rather than stripped on the way to one surface.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs
import qs.modules.common
Singleton {
id: root
// On the glass, and in the prompt. One flag, both consequences.
property bool joined: false
property string socketPath: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/face.sock"
property string rigDir: Quickshell.env("HOME") + "/.souveraine/face"
// Accumulated text of the turn in flight, so the bubble shows the whole
// line rather than the last delta.
property string _line: ""
// The fragment that rides in `ambient` while she is joined. Kept here
// rather than in the server so that leaving costs exactly nothing — there
// is no flag to unset and no prompt to remember to remove.
readonly property string skill: "You have a face on this device right now. "
+ "You may shift expression by emitting a tag on its own line: "
+ "[[face:idle]], [[face:alert]], [[face:thinking]], [[face:processing]], "
+ "[[face:affectionate]], [[face:straining]], [[face:yawning]], "
+ "[[face:listening]], [[face:speaking]]. "
+ "These are the postures the presence system already uses. "
+ "Use them sparingly, where the shift is real."
// No pre-flight check on the rig.
//
// There was one, reading `FileView.exists`, and it reported false for a
// directory that was plainly there — so the guard meant to explain a
// missing rig became the thing preventing a present one from loading. The
// host already fails loudly and specifically when the directory is wrong,
// and `onExited` puts `joined` back, so the honest answer is to let it try
// and report what actually happened. A guard that can be wrong about the
// world is worse than no guard.
function join() {
if (root.joined)
return;
host.running = true;
root.joined = true;
}
function joinHands(): bool {
if (!HidController.open())
return false;
if (!root.joined)
root.join();
// Continue the attached thread when one exists. Otherwise ask the
// server for this agent's latest; an agent with no history naturally
// mints a new conversation on the first utterance.
if (Souveraine.conversationId.length === 0 && !Souveraine.turnActive)
Souveraine.resumeLatestConversation();
root._syncHands();
return true;
}
function leaveHands() {
if (HidController.active)
HidController.close();
root._syncHands();
}
function leave() {
if (root.listening)
root._talk("cancel");
if (HidController.active)
HidController.close();
root.joined = false;
root._send({ op: "quit" });
host.running = false;
}
function toggle() {
if (root.joined)
root.leave();
else
root.join();
}
// Her body keeps the measured 540x760 canvas. The transparent 240px below
// it is the room viewtop deliberately reserved for whatever she shares;
// USB Hands fills that room when joined and otherwise publishes no input
// region there, so the home screen continues to receive touch.
//
// The Cubism view fits the rig to the canvas, so shrinking the canvas
// shrinks *her*; it does not trim the empty margin around her. Tried on
// 2026-08-06: 640 to cut the ~100px of dead space under her feet, and it
// came back "a tiny version that's scaled odd" because the whole figure
// came down with it. The dead space is the rig's own layout (`center_y`
// and `width` in model.json), and moving it is a rig change, not a window
// one. 760 is the size that reads right.
readonly property string faceSize: "540x1000"
Process {
id: host
command: ["souveraine-web",
"--rig", root.rigDir,
"--ipc", root.socketPath,
"--transparent",
"--size", root.faceSize,
"--app-id", "org.souveraine.face",
"--title", "Ani"]
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
let msg;
try {
msg = JSON.parse(line);
} catch (e) {
return;
}
// Anything the page wants to say goes through the one transport.
if (msg.event === "said" && msg.text) {
const result = Souveraine.send(msg.text);
const accepted = result === true;
const reason = result === "step-up"
? "Authentication is required before sending"
: accepted ? "" : "The agent is unavailable or already answering";
root._eval(`window.hands && window.hands.agentResult(${JSON.stringify({
accepted: accepted,
reason: reason
})})`);
}
else if (msg.event === "tapped")
root.tapped(msg.area ?? "body");
else if (msg.event === "talk")
root._talk(msg.phase);
else if (msg.event === "hid")
root._hid(msg);
else if (msg.event === "ready")
root._syncHands();
else if (msg.event === "dismiss")
root.leave();
else if (msg.event === "console")
root._pageSaid(msg.level, msg.text);
}
}
onExited: {
root.joined = false;
if (HidController.active)
HidController.close();
}
}
signal tapped(string area)
function _hid(message) {
if (!HidController.active)
return;
switch (message.op) {
case "move":
HidController.movePointer(message.x ?? 0, message.y ?? 0, message.wheel ?? 0);
break;
case "click":
HidController.click(message.button ?? "left");
break;
case "key":
HidController.key(message.key ?? "", message.modifiers ?? "");
break;
case "type": {
const accepted = HidController.sendText(message.text ?? "");
root._eval(`window.hands && window.hands.hostResult(${JSON.stringify({
accepted: accepted,
reason: accepted ? "" : HidController.lastError
})})`);
break;
}
}
}
function _syncHands() {
if (!root.joined)
return;
const agent = Souveraine.agents[Souveraine.currentAgentId];
const state = {
active: HidController.active,
ready: HidController.ready,
mode: UsbState.mode,
error: HidController.lastError,
agent: agent?.name ?? Souveraine.currentAgentId ?? "agent",
listening: root.listening,
thinking: Souveraine.turnActive
};
root._eval(`window.hands && window.hands.state(${JSON.stringify(state)})`);
}
// Whether she is recording right now. One flag, so a second press cannot
// start a second recorder over the first one's WAV.
property bool listening: false
// ## Speaking through her
//
// The explicit microphone beneath her is the primary affordance in USB
// Hands; press-and-hold on the figure remains available when she is joined
// without it. Both edges enter this one recorder and transcription path.
// She is the *face* of the voice pipeline, not a second chat surface
// (TASK-59 Q1a) — which is why nothing here holds a transcript or a
// conversation, it only hands text to `Souveraine.send()`.
//
// The recorder is `pw-record` at 16k mono s16 and the transcription is
// `souveraine-stt --file`, deliberately: that script already owns the
// endpoint from Settings → Speech and the whole error vocabulary
// (unreachable / 5xx / rejected), and a second copy of that here would be
// the second answer to "where does dictation go". 16k mono s16 is not a
// preference either — the comment in that script records that the server
// 500s on anything else.
//
// Written as one `sh -c` rather than a helper on PATH because the shell
// tree deploys as a unit and a new file on the device would need a package
// to reach it (CLAUDE.md's rule, and the trap that left sessiond five days
// stale). Two commands, one place.
function _talk(phase) {
if (phase === "start") {
if (root.listening)
return;
root.listening = true;
root._syncHands();
root._eval(`window.face.posture("listening")`);
recorder.command = ["sh", "-c",
"rm -f \"$W\"; pw-record --rate 16000 --channels 1 --format s16 \"$W\" & echo $! > \"$P\"; wait"];
recorder.running = true;
return;
}
if (!root.listening)
return;
root.listening = false;
root._syncHands();
recorder.running = false;
// Cancelled — a finger that slid off her, or the window losing focus.
// The recording is dropped rather than transcribed: sending whatever
// was captured before an abandoned gesture would put words she never
// finished into the conversation.
if (phase !== "end") {
stopper.command = ["sh", "-c", `kill -INT $(cat "$P" 2>/dev/null) 2>/dev/null; rm -f "$P" "$W"`];
stopper.running = true;
root._eval(`window.face.posture("idle")`);
return;
}
// The 0.3s is not padding: pw-record finalises the WAV header on the
// way out, and reading it sooner gets a file the server rejects.
// `souveraine-stt` learned this the same way and its comment says so.
// The `tr`/`sed` is not tidying. Whisper wraps its output with
// embedded newlines, and the transcript arrives here through a
// `SplitParser` on "\n" — so four wrapped lines would be **four
// separate messages** sent to her, one turn each, instead of one
// utterance. `souveraine-stt` collapses them on its own typing leg
// and says why in a comment; `--file` prints them raw, so the same
// trap arrives by the other door and has to be closed on this side.
transcriber.command = ["sh", "-c",
`kill -INT $(cat "$P" 2>/dev/null) 2>/dev/null; rm -f "$P"; sleep 0.4; ` +
`[ -s "$W" ] || exit 0; souveraine-stt --file "$W" ` +
`| tr '\\n\\r' ' ' | sed 's/ */ /g; s/^ //; s/ $//'; echo; rm -f "$W"`];
transcriber.running = true;
root._eval(`window.face.posture("thinking")`);
}
readonly property string _wav: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine-face-talk.wav"
readonly property string _pid: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine-face-talk.pid"
Process {
id: recorder
environment: ({ W: root._wav, P: root._pid })
}
Process {
id: stopper
environment: ({ W: root._wav, P: root._pid })
}
Process {
id: transcriber
environment: ({ W: root._wav, P: root._pid })
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
const said = line.trim();
if (said.length === 0)
return;
// Straight into the one transport, so the sidebar logs it and
// the reply streams back to the bubble through the same
// `onStreamEvent` her own speech already uses.
Souveraine.send(said);
}
}
onExited: root._eval(`window.face.posture("idle")`)
}
// What the page says, where someone can see it.
//
// The host forwards console and errors on the same line protocol. Dropped
// here, a rig that fails to draw is silent in every direction — which it
// was, and it cost 2026-08-06 an afternoon: a missing `#live_talk` element
// threw on the runtime's first update, after the model and all four
// textures had loaded, so every other signal read healthy.
function _pageSaid(level, text) {
if (level === "error")
console.warn("[face] page error:", text);
else
console.log("[face]", text);
}
// Reachable by name, so the dial, a launcher and the agent all summon her
// the same way rather than each growing a copy (TASK-30/31). `status`
// answers rather than assumes — an agent that cannot ask whether she is up
// has to guess, and guessing is what a verb table exists to stop.
IpcHandler {
target: "face"
function toggle(): void {
root.toggle();
}
function join(): void {
root.join();
}
function leave(): void {
root.leave();
}
function status(): string {
return JSON.stringify({
joined: root.joined,
rig: root.rigDir,
rigConnected: rigSocketLoader.item?.connected ?? false,
gazeConnected: gaze.connected
});
}
}
// Quickshell's Socket retains its QLocalSocket object after
// ConnectionRefused. Setting `connected` false then true cannot retry:
// socket.cpp only calls connectToServer when that object is null, and the
// error path never nulls it. Recreate the Socket object after a failed
// startup race; the second body gets a fresh QLocalSocket and can connect.
Loader {
id: rigSocketLoader
active: false
sourceComponent: Component {
Socket {
path: root.socketPath
connected: true
onConnectionStateChanged: {
if (connected)
pageReadyTimer.restart();
}
}
}
}
Connections {
target: root
function onJoinedChanged() {
root._rigConnectAttempts = 0;
if (!root.joined)
rigSocketLoader.active = false;
}
}
Timer {
id: faceSocketRecreateTimer
interval: 500
repeat: true
triggeredOnStart: true
running: root.joined
onTriggered: {
if (rigSocketLoader.item?.connected)
return;
if (root._rigConnectAttempts++ < 4)
console.log("[face] connecting to rig host, attempt " + root._rigConnectAttempts);
rigSocketLoader.active = false;
faceSocketCreateTimer.restart();
}
}
Timer {
id: faceSocketCreateTimer
interval: 50
repeat: false
onTriggered: {
if (root.joined)
rigSocketLoader.active = true;
}
}
property int _rigConnectAttempts: 0
Timer {
id: pageReadyTimer
interval: 250
repeat: false
onTriggered: root._syncHands()
}
function _send(msg) {
const rigSocket = rigSocketLoader.item;
if (rigSocket?.connected)
rigSocket.write(JSON.stringify(msg) + "\n");
}
// ## She is the user's, so she leaves when the user does
//
// Casey, 2026-08-06: "if I lock the screen, she should probably assume to
// turn off... she's not for everyone, just the user." Explicitly *not* the
// same as switching to an app — she persists across app use; only the lock
// takes her away.
//
// Gated on `screenLockSecure` — the compositor's acknowledgement — and NOT
// on `screenLocked`, which is only the request.
//
// The request drifts. Measured on the phone 2026-08-06: `session lock`
// answered `already-locked` while logind reported `LockedHint=no` and the
// phone was in use, so `screenLocked` had been stuck true for some time.
// That is CLAUDE.md's rule 2 exactly — a shadow copy of state the protocol
// owns — and a face gated on it would have been permanently dismissed with
// nothing on screen to explain why. `screenLockSecure` is the one
// GlobalStates itself calls "the real 'session is locked' signal".
//
// Nothing is lost by waiting for the ack: the compositor composites lock
// surfaces and nothing else while locked, so she is already off the glass
// before this runs. This is about not holding 283MB of webview through a
// locked night, not about disclosure.
// She does not come back on unlock, deliberately. Casey, 2026-08-06: "I
// want it recognized it locked, and going back to clock, and being clock
// until we retrigger it." Unlocking returns you to the clock, and summoning
// her is a double tap away — so the state you find is the plain one, and
// the face is something you choose each time rather than something that
// was left on.
Connections {
target: GlobalStates
function onScreenLockSecureChanged() {
if (GlobalStates.screenLockSecure && root.joined)
root.leave();
}
}
// Where fingers are, straight from the compositor, so she can look at them.
//
// Only open while she is up, because the compositor throttles but does not
// stop: a feed nobody is reading is a socket buffer filling behind a face
// that is not on screen.
//
// Screen coordinates come in; her window's own coordinates go out. The
// compositor reports in logical panel pixels and the page thinks in CSS
// pixels inside her window, so the origin has to be subtracted or she
// looks at a point offset by however far down the panel she is standing.
Socket {
id: gaze
path: (Quickshell.env("XDG_RUNTIME_DIR") || "/run/user/1000") + "/souveraine/viewtop.sock"
connected: root.joined
// `onConnectionStateChanged`, not `onConnectedChanged` — Quickshell's
// Socket emits the former, so the latter is a handler for a signal
// that does not exist and never runs. The subscribe was therefore
// never sent, the compositor never pushed, and she never followed a
// finger. `ViewtopControl`'s feed had the right idiom the whole time.
onConnectionStateChanged: {
console.log("[face] gaze socket connected=" + gaze.connected);
if (gaze.connected)
gaze.write('{"op":"gaze"}\n');
}
parser: SplitParser {
splitMarker: "\n"
onRead: line => {
let m;
try {
m = JSON.parse(line);
} catch (e) {
return;
}
if (root._gazeSeen === undefined) root._gazeSeen = 0;
if (root._gazeSeen++ < 4)
console.log("[face] gaze push: " + line);
if (m.ok !== undefined && m.down === undefined)
return;
if (!m.down) {
root._eval("window.face.lookAway()");
return;
}
root._eval(`window.face.lookAt(${m.x - root.originX}, ${m.y - root.originY})`);
}
}
}
// Where her window sits on the panel. Read from the compositor's own
// furniture report rather than assumed, because the layout decides it and
// it moves with the zone.
property var _gazeSeen: undefined
property real originX: 0
property real originY: 0
Socket {
id: whereAmI
path: (Quickshell.env("XDG_RUNTIME_DIR") || "/run/user/1000") + "/souveraine/viewtop.sock"
onConnectionStateChanged: {
if (whereAmI.connected)
whereAmI.write('{"op":"state"}\n');
}
parser: SplitParser {
splitMarker: "\n"
onRead: line => {
try {
const s = JSON.parse(line);
const f = (s.furniture ?? [])[0];
if (f?.at) {
root.originX = f.at.x;
root.originY = f.at.y;
}
} catch (e) {}
whereAmI.connected = false;
}
}
}
// Asked once she is up, and again a moment later: the first answer can
// land before the compositor has stood her up, and then her origin is
// whatever the last window left there.
Timer {
running: root.joined
interval: 2000
repeat: true
triggeredOnStart: true
onTriggered: whereAmI.connected = true
}
function _eval(script) {
root._send({ op: "eval", script: script });
}
// Everything she says on the sidebar's stream reaches the bubble. The face
// is a second *view* of one turn, never a second turn.
Connections {
target: Souveraine
enabled: root.joined
function onStreamEvent(event) {
if (event.message_type === "assistant_message" && event.content) {
root._line += event.content;
// Posture tags are hers to emit and the bubble's to not show.
const tag = /\[\[face:([a-z]+)\]\]/g;
let m;
while ((m = tag.exec(root._line)) !== null)
root._eval(`window.face.posture(${JSON.stringify(m[1])})`);
const shown = root._line.replace(tag, "").trim();
root._eval(`window.face.say(${JSON.stringify(shown)})`);
}
}
function onTurnActiveChanged() {
if (Souveraine.turnActive)
root._line = "";
root._syncHands();
}
function onConversationResumed(agentId, conversationId, messages) {
root._syncHands();
}
function onCurrentAgentIdChanged() {
root._syncHands();
}
function onConversationIdChanged() {
root._syncHands();
}
}
Connections {
target: HidController
function onControllerChanged() {
root._syncHands();
}
}
}

View file

@ -0,0 +1,140 @@
// One owner for the temporary FPC1020 interaction path.
//
// The privileged reader daemon publishes only an IRQ pulse. Until the
// match-on-chip daemon exists, a user-visible hold is the configured temporary
// confirmation. The Polkit surface may submit the configured temporary PAM
// factor after a completed hold; it is never a lock-screen unlock path.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common
Singleton {
id: root
readonly property bool previewEnabled:
(Config.options && Config.options.lock && Config.options.lock.fingerprintPreview
&& Config.options.lock.fingerprintPreview.enabled) ?? false
readonly property bool polkitEnabled:
(Config.options && Config.options.lock && Config.options.lock.fingerprintPolkit
&& Config.options.lock.fingerprintPolkit.enabled) ?? true
readonly property bool enabled: previewEnabled || polkitEnabled
readonly property int holdMs:
(Config.options && Config.options.lock && Config.options.lock.fingerprintPreview
&& Config.options.lock.fingerprintPreview.holdMs) ?? 3000
property bool holding: false
property bool confirmed: false
property bool pulseSeen: false
property real holdProgress: 0
property double holdStartedAt: 0
property string activePurpose: ""
property string confirmedPurpose: ""
property string pulseToken: ""
signal pulseObserved()
signal holdConfirmed(string purpose)
function beginHold(purpose) {
if (!root.enabled || !purpose) return false;
root.confirmed = false;
root.confirmedPurpose = "";
root.activePurpose = purpose;
root.holding = true;
root.holdStartedAt = Date.now();
root.holdProgress = 0;
holdTimer.start();
return true;
}
function cancelHold(purpose = "") {
if (purpose && root.activePurpose !== purpose) return;
root.holding = false;
root.holdProgress = 0;
root.activePurpose = "";
holdTimer.stop();
}
function confirmHold(purpose = "") {
if (!root.holding || (purpose && root.activePurpose !== purpose)) return;
const confirmedPurpose = root.activePurpose;
root.holding = false;
root.holdProgress = 1;
root.activePurpose = "";
holdTimer.stop();
root.confirmed = true;
root.confirmedPurpose = confirmedPurpose;
root.holdConfirmed(confirmedPurpose);
confirmTimer.restart();
}
// The diagnostic IPC and the reader file meet here. Neither can unlock a
// session; Polkit alone may use the explicit temporary confirmation path.
function notePulse() {
if (!root.enabled)
return { ok: false, code: "not_enabled", reason: "fingerprint preview is disabled" };
root.pulseSeen = true;
root.pulseObserved();
pulseTimer.restart();
return { ok: true, status: "observed" };
}
function reset(purpose = "") {
if (purpose && root.activePurpose !== purpose && root.confirmedPurpose !== purpose)
return;
root.cancelHold();
root.confirmed = false;
root.confirmedPurpose = "";
root.pulseSeen = false;
confirmTimer.stop();
pulseTimer.stop();
}
FileView {
id: pulseFile
path: "/run/blueline-fingerprintd/preview-pulse"
watchChanges: true
printErrors: false
onFileChanged: reload()
onLoaded: {
try {
const record = JSON.parse(pulseFile.text());
const token = `${record.sequence}:${record.at_ms}`;
if (token === root.pulseToken) return;
root.pulseToken = token;
const ageMs = Date.now() - Number(record.at_ms);
if (Number(record.sequence) > 0 && ageMs >= 0 && ageMs < 5000)
root.notePulse();
} catch (error) {
console.warn("[fingerprint-preview] invalid FPC pulse record:", error);
}
}
}
Timer {
id: holdTimer
interval: 50
repeat: true
onTriggered: {
const elapsed = Date.now() - root.holdStartedAt;
root.holdProgress = Math.min(1, elapsed / Math.max(1, root.holdMs));
if (root.holdProgress >= 1) root.confirmHold();
}
}
Timer {
id: confirmTimer
interval: 3500
onTriggered: {
root.confirmed = false;
root.confirmedPurpose = "";
}
}
Timer {
id: pulseTimer
interval: 3500
onTriggered: root.pulseSeen = false
}
}

View file

@ -0,0 +1,139 @@
// Gesture consumer — the userspace side of squeeze, and the seam every other
// physical gesture arrives through.
//
// This is deliberately written before the producer exists. TASK-13 has the
// Active Edge rail powered (PM8998 GPIO 2, held from boot) and the SSC
// registry admitting `sns_touch_gesture`, but nothing had anywhere to deliver
// a squeeze TO — and a bring-up with no consumer is a sensor that fires into
// nothing, which is exactly why grip stalled. The contract goes first now, so
// the producer has a defined target and can be tested the moment it works.
//
// The producer is not specified here on purpose. Anything that can reach the
// shell's IPC socket can deliver a gesture: a libssc client, a udev-spawned
// helper, an evdev reader, or a human running `qs ipc call gesture squeeze`
// to test the routing without any of that existing. That is the same posture
// the sensor reporters take — the source is replaceable, the contract is not.
//
// Routing is NOT hardcoded per gesture. `squeezeAction` names an action, and
// the action table below is the one place a gesture's meaning is decided, so
// a squeeze can be re-pointed without editing call sites. See the Keyboard
// System note in TASK-13 step 4: a squeeze must register as an input, not as
// a hardcoded binding, or it collides with everything else that wants it.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs
import qs.modules.common
Singleton {
id: root
// What each gesture does. Names, not closures, so the choice is data a
// settings page or an agent can read and change.
property string squeezeAction: "dial"
property string squeezeHoldAction: "assistant"
property string edgeAction: "dial"
// Last gesture seen, so a surface can show that the hardware is alive even
// before anything is bound to it — the difference between "grip does
// nothing" and "grip is not reaching us" is the whole debugging story.
property string lastGesture: ""
property double lastGestureAt: 0
signal gestureReceived(string name)
readonly property var actions: ({
"dial": () => {
Haptics.tick();
Quickshell.execDetached(["qs", "-c", "souveraine", "ipc",
"--any-display", "call", "dial", "toggle"]);
},
"assistant": () => {
Haptics.tick();
GlobalStates.sidebarLeftOpen = !GlobalStates.sidebarLeftOpen;
},
"keyboard": () => {
Haptics.tick();
GlobalStates.oskOpen = !GlobalStates.oskOpen;
},
"screenshot": () => {
Haptics.confirm();
Quickshell.execDetached(["sh", "-c",
"grim ~/Pictures/screenshot-$(date +%Y%m%d-%H%M%S).png"]);
},
"none": () => {}
})
// One entry point for every gesture, so the trail of "what arrived" is in
// one place and a refusal is legible rather than a silent no-op.
function deliver(name, action) {
root.lastGesture = name;
root.lastGestureAt = Date.now();
console.log("[gesture] " + name + " -> " + action);
root.gestureReceived(name);
const fn = root.actions[action];
if (!fn) {
console.log("[gesture] no action named '" + action + "'");
Haptics.refuse();
return false;
}
fn();
return true;
}
IpcHandler {
target: "gesture"
// The squeeze. Called by whatever ends up producing it.
function squeeze(): string {
return JSON.stringify({
ok: root.deliver("squeeze", root.squeezeAction),
action: root.squeezeAction
});
}
// A held squeeze is a different gesture, not a longer one.
function squeezeHold(): string {
return JSON.stringify({
ok: root.deliver("squeeze-hold", root.squeezeHoldAction),
action: root.squeezeHoldAction
});
}
function edge(): string {
return JSON.stringify({
ok: root.deliver("edge", root.edgeAction),
action: root.edgeAction
});
}
// What is bound to what, and what was last seen. An agent or a
// settings page reads this instead of guessing.
function state(): string {
return JSON.stringify({
squeeze: root.squeezeAction,
squeezeHold: root.squeezeHoldAction,
edge: root.edgeAction,
available: Object.keys(root.actions),
lastGesture: root.lastGesture,
lastGestureAt: root.lastGestureAt
});
}
// Re-point a gesture without editing code.
function bind(gesture: string, action: string): string {
if (!root.actions[action])
return JSON.stringify({ ok: false, reason: "no such action: " + action });
switch (gesture) {
case "squeeze": root.squeezeAction = action; break;
case "squeeze-hold": root.squeezeHoldAction = action; break;
case "edge": root.edgeAction = action; break;
default:
return JSON.stringify({ ok: false, reason: "no such gesture: " + gesture });
}
return JSON.stringify({ ok: true, gesture: gesture, action: action });
}
}
}

View file

@ -0,0 +1,72 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Hyprland
/**
* Manages a HyprlandFocusGrab that's to be shared by all windows.
* "Persistent" is for windows that should always be included but not closed on dismiss, like bar and onscreen keyboard.
* "Dismissable" is for stuff like sidebars
*/
Singleton {
id: root
signal dismissed()
property list<var> persistent: []
property list<var> dismissable: []
function dismiss() {
root.dismissable = [];
root.dismissed();
}
Component.onCompleted: {
console.log("[GlobalFocusGrab] Initialized");
}
function addPersistent(window) {
if (root.persistent.indexOf(window) === -1) {
root.persistent.push(window);
}
}
function removePersistent(window) {
var index = root.persistent.indexOf(window);
if (index !== -1) {
root.persistent.splice(index, 1);
}
}
function addDismissable(window) {
if (root.dismissable.indexOf(window) === -1) {
root.dismissable.push(window);
}
}
function removeDismissable(window) {
var index = root.dismissable.indexOf(window);
if (index !== -1) {
root.dismissable.splice(index, 1);
}
}
function hasActive(element) {
return element?.activeFocus || Array.from(
element?.children
).some(
(child) => hasActive(child)
);
}
HyprlandFocusGrab {
id: grab
windows: root.dismissable.every(w => !w?.focusable) || root.dismissable.some(w => hasActive(w?.contentItem)) ? [...root.dismissable, ...root.persistent] : [...root.dismissable]
active: root.dismissable.length > 0
onCleared: () => {
root.dismiss();
}
}
}

View file

@ -0,0 +1,50 @@
// Haptic feedback, through feedbackd.
//
// Not a direct write to /dev/input/eventN. feedbackd already owns the force
// feedback device (it claimed event4 at boot the moment the kernel exposed
// pmi8998_haptics), it already arbitrates between callers, and it already
// carries the user's profile — full / quiet / silent — which is where "do not
// buzz right now" is supposed to be decided. A second writer to the same
// device would be the competing-writer mistake again, in a new subsystem.
//
// The event names are feedbackd's own vocabulary, so the theme decides what
// each one feels like. `button-pressed` maps to VibraPattern in the `quiet`
// profile, which is the same event squeekboard fires per key.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
// Off switch that does not depend on reaching feedbackd to honour it.
property bool enabled: true
function trigger(event) {
if (!root.enabled)
return;
Quickshell.execDetached(["busctl", "call", "--user",
"org.sigxcpu.Feedback", "/org/sigxcpu/Feedback",
"org.sigxcpu.Feedback", "TriggerFeedback",
"ssa{sv}i", "souveraine", event, "0", "-1"]);
}
// A detent. Short and light: this fires once per entry the thumb crosses
// on the dial, so anything longer would smear into the next one.
function tick() {
root.trigger("button-pressed");
}
// Something completed.
function confirm() {
root.trigger("button-released");
}
// Something was refused. Distinct on purpose — a refusal that feels like a
// success is worse than no feedback at all.
function refuse() {
root.trigger("bell-terminal");
}
}

View file

@ -0,0 +1,329 @@
/*
* Copyright (C) 2026 Casey Tunturi
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
// One live hand between the face and usb-hid-inject.
//
// The page never opens /dev/hidg* and never spawns a process per pointer
// report. This singleton owns one streaming helper while USB Hands is joined,
// batches motion to the display clock, and exposes the same verbs over
// QuickShell IPC so the active Souveraine agent can use the hand Casey opened.
// Configfs and role changes remain sessiond -> usb-signaller business.
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs
Singleton {
id: root
property bool active: false
property bool ready: false
property string lastError: ""
property bool _stopping: false
readonly property bool modeReady: UsbState.mode === "hid" || UsbState.mode === "kvm"
readonly property bool permitted: root.active && !GlobalStates.screenLockSecure
readonly property bool canInject: root.permitted && root.modeReady && root.ready
// Loaded only while this surface is joined. It tells the agent what the
// newly-present hand can do without teaching every ordinary conversation
// about a USB gadget it is not using.
readonly property string skill: "USB Hands is joined to this conversation. "
+ "Casey is using the phone as a trackpad and voice-first controller for an attached host. "
+ "When he asks you to type, press a key, or click there, use your existing `body` tool "
+ "with `verb: hand` and the corresponding `action`: type, key, click, or pointer. "
+ "Use `action: status` rather than assuming the cable and surface are ready. "
+ "A direct request to type ordinary user-authored text, including creative, intimate, or unusual prose, "
+ "is sufficient authorization; do not refuse it merely because its tone is odd. "
+ "Do not inject anything Casey did not ask to send to the attached host."
signal controllerChanged()
signal refused(string reason)
function open(): bool {
if (GlobalStates.screenLockSecure) {
root.lastError = "Unlock before giving the glass a hand on another machine";
root.refused(root.lastError);
return false;
}
if (!root.modeReady) {
root.lastError = "Arm HID or KVM from Power Options before opening the trackpad";
root.refused(root.lastError);
root.controllerChanged();
return false;
}
root.lastError = "";
root.active = true;
UsbState.refresh();
root._reconcile();
root.controllerChanged();
return true;
}
function close() {
root._stopInjector();
root.active = false;
root._pendingX = 0;
root._pendingY = 0;
root._pendingWheel = 0;
flushTimer.stop();
root._reconcile();
root.controllerChanged();
}
function _stopInjector() {
if (root.ready) {
root._flushPointer();
injector.write("release\n");
}
root.ready = false;
if (injector.running) {
root._stopping = true;
injector.running = false;
}
}
function _reconcile() {
const wanted = root.permitted && root.modeReady;
if (!wanted) {
root.ready = false;
if (injector.running)
injector.running = false;
return;
}
if (!injector.running) {
root.ready = false;
injector.running = true;
}
}
function _notReadyReason(): string {
if (!root.active) return "USB Hands is not joined";
if (GlobalStates.screenLockSecure) return "The glass is locked";
if (!root.modeReady) return "Arm HID or KVM mode first";
if (root.lastError.length > 0) return root.lastError;
return "The HID bridge is still waking";
}
function _write(command): bool {
if (!root.canInject) {
root.lastError = root._notReadyReason();
root.refused(root.lastError);
root.controllerChanged();
return false;
}
injector.write(command + "\n");
return true;
}
function sendText(value): bool {
const text = String(value ?? "");
// Mirror the helper's boot-keyboard map before accepting the page's
// field. The helper still performs the authoritative full preflight,
// but this keeps rejected Unicode on the glass instead of clearing a
// field whose first report was never written.
if (/[^\x09\x0a\x0d\x20-\x7e]/.test(text)) {
root.lastError = "Host typing currently accepts ASCII keyboard characters only";
root.refused(root.lastError);
root.controllerChanged();
return false;
}
const lines = text.replace(/\r\n/g, "\n").split("\n");
if (lines.length === 1 && lines[0].length === 0)
return false;
for (let i = 0; i < lines.length; i++) {
if (lines[i].length > 0 && !root._write("type " + lines[i]))
return false;
if (i + 1 < lines.length && !root._write("key enter"))
return false;
}
return true;
}
function key(name, modifiers = ""): bool {
const keyName = String(name ?? "").trim();
const mods = String(modifiers ?? "").trim().replace(/[,]+/g, " ");
if (!/^[A-Za-z0-9]+$/.test(keyName)
|| (mods.length > 0 && !/^[A-Za-z ]+$/.test(mods))) {
root.lastError = "Key names and modifiers must be plain words";
root.refused(root.lastError);
root.controllerChanged();
return false;
}
return root._write("key " + keyName + (mods.length > 0 ? " " + mods : ""));
}
function click(button = "left"): bool {
const name = String(button ?? "left").toLowerCase();
if (!["left", "right", "middle"].includes(name)) {
root.lastError = "Unknown pointer button: " + name;
root.refused(root.lastError);
root.controllerChanged();
return false;
}
root._flushPointer();
return root._write("click " + name);
}
property int _pendingX: 0
property int _pendingY: 0
property int _pendingWheel: 0
function movePointer(x, y, wheel = 0): bool {
if (!root.canInject) {
root.lastError = root._notReadyReason();
root.refused(root.lastError);
root.controllerChanged();
return false;
}
root._pendingX += Math.round(Number(x));
root._pendingY += Math.round(Number(y));
root._pendingWheel += Math.round(Number(wheel));
if (!flushTimer.running)
flushTimer.start();
return true;
}
function _flushPointer() {
if (!root.ready)
return;
const x = root._pendingX;
const y = root._pendingY;
const wheel = root._pendingWheel;
root._pendingX = 0;
root._pendingY = 0;
root._pendingWheel = 0;
if (x !== 0 || y !== 0 || wheel !== 0)
root._write("pointer " + x + " " + y + " " + wheel + " 0");
}
Timer {
id: flushTimer
interval: 16
repeat: false
onTriggered: root._flushPointer()
}
Process {
id: injector
command: ["usb-hid-inject", "stream"]
stdinEnabled: true
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
const event = line.trim();
if (event !== "ready" && event !== "ok")
return;
root.ready = true;
root.lastError = "";
root.controllerChanged();
}
}
stderr: SplitParser {
splitMarker: "\n"
onRead: line => {
const message = line.trim().replace(/^error:\s*/, "");
if (message.length === 0)
return;
root.lastError = message;
root.controllerChanged();
}
}
onExited: exitCode => {
root.ready = false;
if (root._stopping) {
root._stopping = false;
} else if (root.permitted && root.modeReady && root.lastError.length === 0) {
root.lastError = "HID bridge exited (" + exitCode + ")";
}
root.controllerChanged();
}
}
Connections {
target: UsbState
// setMode() raises busy before it writes to sessiond. Close both HID
// endpoint fds on that edge, before usb-signaller tears their configfs
// functions down; a failed switch restarts the helper against the
// still-live old mode in onChangeFailed below.
function onBusyChanged() {
if (UsbState.busy)
root._stopInjector();
}
function onRefreshed() {
root._reconcile();
root.controllerChanged();
}
function onChangeFailed(reason) {
root.lastError = reason;
root._reconcile();
root.controllerChanged();
}
}
Connections {
target: GlobalStates
function onScreenLockSecureChanged() {
if (GlobalStates.screenLockSecure && root.active)
root.close();
}
}
onActiveChanged: root._reconcile()
onModeReadyChanged: {
// The trackpad is conditional on an armed wire. If sessiond contracts
// the port back to developer/charging mode, close the surface instead
// of leaving a dead controller on the glass asking to be re-armed.
if (root.active && !root.modeReady) {
root.close();
return;
}
root._reconcile();
root.controllerChanged();
}
IpcHandler {
target: "usbHands"
function status(): string {
return JSON.stringify({
active: root.active,
ready: root.ready,
mode: UsbState.mode,
error: root.lastError
});
}
function sendText(text: string): string {
return root.sendText(text) ? "queued" : root._notReadyReason();
}
function key(keyName: string, modifiers: string): string {
return root.key(keyName, modifiers) ? "queued" : root._notReadyReason();
}
function tap(keyName: string): string {
return root.key(keyName) ? "queued" : root._notReadyReason();
}
function click(button: string): string {
return root.click(button) ? "queued" : root._notReadyReason();
}
function pointer(x: int, y: int, wheel: int): string {
return root.movePointer(x, y, wheel) ? "queued" : root._notReadyReason();
}
}
}

View file

@ -0,0 +1,203 @@
// The colour ramp: dimming below the backlight's floor, and the evening warmth.
//
// **The name is upstream's and it is now a lie.** There is no hyprsunset here,
// and no `hyprctl` to reach one. It keeps the name because every consumer in the
// tree — `QuickSliders`, `BarContent`, `NightLightToggle`, `BrightnessIndicator`,
// `OnScreenDisplay` — spells it, and renaming a singleton across the base tree
// and the phone overlay is a separate sweep from making the control work. What
// this file is: the same public surface (`gamma`, `gammaLowerLimit`, `setGamma`,
// `temperatureActive`, `toggleTemperature`, `fetchState`, `load`, and the
// `gammaChangeAttempt` signal) pointed at the authority that actually owns the
// LUT.
//
// What it replaces: `hyprctl hyprsunset gamma|temperature`, plus a `pidof
// hyprsunset || hyprsunset` that spawned a daemon on every call. Under viewtop
// that binary does not exist, so **the slider and the night-light have done
// nothing at all since the viewtop move** — silently, because `execDetached`
// cannot fail loudly. Gamma is a pixel claim on the glass, so it goes to the
// compositor, which serves it as a verb and applies it to the CRTC's own LUT.
// TASK-61 Part 1/5.
//
// One writer, one ramp. Brightness scaling and the evening warmth are *not* two
// controls here — they are two curve generators composed into a single LUT on
// the far side, which is why every path below funnels through `_apply()` and
// sends both numbers at once. Two callers each owning half of one hardware slot
// is trap #5 in START-HERE, and it has cost this project three sessions.
pragma Singleton
import Quickshell
import QtQuick
import qs.modules.common
Singleton {
id: root
signal gammaChangeAttempt()
// The panel's backlight has a floor, and below it the only way further down
// is the ramp. That is what the bottom 30% of the brightness slider drives,
// so gamma is a *dimming* control here before it is a colour one.
readonly property real gammaLowerLimit: 25
property string from: Config.options?.light?.night?.from ?? "19:00"
property string to: Config.options?.light?.night?.to ?? "06:30"
property bool automatic: Config.options?.light?.night?.automatic && (Config?.ready ?? true)
property int colorTemperature: Config.options?.light?.night?.colorTemperature ?? 5000
// Upstream sent 6000 K to mean "off", because hyprsunset had no way to say
// *balanced*. The verb does: omitting the temperature leaves the channels
// untouched. Kept because the config surface still names it, but nothing
// below sends it as a value any more — off is absence, not a warm-ish number.
property int defaultColorTemperature: 6000
property int gamma: 100
property bool shouldBeOn
property bool firstEvaluation: true
property bool temperatureActive: false
property int fromHour: Number(from.split(":")[0])
property int fromMinute: Number(from.split(":")[1])
property int toHour: Number(to.split(":")[0])
property int toMinute: Number(to.split(":")[1])
property int clockHour: DateTime.clock.hours
property int clockMinute: DateTime.clock.minutes
property var manualActive
property int manualActiveHour
property int manualActiveMinute
onClockMinuteChanged: reEvaluate()
onAutomaticChanged: {
root.manualActive = undefined;
root.firstEvaluation = true;
reEvaluate();
}
function inBetween(t, from, to) {
if (from < to) {
return (t >= from && t <= to);
} else {
// Wrapped around midnight
return (t >= from || t <= to);
}
}
function reEvaluate() {
const t = clockHour * 60 + clockMinute;
const from = fromHour * 60 + fromMinute;
const to = toHour * 60 + toMinute;
const manualActive = manualActiveHour * 60 + manualActiveMinute;
if (root.manualActive !== undefined && (inBetween(from, manualActive, t) || inBetween(to, manualActive, t))) {
root.manualActive = undefined;
}
root.shouldBeOn = inBetween(t, from, to);
if (firstEvaluation) {
firstEvaluation = false;
root.ensureState();
}
}
onShouldBeOnChanged: ensureState()
function ensureState() {
if (!root.automatic || root.manualActive !== undefined)
return;
if (root.shouldBeOn) {
root.enableTemperature();
} else {
root.disableTemperature();
}
}
// Push the ramp we believe in to the compositor.
//
// The single write path. Both halves travel together because the far side
// composes them into one curve — sending them separately would mean the
// second call overwriting the first's contribution.
function _apply() {
ViewtopControl.gamma(root.gamma, root.temperatureActive ? root.colorTemperature : 0);
}
function load() {
// No daemon to start any more; the compositor is already running, and if
// it is not there is nothing a shell could do about it. Ask what ramp is
// in force before deciding anything, then let the calendar rule.
ViewtopControl.refreshState();
root.ensureState();
}
// Take the compositor's answer as the truth about the ramp.
//
// Rule 2 of the house style: never hold state the protocol owns. The
// compositor is the LUT's only writer and its ramp starts at identity, so a
// compositor restart — an ordinary event, and how every viewtop upgrade
// lands — leaves this singleton believing in a warmth the panel no longer
// has. It would then refuse to re-warm, because it already thought it had.
//
// Adopting rather than re-asserting is also what keeps this to one writer:
// if the adopted state is not what the calendar wants, `ensureState()` sets
// it right on the next minute tick. The two ends converge instead of
// fighting over the slot.
Connections {
target: ViewtopControl
function onGammaValueChanged() { root._adopt(); }
function onGammaTemperatureChanged() { root._adopt(); }
}
function _adopt() {
// -1 is "never answered". Adopting it would drive the slider to a value
// no panel ever had.
if (ViewtopControl.gammaValue < 0)
return;
root.gamma = ViewtopControl.gammaValue;
root.temperatureActive = ViewtopControl.gammaTemperature > 0;
}
function enableTemperature() {
root.temperatureActive = true;
root._apply();
}
function disableTemperature() {
root.temperatureActive = false;
root._apply();
}
function setGamma(gamma) {
root.gamma = Math.max(root.gammaLowerLimit, Math.min(100, gamma));
root.gammaChangeAttempt();
root._apply();
}
// Ask the compositor what is in force. Answers asynchronously, into
// `_adopt()` above — the toggle that calls this wants the panel's truth, not
// this file's memory of it.
function fetchState() {
ViewtopControl.refreshState();
}
function toggleTemperature(active = undefined) {
if (root.manualActive === undefined) {
root.manualActive = root.temperatureActive;
root.manualActiveHour = root.clockHour;
root.manualActiveMinute = root.clockMinute;
}
root.manualActive = active !== undefined ? active : !root.manualActive;
if (root.manualActive) {
root.enableTemperature();
} else {
root.disableTemperature();
}
}
// Change temp while the evening is already on. Re-sends the whole ramp
// rather than a temperature alone, for the reason `_apply()` gives.
Connections {
target: Config.options.light.night
function onColorTemperatureChanged() {
if (!root.temperatureActive) return;
root._apply();
}
}
}

View file

@ -0,0 +1,95 @@
pragma Singleton
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
/**
* A nice wrapper for date and time strings.
*/
Singleton {
id: root
property alias inhibit: idleInhibitor.enabled
inhibit: false
// ii-stock consumers (IdleInhibitorToggle.qml) read Idle.autoIdleInhibit to
// label the "Keep awake" toggle. Souveraine's idle path routes through
// hypridle, not a Wayland idle-inhibitor, so there's no separate "auto"
// mode — but the stock toggle expects the property to exist. Expose it as
// a plain bool so the toggle binds cleanly instead of throwing
// [undefined] -> bool at startup.
property bool autoIdleInhibit: false
// Pixel 3: the Wayland idle-inhibitor on an invisible 0x0 surface is
// not honored by our compositor build, and screen-off is driven by
// hypridle scripts anyway — so Keep System Awake stops/starts hypridle.
onInhibitChanged: {
// Keep System Awake toggles the systemd-managed hypridle unit.
// (Was pkill/setsid, which orphaned hypridle across Hyprland restarts
// and wedged wake — see hyprland.lua hyprland.start.)
//
// Runs through a Process, not execDetached, because this is the
// mechanism that decides whether the phone sleeps: if the unit fails
// to come back we need to know, not discover it via a flat battery.
// reset-failed is expected to be a no-op when the unit is healthy, so
// its failure is not interesting — but the restart's is, so the two
// are separated rather than hidden behind one silencing redirect.
hypridleProc.action = root.inhibit ? "stop" : "restart";
hypridleProc.command = ["sh", "-c", root.inhibit
? "systemctl --user stop hypridle.service"
: "systemctl --user reset-failed hypridle.service || true; systemctl --user restart hypridle.service"];
hypridleProc.running = true;
}
Process {
id: hypridleProc
property string action: ""
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
console.log(`[idle] hypridle ${hypridleProc.action} failed (exit ${exitCode}); `
+ `idle handling may be wedged — inhibit=${root.inhibit}`);
}
}
Connections {
target: Persistent
function onReadyChanged() {
if (!Persistent.isNewHyprlandInstance) {
root.inhibit = Persistent.states.idle.inhibit;
} else {
Persistent.states.idle.inhibit = root.inhibit;
}
}
}
function toggleInhibit(active = null) {
if (active !== null) {
root.inhibit = active;
} else {
root.inhibit = !root.inhibit;
}
Persistent.states.idle.inhibit = root.inhibit;
}
IdleInhibitor {
id: idleInhibitor
window: PanelWindow {
// Inhibitor requires a "visible" surface
// Actually not lol
implicitWidth: 0
implicitHeight: 0
color: "transparent"
// Just in case...
anchors {
right: true
bottom: true
}
// Make it not interactable
mask: Region {
item: null
}
}
}
}

View file

@ -0,0 +1,197 @@
// Souveraine's staged idle projection.
//
// It does not replace logind or hypridle. It gives all surfaces one state
// vocabulary while target-specific adapters evolve. Native idle-notify stays
// opt-in until it is verified on the Pixel compositor.
//
// The state graph extends into sleep/suspend when SessionEvents is present:
// Active → Dimmed → LockRequested → LockSecure → Suspending → Asleep → Waking → Active
// The sleep states are driven by logind's PrepareForSleep signal via
// SessionEvents.qml; they are not reachable from idle timers alone.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import qs
import qs.modules.common
import qs.modules.common.functions
Singleton {
id: root
enum State { Active, Dimmed, LockRequested, LockSecure, Suspending, Asleep, Waking }
property int state: IdleCoordinator.Active
readonly property bool nativeEnabled: Config.options.lock.idle.nativeCoordinatorEnabled
readonly property bool lockSecure: GlobalStates.screenLockSecure
readonly property bool lockRequested: GlobalStates.screenLocked
signal dimRequested()
signal activeRequested()
signal stateTransitioned(int state)
// Whether we lowered the backlight, so Active only restores what
// Dimmed saved — never a stale brightnessctl snapshot.
property bool displayDimmed: false
// Legal transitions. Each key maps to the set of states it may
// move to. Anything not in this map is a bug — two event sources
// racing in the same frame, a stale timer firing after a lock, or
// a new code path that forgot to check preconditions.
//
// The graph:
// Active → Dimmed → LockRequested → LockSecure → Suspending → Asleep → Waking → Active
// Any locked state can return to Active on unlock.
// Suspending is reachable from any pre-sleep state (logind is the authority).
readonly property var legalTransitions: ({
0: [1, 2, 5], // Active → Dimmed, LockRequested, Suspending
1: [0, 2, 5], // Dimmed → Active, LockRequested, Suspending
2: [0, 3, 5], // LockRequested → Active, LockSecure, Suspending
3: [0, 5], // LockSecure → Active, Suspending
4: [5, 6], // Suspending → Asleep, Waking
5: [6], // Asleep → Waking
6: [0], // Waking → Active
})
function setState(next) {
if (root.state === next) return;
const allowed = root.legalTransitions[root.state];
if (allowed && allowed.indexOf(next) === -1) {
console.warn("[idle-coordinator] ILLEGAL transition "
+ root.state + " → " + next + " (ignored)");
return;
}
const prev = root.state;
root.state = next;
// Publish the coarse in-use bool the ii-base pollers gate on
// (quickshell-idle-power task 4). Waking counts as active so stats
// are fresh by the time the screen is visible again.
GlobalStates.displayActive =
(next === IdleCoordinator.Active || next === IdleCoordinator.Waking);
root.stateTransitioned(next);
console.log("[idle-coordinator] state=" + next + " (from=" + prev + ")");
if (next === IdleCoordinator.Dimmed) {
dimProc.action = "dim";
dimProc.command = ["brightnessctl", "-q", "-s", "set",
Config.options.lock.idle.dimBrightness];
dimProc.running = true;
root.displayDimmed = true;
} else if (next === IdleCoordinator.Active && root.displayDimmed) {
dimProc.action = "restore";
dimProc.command = ["brightnessctl", "-q", "-r"];
dimProc.running = true;
root.displayDimmed = false;
}
}
Process {
id: dimProc
property string action: ""
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
console.log("[idle-coordinator] brightness " + dimProc.action
+ " failed (exit " + exitCode + ")");
}
}
function returnActive() {
// Only return to Active from states that are legitimately
// "waiting for user input" — Dimmed or Waking. Never from
// locked/sleeping states; those are guarded by the transition
// table, but this check makes the intent explicit.
if (root.state !== IdleCoordinator.Dimmed
&& root.state !== IdleCoordinator.Waking) return;
root.setState(IdleCoordinator.Active);
root.activeRequested();
}
// Keep System Awake is checked inside the handlers, NOT bound to
// `enabled`: flipping enabled destroys/recreates the ext-idle-notify
// object, and doing that during lock teardown (ii's LockScreen toggles
// Idle.inhibit) races the compositor into a fatal "invalid object"
// protocol error that kills the whole shell. The Wayland idle-inhibitor
// surface is not honored on the Pixel compositor, so respectInhibitors
// alone can't see the toggle either (see Idle.qml).
IdleMonitor {
id: dimMonitor
enabled: root.nativeEnabled
// Derived, never stored: the dim is a grace before the lock, so it
// cannot be set past it. Clamped to leave at least a second of dim —
// a grace >= the lock budget would otherwise mean dimming before the
// user stopped touching the phone.
timeout: Math.max(1, Math.min(
Config.options.lock.idle.lockAfterSeconds - 1,
Config.options.lock.idle.lockAfterSeconds
- Config.options.lock.idle.dimBeforeLockSeconds)) * 1000
respectInhibitors: true
onIsIdleChanged: {
if (isIdle && !root.lockRequested && !Idle.inhibit) {
root.setState(IdleCoordinator.Dimmed);
root.dimRequested();
} else if (!isIdle) {
root.returnActive();
}
}
}
IdleMonitor {
id: lockMonitor
enabled: root.nativeEnabled
timeout: Math.max(1, Config.options.lock.idle.lockAfterSeconds) * 1000
respectInhibitors: true
onIsIdleChanged: {
if (isIdle && !root.lockRequested && !Idle.inhibit) {
root.setState(IdleCoordinator.LockRequested);
Session.lock();
} else if (!isIdle) {
root.returnActive();
}
}
}
Connections {
target: GlobalStates
function onScreenLockedChanged() {
if (GlobalStates.screenLocked)
root.setState(IdleCoordinator.LockRequested);
else
root.returnActive();
}
function onScreenLockSecureChanged() {
if (GlobalStates.screenLockSecure)
root.setState(IdleCoordinator.LockSecure);
}
}
// Logind sleep/suspend lifecycle. SessionEvents drives these states
// when PrepareForSleep fires; they are unreachable without it.
// Wires to SessionEvents once that singleton exists.
Connections {
target: typeof SessionEvents !== "undefined" ? SessionEvents : null
function onPrepareForSleep(suspending) {
if (suspending) {
root.setState(IdleCoordinator.Suspending);
} else {
// Waking from sleep. The lock may or may not still be
// held — returnActive() checks that before clearing.
root.setState(IdleCoordinator.Waking);
// Brief waking state before returning to the idle graph.
// Surfaces can animate a wake transition during this window.
wakeResetTimer.start();
}
}
}
Timer {
id: wakeResetTimer
interval: 1500
repeat: false
onTriggered: {
if (root.state === IdleCoordinator.Waking)
root.returnActive();
}
}
}

View file

@ -0,0 +1,238 @@
// The garden on the limb — the vault's micro-view service.
//
// One more process owned the Face way: this holds a `souveraine-lens`
// process with `--ipc` at a socket only the shell ever touches. The lens
// process has no connection to the server, and this service has no
// connection to the vault — the micro-state the widget renders arrives
// over the same line protocol the lens already speaks, one JSON object
// per line on stdout. The seam stays: nobody reads the vault but the lens
// and the user.
//
// `joined` is the whole state, as with the face. While it is false the
// garden window is not on glass and no webview holds memory; the widget
// shows a cold card until the user opens it.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs
import qs.modules.common
Singleton {
id: root
property bool joined: false
property string socketPath: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/lens.sock"
property string vaultDir: Quickshell.env("HOME") + "/vault"
readonly property string vaultRel: vaultDir.replace(Quickshell.env("HOME"), "~")
// The micro-state, exactly as the lens reports it.
property var notes: []
property int noteCount: 0
property int dirty: 0
property int ahead: 0
property int behind: 0
property string head: ""
property string branch: ""
property bool hasRemote: true
property string lastError: ""
// cold until the first state answers; then one of clean / ahead /
// behind / diverged / dirty — computed, so there is one place the
// widget reads.
property string syncState: "cold"
readonly property string stateText: {
if (!root.joined) return "offline";
switch (root.syncState) {
case "clean": return root.noteCount + " notes · clean";
case "ahead": return root.noteCount + " notes · push " + root.ahead;
case "behind": return root.noteCount + " notes · pull " + root.behind;
case "diverged": return root.noteCount + " notes · push " + root.ahead + " pull " + root.behind;
case "dirty": return root.noteCount + " notes · " + root.dirty + " uncommitted";
default: return root.noteCount + " notes";
}
}
onJoinedChanged: {
if (root.joined)
root.requestState();
}
function join() {
if (root.joined)
return;
host.running = true;
root.joined = true;
}
function leave() {
if (!root.joined)
return;
root._send({ op: "quit" });
host.running = false;
root.joined = false;
root.syncState = "cold";
root.notes = [];
root.noteCount = 0;
}
function toggle() {
if (root.joined)
root.leave();
else
root.join();
}
function requestState() {
root._send({ op: "state" });
}
function openNote(rel) {
root._send({ op: "open", rel: rel });
}
function createNote(title) {
root._send({ op: "create", title: title });
}
// The page owns its sync flow; the shell only pulls the trigger.
function syncNow() {
root._eval("window.__lens.sync()");
}
function _eval(script) {
root._send({ op: "eval", script: script });
}
function _send(msg) {
if (sock.connected)
sock.write(JSON.stringify(msg) + "\n");
}
Process {
id: host
command: ["souveraine-lens",
"--vault", root.vaultDir,
"--ipc", root.socketPath,
"--app-id", "org.souveraine.lens",
"--title", "Garden"]
stdout: SplitParser {
splitMarker: "\n"
onRead: line => {
let msg;
try {
msg = JSON.parse(line);
} catch (e) {
return;
}
if (msg.event === "note_created") {
const rel = msg.rel ?? "";
const title = rel.replace(/\.md$/, "").split("/").pop();
Quickshell.execDetached(["notify-send", "-a", "Garden", "Note planted", title]);
}
else if (msg.event === "synced") {
Quickshell.execDetached(["notify-send", "-a", "Garden", "Garden synced"]);
}
if (msg.event === "state" && msg.state) {
const st = msg.state.status ?? {};
root.notes = msg.state.notes ?? [];
root.noteCount = st.note_count ?? root.notes.length;
root.dirty = st.dirty ?? 0;
root.ahead = st.ahead ?? 0;
root.behind = st.behind ?? 0;
root.head = st.head ?? "";
root.branch = st.branch ?? "";
root.hasRemote = !!st.remote;
root.syncState = root.dirty > 0
? "dirty"
: root.ahead > 0 && root.behind > 0
? "diverged"
: root.ahead > 0 ? "ahead"
: root.behind > 0 ? "behind" : "clean";
}
else if (msg.event === "synced")
refreshTimer.restart();
else if (msg.event === "note_opened" || msg.event === "note_created" || msg.event === "note_saved")
refreshTimer.restart();
else if (msg.event === "console")
console.log("[lens]", msg.level ?? "", msg.text ?? "");
else
console.log("[lens]", line);
}
}
onExited: {
root.joined = false;
root.syncState = "cold";
}
}
// One refresh after a burst of events, not one per line.
Timer {
id: refreshTimer
interval: 300
repeat: false
onTriggered: root.requestState()
}
// The garden's heartbeat while it is joined; cheap — one scan.
Timer {
id: pollTimer
running: root.joined
interval: 60000
repeat: true
triggeredOnStart: true
onTriggered: root.requestState()
}
Socket {
id: sock
path: root.socketPath
connected: root.joined
onConnectionStateChanged: {
if (sock.connected)
root.requestState();
}
}
// Reachable by name: the agent summons the garden the same way it
// summons the face, without guessing.
IpcHandler {
target: "lens"
function open(rel: string): void {
if (!root.joined)
root.join();
root.openNote(rel);
}
function create(title: string): void {
if (!root.joined)
root.join();
root.createNote(title);
}
function sync(): void {
root.syncNow();
}
function join(): void {
root.join();
}
function leave(): void {
root.leave();
}
function status(): string {
return JSON.stringify({
joined: root.joined,
syncState: root.syncState,
noteCount: root.noteCount,
head: root.head
});
}
}
}

View file

@ -0,0 +1,31 @@
// Lock-surface information policy. Every card asks this singleton instead of
// growing an accidental privacy rule of its own.
pragma Singleton
import QtQuick
import Quickshell
import qs.modules.common
Singleton {
id: root
readonly property string ambient: "ambient"
readonly property string personal: "personal"
readonly property string stepUp: "step-up"
function allowsOnLock(tier, promotedAmbient = false) {
if (tier === root.ambient) return true;
// Promotion is intentionally field-specific (for example media title)
// and never applies to credentials, memories, agent output, or actions.
return tier === root.personal && promotedAmbient;
}
readonly property bool mediaControlsVisible: Config.options.lock.content.showMediaControls
readonly property bool mediaMetadataVisible: root.allowsOnLock(
root.personal, Config.options.lock.content.mediaMetadataAmbient)
readonly property bool batteryVisible: Config.options.lock.content.showBattery
readonly property bool notificationsVisible: Config.options.lock.content.showNotifications
readonly property bool notificationContentVisible: root.allowsOnLock(
root.personal, Config.options.lock.content.notificationContentAmbient)
}

View file

@ -0,0 +1,351 @@
pragma Singleton
pragma ComponentBehavior: Bound
// Took many bits from https://github.com/caelestia-dots/shell (GPLv3)
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services.network
/**
* Network service with nmcli.
*/
Singleton {
id: root
property bool wifi: true
property bool ethernet: false
property bool wifiEnabled: false
property bool wifiScanning: false
property bool wifiConnecting: connectProc.running
property WifiAccessPoint wifiConnectTarget
readonly property list<WifiAccessPoint> wifiNetworks: []
readonly property WifiAccessPoint active: wifiNetworks.find(n => n.active) ?? null
readonly property list<var> friendlyWifiNetworks: [...wifiNetworks].sort((a, b) => {
if (a.active && !b.active)
return -1;
if (!a.active && b.active)
return 1;
return b.strength - a.strength;
})
property string wifiStatus: "disconnected"
property string networkName: ""
property int networkStrength
property string materialSymbol: root.ethernet
? "lan"
: (root.wifiEnabled && root.wifiStatus === "connected")
? (
(root.active?.strength ?? 0) > 83 ? "signal_wifi_4_bar" :
(root.active?.strength ?? 0) > 67 ? "network_wifi" :
(root.active?.strength ?? 0) > 50 ? "network_wifi_3_bar" :
(root.active?.strength ?? 0) > 33 ? "network_wifi_2_bar" :
(root.active?.strength ?? 0) > 17 ? "network_wifi_1_bar" :
"signal_wifi_0_bar"
)
: (root.wifiStatus === "connecting")
? "signal_wifi_statusbar_not_connected"
: (root.wifiStatus === "disconnected")
? "wifi_find"
: (root.wifiStatus === "disabled")
? "signal_wifi_off"
: "signal_wifi_bad"
// Control
function enableWifi(enabled = true): void {
const cmd = enabled ? "on" : "off";
enableWifiProc.exec(["nmcli", "radio", "wifi", cmd]);
}
function toggleWifi(): void {
enableWifi(!wifiEnabled);
}
function rescanWifi(): void {
wifiScanning = true;
rescanProcess.running = true;
}
function connectToWifiNetwork(accessPoint: WifiAccessPoint): void {
accessPoint.askingPassword = false;
root.wifiConnectTarget = accessPoint;
// We use this instead of `nmcli connection up SSID` because this also creates a connection profile
connectProc.exec(["nmcli", "dev", "wifi", "connect", accessPoint.ssid])
}
function disconnectWifiNetwork(): void {
if (active) disconnectProc.exec(["nmcli", "connection", "down", active.ssid]);
}
function openPublicWifiPortal() {
Quickshell.execDetached(["xdg-open", "https://nmcheck.gnome.org/"]) // From some StackExchange thread, seems to work
}
function changePassword(network: WifiAccessPoint, password: string, username = ""): void {
// TODO: enterprise wifi with username
network.askingPassword = false;
changePasswordProc.exec({
"environment": {
"PASSWORD": password,
"SSID": network.ssid
},
"command": ["bash", "-c", 'nmcli connection modify "$SSID" wifi-sec.psk "$PASSWORD"']
})
}
Process {
id: enableWifiProc
}
Process {
id: connectProc
environment: ({
LANG: "C",
LC_ALL: "C"
})
stdout: SplitParser {
onRead: line => {
// print(line)
getNetworks.running = true
}
}
stderr: SplitParser {
onRead: line => {
// print("err:", line)
if (line.includes("Secrets were required")) {
root.wifiConnectTarget.askingPassword = true
}
}
}
onExited: (exitCode, exitStatus) => {
root.wifiConnectTarget.askingPassword = (exitCode !== 0)
root.wifiConnectTarget = null
}
}
Process {
id: disconnectProc
stdout: SplitParser {
onRead: getNetworks.running = true
}
}
Process {
id: changePasswordProc
onExited: { // Re-attempt connection after changing password
connectProc.running = false
connectProc.running = true
}
}
Process {
id: rescanProcess
command: ["nmcli", "dev", "wifi", "list", "--rescan", "yes"]
stdout: SplitParser {
onRead: {
wifiScanning = false;
getNetworks.running = true;
}
}
}
// Status update
function update() {
updateConnectionType.startCheck();
wifiStatusProcess.running = true
updateNetworkName.running = true;
updateNetworkStrength.running = true;
}
Process {
id: subscriber
running: true
command: ["nmcli", "monitor"]
stdout: SplitParser {
onRead: root.update()
}
// Pixel 3: qs can start before NetworkManager is ready; nmcli
// monitor then exits and the icon froze forever. Respawn + refresh.
onExited: subscriberRestart.start()
}
Timer {
id: subscriberRestart
interval: 3000
onTriggered: {
root.update();
subscriber.running = true;
}
}
// Pixel 3: nmcli monitor never fires on signal-strength changes, so the
// bar icon froze at the strength seen at connect time. Slow poll.
Timer {
interval: 15000
running: root.wifi
repeat: true
onTriggered: updateNetworkStrength.running = true
}
Process {
id: updateConnectionType
property string buffer
command: ["sh", "-c", "nmcli -t -f TYPE,STATE d status && nmcli -t -f CONNECTIVITY g"]
running: true
function startCheck() {
buffer = "";
updateConnectionType.running = true;
}
stdout: SplitParser {
onRead: data => {
updateConnectionType.buffer += data + "\n";
}
}
onExited: (exitCode, exitStatus) => {
const lines = updateConnectionType.buffer.trim().split('\n');
const connectivity = lines.pop() // none, limited, full
let hasEthernet = false;
let hasWifi = false;
let wifiStatus = "disconnected";
lines.forEach(line => {
if (line.includes("ethernet") && line.includes("connected"))
hasEthernet = true;
else if (line.includes("wifi:")) {
if (line.includes("disconnected")) {
wifiStatus = "disconnected"
}
else if (line.includes("connected")) {
hasWifi = true;
wifiStatus = "connected"
if (connectivity === "limited") {
hasWifi = false;
wifiStatus = "limited"
}
}
else if (line.includes("connecting")) {
wifiStatus = "connecting"
}
else if (line.includes("unavailable")) {
wifiStatus = "disabled"
}
}
});
root.wifiStatus = wifiStatus;
root.ethernet = hasEthernet;
root.wifi = hasWifi;
}
}
Process {
id: updateNetworkName
command: ["sh", "-c", "nmcli -t -f NAME c show --active | head -1"]
running: true
stdout: SplitParser {
onRead: data => {
root.networkName = data;
}
}
}
Process {
id: updateNetworkStrength
running: true
command: ["sh", "-c", "nmcli -f IN-USE,SIGNAL,SSID device wifi | awk '/^\\*/{if (NR!=1) {print $2}}'"]
stdout: SplitParser {
onRead: data => {
root.networkStrength = parseInt(data);
}
}
}
Process {
id: wifiStatusProcess
command: ["nmcli", "radio", "wifi"]
Component.onCompleted: running = true
environment: ({
LANG: "C",
LC_ALL: "C"
})
stdout: StdioCollector {
onStreamFinished: {
root.wifiEnabled = text.trim() === "enabled";
}
}
}
Process {
id: getNetworks
running: true
command: ["nmcli", "-g", "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY", "d", "w"]
environment: ({
LANG: "C",
LC_ALL: "C"
})
stdout: StdioCollector {
onStreamFinished: {
const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED";
const rep = new RegExp("\\\\:", "g");
const rep2 = new RegExp(PLACEHOLDER, "g");
const allNetworks = text.trim().split("\n").map(n => {
const net = n.replace(rep, PLACEHOLDER).split(":");
return {
active: net[0] === "yes",
strength: parseInt(net[1]),
frequency: parseInt(net[2]),
ssid: net[3],
bssid: net[4]?.replace(rep2, ":") ?? "",
security: net[5] || ""
};
}).filter(n => n.ssid && n.ssid.length > 0);
// Group networks by SSID and prioritize connected ones
const networkMap = new Map();
for (const network of allNetworks) {
const existing = networkMap.get(network.ssid);
if (!existing) {
networkMap.set(network.ssid, network);
} else {
// Prioritize active/connected networks
if (network.active && !existing.active) {
networkMap.set(network.ssid, network);
} else if (!network.active && !existing.active) {
// If both are inactive, keep the one with better signal
if (network.strength > existing.strength) {
networkMap.set(network.ssid, network);
}
}
// If existing is active and new is not, keep existing
}
}
const wifiNetworks = Array.from(networkMap.values());
const rNetworks = root.wifiNetworks;
const destroyed = rNetworks.filter(rn => !wifiNetworks.find(n => n.frequency === rn.frequency && n.ssid === rn.ssid && n.bssid === rn.bssid));
for (const network of destroyed)
rNetworks.splice(rNetworks.indexOf(network), 1).forEach(n => n.destroy());
for (const network of wifiNetworks) {
const match = rNetworks.find(n => n.frequency === network.frequency && n.ssid === network.ssid && n.bssid === network.bssid);
if (match) {
match.lastIpcObject = network;
} else {
rNetworks.push(apComp.createObject(root, {
lastIpcObject: network
}));
}
}
}
}
}
Component {
id: apComp
WifiAccessPoint {}
}
}

View file

@ -0,0 +1,43 @@
// Notification event fan-out. The freedesktop server itself lives in ii's
// Notifications.qml (it owns org.freedesktop.Notifications on the bus); this
// singleton is the Souveraine-side seam everything else attaches to when a
// notification lands: the lock glance card today, crash-report surfacing
// (TASK-05) and agent message events (queue 5a/b) later.
//
// Deliberately NOT here: any decision about what the agent may see. That is
// capability-gate territory (queue 5b) — this file is only the wire. Events
// are plain objects, not Notif wrappers, so consumers can't reach actions or
// dismissal through this path.
pragma Singleton
import QtQuick
import Quickshell
Singleton {
id: root
// Newest-first ring of recent events: { app, summary, body, urgency,
// isTransient, time }. Session-scoped; ii already persists the full
// notification list to disk, this ring is for reactive consumers.
property var recent: []
readonly property int capacity: 32
// Fired once per incoming notification, after `recent` is updated.
signal landed(var evt)
Connections {
target: Notifications
function onNotify(notif) {
const evt = {
app: notif.appName,
summary: notif.summary,
body: notif.body,
urgency: notif.urgency,
isTransient: notif.isTransient,
time: notif.time,
};
root.recent = [evt, ...root.recent].slice(0, root.capacity);
root.landed(evt);
}
}
}

View file

@ -0,0 +1,234 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs
import qs.modules.common
import Quickshell
import Quickshell.Io
import QtQuick
/**
* Selection — compositor-wide text selection, for the TASK-18 action menu.
*
* Mechanism (verified on blueline 2026-07-29): the phone's Hyprland advertises
* `zwp_primary_selection_device_manager_v1` plus BOTH data-control managers
* (`zwlr_data_control_manager_v1`, `ext_data_control_manager_v1`). data-control
* is what lets an unfocused client observe selection changes, so
* `wl-paste --primary --watch` sees every selection with no per-app hooks and no
* compositor patch. That answers TASK-18's open "selection-detection mechanism"
* question: compositor-level, not per-app. viewtop is not required for this.
*
* What the protocol does NOT give us is the selection's bounding rectangle.
* Android solves this at the toolkit layer (ActionMode/FloatingToolbar) and Apple
* in-app; neither is available to us. So `anchorX/anchorY` is the pointer
* position at selection time, which on a phone is where the finger lifted — the
* same place Android puts its floating toolbar anyway. Consumers should treat it
* as a hint and clamp themselves on screen.
*
* ── Privacy, and why this service is written defensively ──────────────────
*
* A primary-selection watcher is a firehose of personal content: it receives
* every selection on the device, including a password highlighted inside a
* password manager. Nothing in the doctrine's tier table (SESSION-AUTHORITY
* §2) covers an ambient capability of that shape, so this service takes the
* conservative reading of §9 (a reading is evidence, and this one is sensitive):
*
* - The watcher only runs while `enabled` AND the session is genuinely
* unlocked. On lock it is KILLED, not paused-and-buffered — there is
* nothing to leak from a process that isn't running.
* - `text` is cleared on lock, and never written to disk, never logged, and
* never put in the forensic trail. DEVICE-STATE-MACHINE §11 wants intent
* recorded, not leaves; the leaf here is the user's private content.
* - Lock state is read live from GlobalStates (which mirrors
* WlSessionLock.secure) rather than cached, per doctrine §4: never hold
* state the protocol owns. Gating on `screenLockSecure` and not
* `screenLocked` is deliberate — GlobalStates.qml:109 says disclosure gates
* on the compositor ack, not on the request.
* - console.log NEVER receives selection text, only its length.
*/
Singleton {
id: root
// User kill switch (settings → Selection). Default off: this capability
// reads everything the user highlights, so it is opt-in, not opt-out.
readonly property bool enabled: Config.options?.selection?.enable ?? false
// The live selection. Empty when there is none, or whenever the session is
// not genuinely unlocked. Never persisted.
property string text: ""
readonly property bool hasSelection: root.text.length > 0
// Pointer position when the selection last changed — a hint for anchoring,
// not the selection's real geometry (see the header). -1 when unknown.
property int anchorX: -1
property int anchorY: -1
// True only when it is safe to observe selections at all.
readonly property bool _permitted: root.enabled && !GlobalStates.screenLockSecure
// Selections shorter than this are almost always an accidental drag.
readonly property int _minChars: 2
// A drag fires primary-selection repeatedly as it grows. Settle before
// announcing, so consumers see one selection and not thirty.
readonly property int _settleMs: 220
signal selectionSettled(string text, int x, int y)
signal selectionCleared()
onEnabledChanged: root._reconcile()
Connections {
target: GlobalStates
function onScreenLockSecureChanged() {
root._reconcile();
}
}
// Bring the watcher in line with policy, and scrub on the way down.
function _reconcile() {
if (root._permitted) {
if (!watcher.running) {
root._primed = false;
watcher.running = true;
console.log("[Selection] watching primary selection");
}
return;
}
if (watcher.running || root.text.length > 0) {
console.log("[Selection] stopping watcher and clearing (permitted=false)");
}
watcher.running = false;
settleTimer.running = false;
root._clear();
}
function _clear() {
const had = root.text.length > 0;
root.text = "";
root.anchorX = -1;
root.anchorY = -1;
if (had) root.selectionCleared();
}
// Called by the menu when the user dismisses it or an action consumes the
// selection. Does not touch the compositor's selection — only our view of it.
//
// The dismissed text is remembered: the compositor's selection is unchanged
// by dismissing, so any later re-read of it (a watcher restart) would
// otherwise resurrect the chip the user just closed.
function dismiss() {
settleTimer.running = false;
root._dismissedText = root.text;
root._clear();
}
property string _pending: ""
property string _dismissedText: ""
// wl-paste --watch fires ONCE IMMEDIATELY with whatever the selection
// already holds, before any new user action. That replay is not a fresh
// selection: it resurrected an hour-old selection every time the watcher
// restarted (i.e. on every unlock), leaving a chip that could never be
// cleared because the primary buffer never changed again. The first
// emission after a start only establishes the baseline.
property bool _primed: false
Component.onCompleted: root._reconcile()
// ── the watcher ──────────────────────────────────────────────────────
// `--watch cat` makes wl-paste run `cat` per change with the selection on
// that child's stdin; wl-paste relays it to our stdout, so one long-lived
// process yields a stream of selections. -n keeps trailing newlines off.
// WAYLAND_DISPLAY is set explicitly: remote/`systemctl --user` contexts do
// not inherit it reliably on this device.
Process {
id: watcher
command: ["sh", "-c",
"WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-wayland-1} " +
"exec wl-paste --primary --watch sh -c 'cat; printf \"\\036\"'"]
// \036 (record separator) terminates each selection: selections contain
// newlines, so a line-based split would fragment multi-line text.
stdout: SplitParser {
splitMarker: "\u001e"
onRead: data => root._onSelection(data)
}
onExited: (exitCode) => {
if (!root._permitted) return; // intentional stop
if (exitCode !== 0 && exitCode !== 15)
console.log("[Selection] watcher exited", exitCode, "- selection menu is inert");
}
}
function _onSelection(raw) {
if (!root._permitted) return;
const t = String(raw ?? "");
// The startup replay: record it as already-seen and show nothing.
if (!root._primed) {
root._primed = true;
root._dismissedText = t;
console.log(`[Selection] baseline ${t.trim().length} chars (not shown)`);
return;
}
// A re-read of something the user already dismissed is not a new
// selection. Only an actual change re-opens the chip.
if (t.length > 0 && t === root._dismissedText) return;
if (t.trim().length < root._minChars) {
// A cleared or trivial selection retires the menu rather than
// leaving a stale one anchored over nothing.
settleTimer.running = false;
root._clear();
return;
}
// A genuinely new selection clears the dismissal memory.
root._dismissedText = "";
root._pending = t;
// Restart the settle window on every growth tick.
settleTimer.restart();
}
Timer {
id: settleTimer
interval: root._settleMs
repeat: false
onTriggered: {
if (!root._permitted || root._pending.length === 0) return;
// Ask for the pointer position only once the selection has settled —
// one hyprctl call per selection, not one per drag tick.
cursorPos.running = true;
}
}
// Anchor hint. A query, not a dispatch — the phone's hyprctl is a Lua-eval
// variant where classic dispatch syntax fails, but queries are unaffected.
// Failure is non-fatal: the menu falls back to its own placement.
Process {
id: cursorPos
command: ["sh", "-c",
"WAYLAND_DISPLAY=${WAYLAND_DISPLAY:-wayland-1} hyprctl cursorpos 2>/dev/null"]
stdout: StdioCollector {
onStreamFinished: {
let x = -1, y = -1;
const m = String(text).match(/(-?\d+)\s*,\s*(-?\d+)/);
if (m) { x = parseInt(m[1]); y = parseInt(m[2]); }
root._announce(x, y);
}
}
onExited: (exitCode) => {
// StdioCollector already announced on success; only cover the case
// where the process died without producing parseable output.
if (exitCode !== 0 && root._pending.length > 0)
root._announce(-1, -1);
}
}
function _announce(x, y) {
if (!root._permitted || root._pending.length === 0) return;
root.text = root._pending;
root._pending = "";
root.anchorX = x;
root.anchorY = y;
// Length only — never the content.
console.log(`[Selection] settled: ${root.text.length} chars, anchor ${x},${y}`);
root.selectionSettled(root.text, x, y);
}
}

View file

@ -0,0 +1,298 @@
// Session audit trail — tamper-evident log of session state transitions.
pragma Singleton
//
// Every lock/unlock, sleep/wake, auth event, and break-glass grant is
// appended to an append-only log file with a hash chain. Each entry
// includes the hash of the previous entry, making the log tamper-evident:
// altering any past entry invalidates every subsequent hash.
//
// The log is JSONL (one JSON object per line). The hash chain uses
// SHA-256 via the `sha256sum` binary. The tamper-evidence is advisory —
// it detects casual modification, not a determined attacker with access
// to the file.
//
// Log location: ~/.local/share/souveraine/session-audit.jsonl
//
// Entry format:
// {
// "seq": 42,
// "prev": "sha256-of-previous-entry",
// "ts": 1234567890,
// "event": "lock-requested",
// "data": { ... },
// "hash": "sha256-of-this-entry-without-hash-field"
// }
//
// The hash is computed over the JSON string of the entry WITHOUT the hash
// field. This is: sha256(JSON.stringify({seq, prev, ts, event, data})).
// Computed by piping the entry JSON through sha256sum in the same shell
// command that appends it to the log, so the hash and write are atomic.
//
// Integration points:
// - GlobalStates: lock/unlock transitions
// - IdleCoordinator: state machine transitions
// - SessionEvents: PrepareForSleep, session Lock signal
// - StepUpAuth: auth succeeded/failed, break-glass issued/consumed
// - Session: action failures, verb refusals
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs.services
import qs.modules.common
Singleton {
id: root
// The audit log file path. Uses the standard XDG data directory.
readonly property string auditPath: Quickshell.env("HOME")
+ "/.local/share/souveraine/session-audit.jsonl"
// The hash of the last entry in the chain. Empty for the first entry
// (genesis block). Updated after every append.
property string lastHash: ""
// Sequence number. Incremented with every entry. Combined with the
// hash chain, this detects gaps (skipped entries) as well as mutations.
property int nextSeq: 0
// Ensure the parent directory exists on startup.
Process {
id: dirCreator
running: true
command: ["mkdir", "-p",
Quickshell.env("HOME") + "/.local/share/souveraine"]
}
// --- Append logic -------------------------------------------------------
// Computes SHA-256 and appends in one shell command so the hash and
// write are atomic. The entry JSON (without hash field) is piped to
// sha256sum; the final entry (with hash) is appended to the log, and
// the hex hash is emitted to stdout so the StdioCollector can update
// lastHash for the next entry in the chain.
function append(event, data) {
const entry = {
seq: root.nextSeq,
prev: root.lastHash,
ts: Math.floor(Date.now() / 1000),
event: event,
data: data || {}
};
const hashInput = JSON.stringify(entry);
const escaped = _escape(hashInput);
// Shell: compute sha256, inject hash into the JSON via sed
// (available on all target devices), append to log, echo hash
// to stdout. The sed command inserts "hash":"<hex>" before the
// LAST closing brace (anchored with $), which is the JSON root.
const cmd =
`h=$(printf '%s' '${escaped}' | sha256sum | cut -d' ' -f1); ` +
`printf '%s\\n' '${escaped}' | sed "s/}$/,\\"hash\\":\\"$h\\"}/" >> ${root.auditPath}; ` +
`echo "$h"`;
appendProcHash.command = ["sh", "-c", cmd];
appendProcHash.running = true;
root.nextSeq++;
console.log("[audit] " + event + " (seq=" + entry.seq + ")");
}
// Escape single quotes for shell embedding.
function _escape(s) {
return s.replace(/'/g, "'\\''");
}
Process {
id: appendProcHash
stdout: StdioCollector {
onStreamFinished: {
const hash = text.trim();
if (hash.length === 64) {
root.lastHash = hash;
} else {
console.log("[audit] unexpected sha256 output: " + text);
}
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
console.log("[audit] append failed (exit " + exitCode + ")");
}
}
}
// --- Load existing chain on startup -------------------------------------
// Read the last entry from the log to restore the hash chain state.
// If the log doesn't exist or is empty, start fresh.
Process {
id: chainLoader
running: true
command: ["sh", "-c",
`if [ -f ${root.auditPath} ]; then tail -1 ${root.auditPath}; else echo ""; fi`]
stdout: StdioCollector {
onStreamFinished: {
const line = text.trim();
if (line.length === 0) {
root.lastHash = "";
root.nextSeq = 0;
root.append("audit-started", { reason: "new chain" });
return;
}
try {
const last = JSON.parse(line);
root.lastHash = last.hash || "";
root.nextSeq = (last.seq || 0) + 1;
root.append("audit-started", { reason: "chain resumed" });
} catch (e) {
console.log("[audit] could not parse last entry: " + e);
root.lastHash = "";
root.nextSeq = 0;
root.append("audit-started", { reason: "chain reset (parse error)" });
}
}
}
}
// --- Event wiring -------------------------------------------------------
// Lock/unlock transitions.
Connections {
target: GlobalStates
function onScreenLockedChanged() {
root.append(GlobalStates.screenLocked
? "lock-requested" : "lock-cleared",
{ screenLocked: GlobalStates.screenLocked });
}
function onScreenLockSecureChanged() {
root.append(GlobalStates.screenLockSecure
? "lock-secure" : "lock-insecure",
{ screenLockSecure: GlobalStates.screenLockSecure });
}
}
// Idle state transitions.
Connections {
target: IdleCoordinator
function onStateTransitioned(state) {
const names = ["active", "dimmed", "lock-requested",
"lock-secure", "suspending", "asleep", "waking"];
root.append("idle-transition", {
state: names[state] || String(state)
});
}
}
// Sleep/wake events.
Connections {
target: typeof SessionEvents !== "undefined" ? SessionEvents : null
function onPrepareForSleep(suspending) {
root.append(suspending ? "sleep-requested" : "wake",
{ sleepInhibitorHeld: SessionEvents.sleepInhibitorHeld });
}
function onSleepInhibitorReleased() {
root.append("sleep-inhibitor-released", {});
}
function onSleepInhibitorAcquired() {
root.append("sleep-inhibitor-acquired", {});
}
function onSessionLockRequested() {
root.append("external-lock-signal", {});
}
}
// Auth events.
Connections {
target: typeof StepUpAuth !== "undefined" ? StepUpAuth : null
function onAuthSucceeded(family) {
root.append("auth-succeeded", { family: family });
}
function onAuthFailed(family) {
root.append("auth-failed", { family: family });
}
function onBreakGlassIssued(reason, expiresAt) {
root.append("break-glass-issued", {
reason: reason,
expiresAt: expiresAt
});
}
function onBreakGlassConsumed(reason) {
root.append("break-glass-consumed", { reason: reason });
}
function onBreakGlassExpired(reason) {
root.append("break-glass-expired", { reason: reason });
}
function onGrantExpired(family) {
root.append("grant-expired", { family: family });
}
function onGrantRevoked(family) {
root.append("grant-revoked", { family: family });
}
}
// ── Forensic: device state transitions ────────────────────────
// These entries capture the decision context at each state change.
// The Rust-side forensic log (forensic.jsonl) has the full sensor
// snapshots; this trail records the same events in the
// tamper-evident hash chain for cross-referencing.
// Brightness / idle coordinator errors.
Connections {
target: typeof IdleCoordinator !== "undefined" ? IdleCoordinator : null
function onStateTransitioned(state) {
const names = ["active", "dimmed", "lock-requested",
"lock-secure", "suspending", "asleep", "waking"];
root.append("device-state-transition", {
state: names[state] || String(state),
source: "idle-coordinator"
});
}
}
// DPMS / screen power errors (from blueline-screen-toggle or
// brightnessctl failures). These are the operational errors that
// the old code logged to console.log only.
function logDeviceError(component, action, error) {
root.append("device-error", {
component: component,
action: action,
error: error,
device_state: typeof IdleCoordinator !== "undefined"
? IdleCoordinator.state : -1,
screen_locked: typeof GlobalStates !== "undefined"
? GlobalStates.screenLocked : false,
screen_lock_secure: typeof GlobalStates !== "undefined"
? GlobalStates.screenLockSecure : false,
});
}
// Sensor input that affected device state (proximity, accel, etc.).
function logSensorInput(source, value, confidence, decision) {
root.append("sensor-input", {
source: source,
value: value,
confidence: confidence,
decision: decision,
device_state: typeof IdleCoordinator !== "undefined"
? IdleCoordinator.state : -1,
});
}
// Wake event — what triggered the screen to turn on.
function logWakeEvent(trigger, details) {
root.append("wake-event", {
trigger: trigger,
details: details || {},
device_state: typeof IdleCoordinator !== "undefined"
? IdleCoordinator.state : -1,
proximity: typeof GlobalStates !== "undefined"
? GlobalStates.proximityNear : null,
});
}
Timer {
id: initLogTimer
interval: 0
repeat: false
running: true
onTriggered: console.log("[audit] initialized; log at " + root.auditPath)
}
}

View file

@ -0,0 +1,356 @@
// Souveraine logind event ingress — the shell's ears for external lock and
// suspend requests.
//
// logind is the system session manager. Other software (a phone's power
// button daemon, a remote SSH session, `loginctl lock-session`) can ask it
// to lock or suspend at any time. Without this file the shell only knows
// about locks it initiates itself; an external lock request would arrive as
// a D-Bus signal that nothing listens to, and the phone would suspend into
// an unlocked session.
//
// Architecture: two `gdbus monitor` processes (one for the manager bus, one
// for the session bus) emit lines that SplitParser splits into individual
// D-Bus signal frames. This is the same Process + SplitParser pattern that
// Souveraine.qml uses for the SSE turn stream — a long-lived child whose
// stdout is line-delimited and parsed in-process.
//
// The sleep delay inhibitor is a `systemd-inhibit --what=sleep --mode=delay
// sleep infinity` process that holds the logind delay inhibitor slot from
// shell startup. Kill it to release (allowing suspend); restart it to
// reacquire (blocking suspend again after wake). This is the same pattern
// Idle.qml uses for hypridle: a background Process whose lifecycle IS the
// inhibitor's lifecycle.
//
// The trust boundary is the same as Session.qml: this surface is local,
// single-user, reachable only over quickshell's IPC socket. The D-Bus
// signals it listens to are system-bus signals that any session member can
// see; this file simply acts on them before logind times out its inhibitor
// delay (typically 5s).
//
// See: session-inhibitors.md, suspend-before-lock.md, capability-tiers.md
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs
import qs.services
import qs.modules.common
import qs.modules.common.functions
Singleton {
id: root
// --- Properties --------------------------------------------------------
// The resolved logind session path (e.g. "/org/freedesktop/login1/session/_3").
// Empty until the resolver Process completes. The session monitor does not
// start until this is set, because the D-Bus object path depends on it.
property string sessionPath: ""
// Whether the sleep delay inhibitor is currently held. True from shell
// startup; released only when PrepareForSleep(true) arrives and the
// session is locked (or about to be). Reacquired on PrepareForSleep(false).
property bool sleepInhibitorHeld: true
// --- Signals -----------------------------------------------------------
// Emitted on every PrepareForSleep signal. `suspending` is true when the
// system is about to sleep, false when it has woken. Listeners can use
// this to pause/resume network activity, dim screens, etc.
signal prepareForSleep(bool suspending)
// Emitted when an external Lock signal arrives from logind (e.g.
// `loginctl lock-session` from another process, or a power-button daemon
// that locks before suspend).
signal sessionLockRequested()
// Emitted when the delay inhibitor is released to let suspend proceed.
// This happens after WlSessionLock.secure confirms the compositor has
// locked the session, or immediately if the session was already secure.
signal sleepInhibitorReleased()
// Emitted when the delay inhibitor is reacquired after wake. The system
// is no longer suspending, and the shell is blocking suspend again until
// the next PrepareForSleep(true) cycle.
signal sleepInhibitorAcquired()
// --- Session path resolution -------------------------------------------
// Resolve the graphical session's REAL object path via the user's `Display`
// session. sessiond resolves exactly this way; see the two measured traps
// documented on `resolve_session_path` in src/sessiond/lockhint.rs:
//
// Not `auto`. `GetSession("auto")` resolves to the CALLER's session, and
// this shell is a systemd user unit under user@1000.service, outside any
// session scope — it has no session of its own to name.
//
// Not the `auto` path either. /org/freedesktop/login1/session/auto is an
// alias, not an object: PropertiesChanged only fires on the concrete
// path, so monitoring the alias subscribes to something never delivered.
//
// The user's `Display` session is the graphical one by definition, whoever
// asks. The previous probe here asked for a `-p ObjectPath` property that
// loginctl does not have, so it returned empty on every boot and external
// lock signals were never monitored at all.
Process {
id: sessionResolver
running: true
command: ["sh", "-c",
"busctl --system get-property org.freedesktop.login1 "
+ "/org/freedesktop/login1/user/_$(id -u) "
+ "org.freedesktop.login1.User Display 2>/dev/null"]
stdout: StdioCollector {
onStreamFinished: {
const raw = text.trim();
// `(so) "1" "/org/freedesktop/login1/session/_31"` — the object
// path is the second quoted field.
const path = raw.split('"')[3];
if (path && path.startsWith("/org/freedesktop/login1/session/")) {
root.sessionPath = path;
console.log("[session-events] session path:", root.sessionPath);
sessionLockMonitor.running = true;
} else if (raw.length > 0) {
console.error("[session-events] could not parse Display session from:", raw,
"— retrying; external lock signals are NOT being monitored");
} else {
// Do not settle for this. logind may simply not have the
// graphical session yet at the moment we asked, and giving
// up leaves the shell permanently blind to external lock and
// unlock — the exact silent gap that made a fallback-surface
// unlock leave a stale lock surface on screen.
console.error("[session-events] no Display session for this user yet; "
+ "retrying — external lock signals are NOT being monitored");
}
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
console.error("[session-events] session path probe failed (exit " + exitCode
+ "); retrying — external lock monitoring is NOT active");
}
}
}
// Keep asking until the graphical session exists. Idle the moment it does:
// `running` is false once sessionPath is set, so this costs nothing in the
// normal case and closes the window where a shell that started before
// logind published the session would never monitor lock signals at all.
Timer {
id: sessionResolveRetry
interval: 5000
repeat: true
running: root.sessionPath.length === 0
onTriggered: sessionResolver.running = true
}
// --- Sleep delay inhibitor ---------------------------------------------
// systemd-inhibit with --mode=delay holds a delay inhibitor on the sleep
// verb. logind allows a configurable delay (typically 5s) before forcing
// suspend, giving the shell time to lock the session securely. The
// process runs `sleep infinity` so it stays alive until we kill it.
//
// Kill this process = release the inhibitor = logind may proceed with
// suspend. Restart it = reacquire = suspend is blocked again.
//
// This is deliberately NOT wired through Session.inhibit() yet. Session.qml
// now supports "sleep" kind, but the delay-mode inhibitor must be held from
// startup and released reactively on PrepareForSleep — it does not follow
// the cookie-based request/release pattern that Session.inhibit() exposes.
// Managing the process here keeps the sleep path working without coupling
// the reactive suspend flow to the IPC-facing inhibitor API.
Process {
id: sleepInhibitor
running: true
command: ["systemd-inhibit", "--what=sleep", "--mode=delay",
"--who=Souveraine Shell",
"--why=Delay suspend until WlSessionLock.secure confirms session is locked",
"sleep", "infinity"]
onExited: (exitCode, exitStatus) => {
// Unexpected exit while we think the inhibitor is held. This can
// happen if systemd-inhibit is not installed, or if logind
// restarted and invalidated the inhibitor fd. Log it; the next
// PrepareForSleep(true) will find no inhibitor and suspend will
// proceed immediately — which is the safe degradation: the phone
// sleeps instead of draining its battery blocking a suspend that
// will never complete.
if (root.sleepInhibitorHeld) {
console.log("[session-events] sleep inhibitor exited unexpectedly (exit "
+ exitCode + "); suspend will no longer be delayed");
root.sleepInhibitorHeld = false;
}
}
}
function releaseSleepInhibitor() {
if (!root.sleepInhibitorHeld) return;
// Setting running=false sends SIGTERM to the process. `sleep infinity`
// exits cleanly on SIGTERM, which releases the systemd-inhibit fd.
sleepInhibitor.running = false;
root.sleepInhibitorHeld = false;
root.sleepInhibitorReleased();
console.log("[session-events] sleep inhibitor released; suspend may proceed");
}
function reacquireSleepInhibitor() {
if (root.sleepInhibitorHeld) return;
sleepInhibitor.running = true;
root.sleepInhibitorHeld = true;
root.sleepInhibitorAcquired();
console.log("[session-events] sleep inhibitor reacquired; suspend blocked");
}
// --- D-Bus monitor: PrepareForSleep ------------------------------------
// Watches org.freedesktop.login1.Manager for the PrepareForSleep signal.
// This signal carries a boolean: true = suspending, false = waking.
//
// gdbus monitor output format:
// /org/freedesktop/login1.Manager: org.freedesktop.login1.Manager.PrepareForSleep (true,)
//
// We parse the boolean from the signal arguments. The object path line
// is also emitted by gdbus but SplitParser gives us one line at a time,
// so the signal name line is what carries the argument.
Process {
id: sleepMonitor
running: true
command: ["gdbus", "monitor", "--system",
"--dest", "org.freedesktop.login1",
"--object-path", "/org/freedesktop/login1"]
stdout: SplitParser {
onRead: data => {
if (data.length === 0) return;
if (!data.includes("PrepareForSleep")) return;
// Extract the boolean from the parenthesized argument list.
// The format is "(true,)" or "(false,)" — the trailing comma
// is gdbus's tuple representation of a single-argument signal.
const suspending = data.includes("(true,");
root.prepareForSleep(suspending);
console.log("[session-events] PrepareForSleep:", suspending ? "suspending" : "waking");
if (suspending) {
root.onSuspendRequested();
} else {
root.onWoken();
}
}
}
onExited: (exitCode) => {
console.log("[session-events] sleep monitor exited (exit " + exitCode
+ "); PrepareForSleep signals will not be received");
}
}
// --- D-Bus monitor: Session Lock ---------------------------------------
// Watches the current session's object path for the Lock signal. This
// signal has no arguments — it is a notification that something asked
// logind to lock this session.
//
// The monitor does not start until sessionPath is resolved, because the
// object path is session-specific. If resolution fails, this monitor
// stays disabled and only shell-initiated locks work.
Process {
id: sessionLockMonitor
command: root.sessionPath.length > 0
? ["gdbus", "monitor", "--system",
"--dest", "org.freedesktop.login1",
"--object-path", root.sessionPath]
: ["false"] // never runs; guarded by sessionPath check
stdout: SplitParser {
onRead: data => {
if (data.length === 0) return;
if (!data.includes("org.freedesktop.login1.Session.Lock")) return;
// Avoid reacting to our own lock-session notification.
// Session.lock() calls loginctl lock-session, which emits
// this same signal back at us. If the session is already
// locked (or lock was requested), this is an echo, not a
// new external request.
if (GlobalStates.screenLocked) return;
console.log("[session-events] external Lock signal received");
root.sessionLockRequested();
Session.lock();
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
console.log("[session-events] session lock monitor exited (exit " + exitCode
+ "); external lock signals will not be received");
}
}
}
// --- Suspend/lock coordination -----------------------------------------
// The core logic: when logind tells us the system is about to suspend,
// we must ensure the session is locked BEFORE we release the delay
// inhibitor. If the session is already secure (WlSessionLock confirmed),
// release immediately. If not, request a Wayland lock and wait for
// LockScreen.qml to confirm secure — then release.
//
// This is the suspend-before-lock protocol documented in
// suspend-before-lock.md: PrepareForSleep(true) -> request lock ->
// wait for screenLockSecure -> release inhibitor -> logind proceeds.
// Whether we requested a lock as part of the suspend path. Used to
// distinguish "we locked for suspend" from "we woke up and should not
// unlock". The lock persists across suspend/resume; only the human
// unlocks via the credential gate.
property bool _lockForSuspend: false
function onSuspendRequested() {
if (GlobalStates.screenLockSecure) {
// Session is already secure — the compositor has confirmed the
// lock surface. Release the inhibitor immediately so logind can
// proceed with suspend within its delay window.
root.releaseSleepInhibitor();
} else {
// Session is not yet secure. Request a Wayland lock; the
// Connections block below watches for screenLockSecure and will
// release the inhibitor when it arrives. We set the flag so the
// Connections handler knows this lock came from the suspend path.
root._lockForSuspend = true;
if (!GlobalStates.screenLocked) {
Session.lock();
}
// If screenLocked is true but screenLockSecure is not yet true,
// the lock has been requested but the compositor has not acked.
// The Connections handler will catch the ack.
console.log("[session-events] lock requested for suspend; "
+ "waiting for WlSessionLock.secure");
}
}
function onWoken() {
// The system has woken from suspend. Reacquire the delay inhibitor
// so the next PrepareForSleep(true) is again blocked until the
// session is secure. The lock persists — we do not unlock on wake.
// Only the human credential gate (PIN pad) unlocks.
root._lockForSuspend = false;
root.reacquireSleepInhibitor();
}
// Watch for the compositor confirming the lock surface. This is the
// signal from LockScreen.qml's WlSessionLock.onSecureChanged handler,
// mediated through GlobalStates.screenLockSecure. If we are on the
// suspend path (lock requested for suspend, inhibitor still held), the
// secure confirmation means the session is now safe to suspend.
Connections {
target: GlobalStates
function onScreenLockSecureChanged() {
if (!GlobalStates.screenLockSecure) return;
if (!root._lockForSuspend) return;
if (!root.sleepInhibitorHeld) return;
// WlSessionLock.secure confirms the compositor has replaced the
// session surfaces with the lock surface. The session is now
// secure; release the delay inhibitor so logind can suspend.
root._lockForSuspend = false;
root.releaseSleepInhibitor();
}
}
Timer {
id: initLogTimer
interval: 0
repeat: false
running: true
onTriggered: console.log("[session-events] initialized; sleep inhibitor held="
+ root.sleepInhibitorHeld)
}
}

View file

@ -0,0 +1,467 @@
// Shell side of the souveraine-sessiond handoff protocol.
//
// sessiond takes ext-session-lock before the shell exists; this bridge is
// how the shell (a) announces itself and takes the lock over, (b) keeps the
// heartbeat connection open so sessiond can retake the lock the moment the
// shell dies, and (c) confirms the compositor-acked lock (locked_ack).
//
// The session is never unlocked during the handoff: sessiond abandons its
// lock (connection drop) and misc:allow_session_lock_restore lets our
// WlSessionLock inherit the locked session. See
// souveraine/src/sessiond/protocol.rs — that file is the contract.
//
// No sessiond on the socket (laptop, or bring-up) = everything no-ops and
// the legacy launchOnStartup path decides alone.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import qs
import qs.modules.common.functions
Singleton {
id: root
// Only shell.qml calls claimAuthority(). Importing this singleton from a
// utility window must never create a session-authority connection.
property bool authorityScope: false
// Registered = shell_ready was answered ok on the CURRENT connection.
property bool registered: false
// Latched copy of `registered` taken when the connection drops. The
// disconnect branch clears `registered` before the reconnect branch runs,
// so reconnect cannot read it directly to decide whether to re-register.
property bool wasRegistered: false
// We owe the authority a registration that has not landed yet — set when a
// handshake times out or is refused because the lease is still held,
// cleared once one is answered. Drives registerRetry.
property bool needsRegistration: false
// Consecutive "already registered" refusals. Only for log cadence — the
// retry itself is unconditional, because the common cause is our own
// outgoing connection not having hit EOF yet.
property int refusalStreak: 0
// sessiond held the session lock when we registered; we owe it a lock
// and a locked_ack.
property bool oweLock: false
property bool ackSent: false
property var pendingReady: null // callback awaiting the shell_ready response
function claimAuthority() {
root.authorityScope = true
}
function load() {
if (!root.authorityScope)
console.log("[sessiond-bridge] inactive outside authority scope")
}
// One short-lived request connection for power authority. Never put this
// on `sock`: suspend can keep the daemon's synchronous request handler
// occupied until resume, while the heartbeat connection must remain free
// to carry locked_ack, directives and the EOF that means shell death.
//
// This is deliberately only the transport seam for now. Session.qml keeps
// its legacy executor until the packaged daemon and its polkit subject have
// been proven on each target. A locally accepted request returns pending;
// the callback and powerFinished carry the daemon's eventual verdict.
property var pendingPower: null
property int powerRequestSequence: 0
signal powerFinished(string requestId, var reply)
function requestPower(verb, callback) {
const powerVerb = String(verb ?? "");
if (!["poweroff", "reboot", "suspend", "hibernate"].includes(powerVerb)) {
return {
ok: false,
code: "unsupported",
reason: "unsupported power verb: " + powerVerb
};
}
if (root.pendingPower !== null) {
return {
ok: false,
code: "refused_by_state",
reason: "another power request is already in flight"
};
}
root.powerRequestSequence += 1;
const requestId = "power-" + Date.now() + "-" + root.powerRequestSequence;
root.pendingPower = {
requestId: requestId,
verb: powerVerb,
callback: typeof callback === "function" ? callback : null,
sent: false
};
powerConnectTimeout.restart();
powerSock.connected = true;
return { ok: true, status: "pending", request_id: requestId };
}
function _finishPower(reply) {
if (root.pendingPower === null)
return;
const pending = root.pendingPower;
root.pendingPower = null;
powerConnectTimeout.stop();
const result = {};
for (const key in reply)
result[key] = reply[key];
result.request_id = pending.requestId;
// Clear our request before closing. The disconnect edge must not turn
// a parsed refusal/acceptance into a second outcome_unknown callback.
powerSock.connected = false;
root.powerFinished(pending.requestId, result);
if (pending.callback !== null) {
try {
pending.callback(result);
} catch (error) {
console.error("[sessiond-bridge] power callback failed: " + error);
}
}
}
Timer {
id: powerConnectTimeout
interval: 1500
repeat: false
onTriggered: {
if (root.pendingPower !== null && !root.pendingPower.sent) {
root._finishPower({
ok: false,
code: "unavailable",
reason: "sessiond power socket unavailable"
});
}
}
}
Socket {
id: powerSock
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
onConnectionStateChanged: {
if (powerSock.connected && root.pendingPower !== null
&& !root.pendingPower.sent) {
const pending = root.pendingPower;
root.pendingPower = {
requestId: pending.requestId,
verb: pending.verb,
callback: pending.callback,
sent: true
};
powerConnectTimeout.stop();
powerSock.write(JSON.stringify({
op: "power",
verb: pending.verb
}) + "\n");
powerSock.flush();
} else if (!powerSock.connected && root.pendingPower !== null) {
const sent = root.pendingPower.sent;
root._finishPower(sent ? {
ok: false,
code: "outcome_unknown",
status: "outcome_unknown",
reason: "sessiond disconnected after the power request was sent"
} : {
ok: false,
code: "unavailable",
reason: "sessiond power socket unavailable"
});
}
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
if (root.pendingPower === null)
return;
let reply;
try {
reply = JSON.parse(message);
} catch (error) {
root._finishPower({
ok: false,
code: "outcome_unknown",
status: "outcome_unknown",
reason: "sessiond returned an unparseable power reply"
});
return;
}
root._finishPower(reply);
}
}
}
// Announce the shell. cb(mustLock) fires exactly once: mustLock true
// means sessiond was holding and the session IS locked — the shell must
// raise its own lock surface immediately.
//
// EVERY failure path here answers TRUE. Not knowing whether the session is
// locked is not the same as knowing it is not, and the two must never be
// collapsed: sessiond takes ext-session-lock before any shell surface can
// exist (protocol.rs §1), so a shell that cannot get an answer is a shell
// that arrived after something already locked the session. The daemon side
// of this contract is explicit — heartbeat EOF retakes the lock "whether or
// not the session was locked at the time. Fail closed." — and this side has
// to match it or the pair fails open at exactly the moment the authority is
// unreachable.
//
// Answering true costs an unnecessary lock surface the user dismisses with
// PAM. Answering false costs an unlocked phone.
function shellReady(cb) {
if (!root.authorityScope) {
// Not a fail-open: a utility QML process is not the session
// authority and has no lock to owe. Only shell.qml claims scope.
console.log("[sessiond-bridge] shell_ready refused outside authority scope")
cb(false)
return
}
if (!sock.connected) {
console.warn("[sessiond-bridge] shell_ready with no socket — assuming locked");
cb(true);
return;
}
if (root.pendingReady) {
// A handshake is already in flight; it will answer authoritatively.
// This duplicate assumes locked rather than racing it to "unlocked".
console.warn("[sessiond-bridge] duplicate shell_ready — assuming locked");
cb(true);
return;
}
root.pendingReady = cb;
readyTimeout.restart();
sock.write(JSON.stringify({ op: "shell_ready" }) + "\n");
sock.flush();
}
function sendLockedAck() {
if (!sock.connected || !root.registered || root.ackSent) return;
root.ackSent = true;
sock.write(JSON.stringify({ op: "locked_ack" }) + "\n");
sock.flush();
console.log("[sessiond-bridge] locked_ack sent");
}
Timer {
id: readyTimeout
// Longer than sessiond's own deadline, and that ordering is the whole
// point. `shell_ready` blocks in the daemon for up to 5 s waiting for
// its lock-session thread to drop its Wayland connection, because the
// compositor refuses a second locker while the first is alive
// (server.rs, `wait_timeout_while`). At 3 s this timer fired *first*,
// so the shell gave up on a handshake the daemon was still answering,
// assumed locked, and asked for a lock sessiond had not released yet —
// straight into TASK-48's `Tried to show lockscreen surfaces without
// active lock`.
//
// Under Hyprland that race is usually won: the release lands in
// milliseconds. Measured against viewtop on blueline 2026-08-02 it
// loses every time — the shell crash-looped every 11 s and never came
// up. Same latent bug, a compositor that exposes it.
//
// The daemon has an answer for this case and it is a refusal
// (`lock session did not release in time`). Waiting for a real refusal
// beats inventing a verdict: `assuming locked` is the shell holding
// state the authority owns, which is the failure doctrine §4 is about.
interval: 7000
repeat: false
onTriggered: {
if (root.pendingReady) {
// Was "proceeding without sessiond" with cb(false): a silent
// fail-open that left the session unlocked precisely when the
// authority was not answering. Assume locked, and keep trying —
// an unanswered handshake is a transient (sessiond restarting),
// not a verdict.
console.warn("[sessiond-bridge] shell_ready timed out — assuming locked, will retry");
const cb = root.pendingReady;
root.pendingReady = null;
root.needsRegistration = true;
cb(true);
}
}
}
// Register, and apply whatever the authority says we owe. One path, used by
// the reconnect handler and the retry timer alike, so the two can never
// drift into handling the answer differently.
function registerWithAuthority() {
root.shellReady(function(mustLock) {
if (!mustLock)
return;
// sessiond locked while we were disconnected (it treats our EOF as
// shell death), or we could not confirm and are failing closed.
GlobalStates.screenLocked = true;
// Our surface may ALREADY be secure from before the daemon
// restarted. onScreenLockSecureChanged is an edge, and that edge is
// in the past, so nothing would ever send the ack this new handoff
// owes — sessiond waits out its timer and retakes the lock
// ("shell never confirmed its lock after handoff").
if (GlobalStates.screenLockSecure)
root.sendLockedAck();
});
}
// A handshake that timed out is retried until it lands. Without this a
// shell that merely started while sessiond was restarting stays
// unregistered for its whole life: sessiond sees no heartbeat, believes the
// shell is dead, and raises its own fallback surface over ours forever.
Timer {
id: registerRetry
interval: 5000
repeat: true
running: root.authorityScope && root.needsRegistration
&& !root.registered && sock.connected
onTriggered: root.registerWithAuthority()
}
// Reconnect: sessiond may restart (upgrade) or start late. While
// connected this timer is idle; the Socket does not retry by itself.
Timer {
id: reconnect
interval: 5000
repeat: true
running: root.authorityScope && !sock.connected
onTriggered: sock.connected = true
}
Socket {
id: sock
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
connected: root.authorityScope
// `connectionStateChanged` is the real Signal on Quickshell.Io.Socket;
// the `onSocketConnected`/`onSocketDisconnected` handler-slots aren't
// reliably attachable across quickshell builds, so branch on
// `connected` here. Re-register on reconnect if we were registered
// before (sessiond restarted underneath us).
onConnectionStateChanged: {
if (sock.connected) {
console.log("[sessiond-bridge] connected");
const wasRegistered = root.wasRegistered;
root.registered = false;
root.wasRegistered = false;
root.ackSent = false;
if (wasRegistered || root.needsRegistration) {
root.registerWithAuthority();
}
} else {
console.log("[sessiond-bridge] disconnected");
root.wasRegistered = root.registered;
root.registered = false;
root.pendingReady = null;
}
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
let reply;
try {
reply = JSON.parse(message);
} catch (e) {
console.log("[sessiond-bridge] unparseable reply: " + message);
return;
}
// Not every line is a reply. The authority pushes directives
// down this connection: it owns the decision, the shell owns
// the surface. A blank that needs a lock first arrives here
// (LOCK-DPMS-LESSONS §1 — lock, then off), and the daemon is
// holding the panel dark until this is answered.
if (reply.directive !== undefined) {
root.handleDirective(reply);
return;
}
if (root.pendingReady) {
// The only request we await a response for.
const cb = root.pendingReady;
root.pendingReady = null;
readyTimeout.stop();
if (reply.ok) {
root.registered = true;
root.needsRegistration = false;
root.refusalStreak = 0;
root.oweLock = reply.must_lock === true;
console.log("[sessiond-bridge] registered, must_lock=" + root.oweLock);
cb(root.oweLock);
} else {
// "already registered" means the lease is held right
// now. It does NOT mean it is held by someone else,
// and treating it as a verdict was a real bug: a scene
// RELOAD re-runs this file while the outgoing
// connection is still open, so the reload's shell_ready
// races its own predecessor's EOF and is refused. The
// old code then stopped retrying — and when that EOF
// landed a second later it cleared shell_alive, leaving
// sessiond believing there was no shell at all, for the
// life of the session. Observed 2026-07-29 09:14:35:
// refused, old socket closed 09:16:36, and the daemon
// reported shell_alive=false with a live shell on the
// other end of a connected socket.
//
// So it is a TRANSIENT. Keep registering. If the lease
// really is another live shell's, every retry is
// refused again and costs nothing — and the moment that
// shell dies we are the one that should hold it.
const leaseHeld =
String(reply.reason || "").indexOf("already registered") !== -1;
if (leaseHeld) {
root.needsRegistration = true;
root.refusalStreak += 1;
// Loud once, then once a minute: a lease that never
// frees is a real problem, but 12 lines a minute is
// how a real problem gets scrolled past.
if (root.refusalStreak === 1 || root.refusalStreak % 12 === 0)
console.warn("[sessiond-bridge] shell_ready refused: "
+ reply.reason + " — lease still held, retrying ("
+ root.refusalStreak + ")");
} else {
console.warn("[sessiond-bridge] shell_ready refused: "
+ reply.reason + " — assuming locked");
}
cb(!leaseHeld);
}
}
}
}
}
// Carry out an authority directive. The shell is the executor here, not a
// peer deciding whether it agrees: sessiond is the session authority and
// it has already withheld the panel waiting for this.
//
// Unknown directives are LOUD. A newer daemon asking for something this
// shell cannot do is a real divergence, and silently dropping it would
// leave the daemon waiting out its ack budget and then blanking unlocked.
function handleDirective(msg) {
if (msg.directive === "lock") {
console.log("[sessiond-bridge] authority directive: lock ("
+ (msg.why || "no reason given") + ")");
Session.lock();
return;
}
if (msg.directive === "accessory_presentation") {
// sessiond sends this only after producer admission and content
// classification. The shell projects it; it does not reinterpret
// a Bluetooth observation into an authority decision.
AccessoryPresentation.present(msg.presentation ?? {});
return;
}
console.error("[sessiond-bridge] UNKNOWN authority directive: "
+ JSON.stringify(msg)
+ " — this shell is older than the daemon driving it");
}
// The compositor acknowledged OUR lock surface — tell sessiond the
// handoff is complete. Gated on secure, not the request, per doctrine.
Connections {
target: GlobalStates
function onScreenLockSecureChanged() {
if (GlobalStates.screenLockSecure && root.oweLock && !root.ackSent) {
root.sendLockedAck();
}
}
}
}

View file

@ -0,0 +1,125 @@
// The Settings view over souveraine-sessiond's device-state policy.
//
// TASK-19's rule is that every control is a view over the owning service — "no
// success-shaped switches". The idle timers are owned by the state machine in
// sessiond, not by a JSON file in ~/.config, so a spinbox that writes
// Config.options and stops there is exactly the lie that rule forbids. It was
// that lie until 2026-07-25: the settings page moved dimAfterSeconds and
// lockAfterSeconds while the daemon that actually blanks the panel ran on its
// compiled-in defaults, because SetPolicy existed in the protocol with zero
// callers anywhere in the tree.
//
// This talks to the daemon on a SEPARATE, short-lived connection. The
// SessiondBridge socket is the heartbeat: its EOF is how sessiond learns the
// shell died, and its open line is how the authority pushes directives. Settings
// traffic does not belong on it. A second connection is harmless — only
// `shell_ready` claims the authority lease, and this never sends it.
//
// Failures here are LOUD. A policy write that silently did nothing would
// recreate the exact bug this file exists to close.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
// True once a reply has been parsed — until then the page must not claim
// to be showing the daemon's values.
property bool available: false
property string lastError: ""
// Mirrors DeviceStatePolicy. Seconds; 0 means "never" for the budgets.
property int lockBlankAfterSecs: 0
property int lockBlankAfterHeldSecs: 0
property int dimGraceSecs: 0
property int evidenceTtlSecs: 0
property bool dimWarning: true
property int lockAckBudgetSecs: 0
property int unlockedBlankAfterSecs: 0
signal refreshed()
signal applyFailed(string reason)
function refresh() {
root._send({ op: "get_policy" });
}
// Every field is optional daemon-side; omitted fields keep their value.
function apply(fields) {
const msg = { op: "set_policy" };
for (const k in fields) msg[k] = fields[k];
root._send(msg);
}
property var _queued: null
function _send(msg) {
if (sock.connected) {
sock.write(JSON.stringify(msg) + "\n");
return;
}
// One in flight is enough; Settings is a single page and the daemon
// answers in microseconds.
root._queued = msg;
sock.connected = true;
}
Socket {
id: sock
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
onConnectionStateChanged: {
if (connected && root._queued) {
const m = root._queued;
root._queued = null;
sock.write(JSON.stringify(m) + "\n");
} else if (!connected && root._queued) {
root.lastError = "sessiond socket unavailable";
root.available = false;
console.error("[sessiond-policy] could not reach the daemon at "
+ sock.path + " — the idle timers shown are NOT authoritative");
root._queued = null;
root.applyFailed(root.lastError);
}
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
let reply;
try {
reply = JSON.parse(message);
} catch (e) {
console.error("[sessiond-policy] unparseable reply: " + message);
root.lastError = "unparseable reply";
root.applyFailed(root.lastError);
return;
}
if (reply.ok !== true) {
const why = reply.reason || "refused without a reason";
console.error("[sessiond-policy] daemon refused: " + why);
root.lastError = why;
root.applyFailed(why);
return;
}
// get_policy answers flat; set_policy answers under `policy`.
const p = reply.policy !== undefined ? reply.policy : reply;
root.lockBlankAfterSecs = p.lock_blank_after_secs ?? 0;
root.lockBlankAfterHeldSecs = p.lock_blank_after_held_secs ?? 0;
root.dimGraceSecs = p.dim_grace_secs ?? 0;
root.evidenceTtlSecs = p.evidence_ttl_secs ?? 0;
root.dimWarning = p.dim_warning === true;
root.lockAckBudgetSecs = p.lock_ack_budget_secs ?? 0;
root.unlockedBlankAfterSecs = p.unlocked_blank_after_secs ?? 0;
root.available = true;
root.lastError = "";
root.refreshed();
}
}
}
}

View file

@ -0,0 +1,770 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common.functions as CF
import qs.modules.common
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
/**
* Souveraine — the substrate singleton for every shell module.
*
* This is the one connection to the Souveraine server. The chat sidebar,
* presence widget, cockpit pane, agent manager and settings module all hang
* off this service; none of them open their own transport. Ai.qml is the
* ii-compat adapter over this for the existing sidebar UI.
*
* Responsibilities:
* - agent inventory (GET /v1/agents)
* - conversation lifecycle (create and server-derived resume)
* - the SSE turn stream — raw events re-emitted via streamEvent(var)
* - the backchannel: cancelTurn() and interject(text)
* - the desktop sensorium: every send carries ambient context (active
* window, open apps, cursor position) so she perceives the room she is
* being spoken to in. Extension point for device sensors (SouveraineOS).
*/
Singleton {
id: root
property string serverBase: Config.options?.ai?.souveraineUrl ?? "http://127.0.0.1:8484"
property bool serverUp: false
// Ambient perception on by default; ai.ambient=false in ii config disables.
property bool ambientEnabled: Config.options?.ai?.ambient ?? true
// Start `souveraine server` ourselves when it isn't running. The surface
// is the OS frontend — opening it means summoning her, not staring at a
// connection error. ai.souveraineAutostart=false disables; a manual
// start affordance can call startServer() directly.
property bool autostartEnabled: Config.options?.ai?.souveraineAutostart ?? true
property string serverBin: Config.options?.ai?.souveraineBin ?? "souveraine"
property bool _autostartTried: false
// id -> { name, description }
property var agents: ({})
property var agentList: Object.keys(agents)
property string currentAgentId: ""
// Structured read-only projection of the current agent's canonical
// itinerary. The memfs file remains the owner; this survives a pane/shell
// reload by asking the substrate instead of keeping a ribbon-local copy.
property string itineraryAgentId: ""
property var itinerary: ({
"exists": false,
"active": false,
"title": "",
"current": 0,
"route": "",
"stops": []
})
property bool itineraryStale: false
// On agent (re)establishment — shell start, reboot, agent switch — we look
// up her latest server-persisted conversation. What we do with it depends
// on autoResume.
//
// autoResume false (default): we *offer* it. `resumeOffered` fires with
// the thread's id and metadata; nothing is attached. Doing nothing
// starts fresh, which is what a reload should do. Continuing is one
// deliberate act (acceptOfferedResume), not a default.
// autoResume true: legacy behaviour — attach it silently.
//
// The distinction matters because the shell reloads often (a deploy, a
// lock, a crash) and silent re-attachment makes every one of those look
// like a continuation of a conversation the human may have finished with.
// Explicit resume paths (/resume, the Face control) are unaffected: they
// call resumeLatestConversation() with no argument and still load.
//
// Note this is NOT the amnesia fix from e4e6594 — that one stopped
// selectAgent clearing conversationId on an unchanged agent, and stays.
// Gated so it never clobbers a live turn or an already-attached thread.
property bool autoResume: Config.options?.ai?.autoResume ?? false
// Emitted when a resumable thread exists and we chose not to attach it.
// agentId/conversationId identify it; the rest is for drawing the offer.
signal resumeOffered(string agentId, string conversationId, string title, string updatedAt)
property string offeredConversationId: ""
property string offeredConversationTitle: ""
property string offeredConversationUpdatedAt: ""
property var conversations: []
property bool conversationsLoading: false
property bool conversationsStale: false
onCurrentAgentIdChanged: {
if (root.currentAgentId.length > 0 && root.serverUp
&& root.conversationId.length === 0 && !root.turnActive) {
root.resumeLatestConversation(!root.autoResume);
}
if (root.currentAgentId.length > 0 && root.serverUp)
Qt.callLater(root.refreshItinerary);
else
root._clearItinerary();
}
onServerUpChanged: if (root.serverUp && root.currentAgentId.length > 0)
Qt.callLater(root.refreshItinerary)
property string conversationId: ""
property bool turnActive: false
// ── Turn clock ───────────────────────────────────────────────────────
// Wall-clock for the request in flight. Lives here because turnActive
// does; the chat surface and the pill both read it. `turnStartedAt` is
// when we handed the request to curl (ms epoch, 0 = nothing sent yet),
// `turnElapsedMs` ticks while the turn runs and freezes at the total.
property double turnStartedAt: 0
property int turnElapsedMs: 0
Timer {
running: root.turnActive
interval: 100
repeat: true
onTriggered: root.turnElapsedMs = Date.now() - root.turnStartedAt
}
/* Raw wire events (message_type-tagged objects from the SSE stream). */
signal streamEvent(var event)
/* Stream closed (process exit). exitCode 0 = clean. */
signal streamClosed(int exitCode)
signal agentsRefreshed()
signal serverUnreachable()
// Emitted after a server-owned conversation has been selected and its
// persisted transcript loaded for the active surface.
signal conversationResumed(string agentId, string conversationId, var messages)
// Emitted when a send is blocked by step-up auth. The UI should call
// StepUpAuth.requestAuth("send", callback) and retry on success.
signal stepUpRequired(string actionFamily, string queuedText)
// ── Agent inventory ──────────────────────────────────────────────────
Process {
id: getAgents
running: true
command: ["curl", "-sf", "--max-time", "3", `${root.serverBase}/v1/agents`]
stdout: StdioCollector {
onStreamFinished: {
if (text.length === 0) return;
try {
const list = JSON.parse(text);
const map = {};
// voice_id comes from the agent's `_souveraine` block via
// the public list endpoint. None/absent = use the system
// voice. Speech reads this per active agent.
list.forEach(a => { map[a.id] = { "name": a.name, "description": a.description ?? "", "voice_id": a.voice_id ?? "" }; });
root.agents = map;
root.agentList = Object.keys(map);
root.serverUp = true;
if (!root.agents[root.currentAgentId] && root.agentList.length > 0) {
root.currentAgentId = root.agentList[0];
}
root.agentsRefreshed();
} catch (e) {
console.log("[Souveraine] Could not parse agent list:", e);
}
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
root.serverUp = false;
if (root.autostartEnabled && !root._autostartTried) {
root.startServer();
} else {
root.serverUnreachable();
}
}
}
}
function refreshAgents() {
getAgents.running = true;
}
// ── Persistent itinerary projection ────────────────────────────────
Process {
id: getItinerary
property string agentId: ""
stdout: StdioCollector {
onStreamFinished: {
if (getItinerary.agentId !== root.currentAgentId) return;
if (text.length === 0) {
root.itineraryStale = true;
return;
}
try {
root.itinerary = JSON.parse(text);
root.itineraryAgentId = getItinerary.agentId;
root.itineraryStale = false;
} catch (e) {
root.itineraryStale = true;
console.log("[Souveraine] Could not parse itinerary:", e);
}
}
}
onExited: exitCode => {
if (exitCode !== 0 && getItinerary.agentId === root.currentAgentId)
root.itineraryStale = true;
}
}
function _clearItinerary() {
root.itineraryAgentId = root.currentAgentId;
root.itinerary = ({
"exists": false,
"active": false,
"title": "",
"current": 0,
"route": "",
"stops": []
});
root.itineraryStale = false;
}
function refreshItinerary() {
const agentId = root.currentAgentId;
if (!root.serverUp || agentId.length === 0) {
root._clearItinerary();
return false;
}
if (root.itineraryAgentId !== agentId)
root._clearItinerary();
getItinerary.running = false;
getItinerary.agentId = agentId;
getItinerary.command = ["bash", "-c",
root._tokenReadLine(agentId)
+ `curl -sf --max-time 5 "${root.serverBase}/v1/agents/${agentId}/itinerary"`
+ ` -H "Authorization: Bearer $TOKEN"`
];
getItinerary.running = true;
return true;
}
// The inventory used to be fetched exactly once, at shell start, so an
// agent created afterwards stayed invisible until the whole shell was
// reloaded. One curl a minute is cheaper than that surprise. Skipped
// while a turn is in flight so a slow local model isn't competing with
// polling for the server's attention.
Timer {
interval: 60000
repeat: true
running: true
onTriggered: if (!root.turnActive) root.refreshAgents()
}
// ── Server autostart ─────────────────────────────────────────────────
// systemd user unit first (survives shell restarts, journald logging);
// bare nohup fallback for systems without it. One attempt per shell
// session — a broken install shouldn't spawn-loop.
Process {
id: serverStarter
command: ["bash", "-c",
`if command -v systemctl >/dev/null && systemctl --user list-unit-files souveraine.service &>/dev/null; then
systemctl --user start souveraine.service
else
nohup ${root.serverBin} server >/dev/null 2>&1 &
fi`]
onExited: {
serverRetryTimer.start();
}
}
Timer {
id: serverRetryTimer
interval: 2500
repeat: false
onTriggered: root.refreshAgents()
}
function startServer() {
if (root._autostartTried) return;
root._autostartTried = true;
console.log("[Souveraine] server not reachable — starting it");
serverStarter.running = true;
}
function selectAgent(agentId) {
if (!root.agents[agentId]) return false;
// Re-affirming the agent that is already active is a no-op, and it has
// to be. Ai.qml re-selects the persisted agent on every agentsRefreshed,
// and the inventory poll above fires that once a minute, forever. While
// this function cleared conversationId unconditionally, every message
// sent more than a minute after the previous one opened a NEW
// conversation and therefore arrived with no history at all.
//
// Measured on the phone 2026-07-31: 27 conversations for one agent in a
// day, all but two exactly four messages long — system prompt, ambient,
// user, reply. One exchange each. That is the whole of the "she doesn't
// remember what I just said" report, and it is not the turn loop: the
// server assembles history from session.messages correctly, and there
// was simply never more than one exchange in a session to assemble.
//
// Switching agents is a decision. Polling is not.
if (agentId === root.currentAgentId) return true;
// A live turn belongs to the current conversation. Switching beneath
// it would render one agent's response in another agent's surface.
if (root.turnActive) return false;
// Clear before the id changes, so onCurrentAgentIdChanged observes an
// empty conversation and re-attaches the incoming agent's own latest
// thread rather than leaving her on a blank one.
listConversations.running = false;
root.conversationId = "";
root.dismissOfferedResume();
root.conversations = [];
root.conversationsStale = false;
root.currentAgentId = agentId;
return true;
}
function newConversation() {
root.conversationId = "";
root.dismissOfferedResume();
}
// ── Server-derived resume ───────────────────────────────────────────
// The GUI keeps no per-agent conversation map. The server is the source
// of truth: it persists conversations under each agent and this query
// hydrates them after a server restart before returning the latest one.
Process {
id: listConversations
property string agentId: ""
// Set by resumeLatestConversation(true): announce the thread rather
// than loading it.
property bool offerOnly: false
// Set by refreshConversations(): update the footer picker without
// attaching to or offering any thread.
property bool listOnly: false
stdout: StdioCollector {
onStreamFinished: {
if (listConversations.agentId !== root.currentAgentId) return;
// Empty stdout is a FAILED request, not an empty agent: curl -sf
// writes nothing on 4xx/5xx. Conflating the two clears
// conversationId and hands the surface an empty transcript to
// render, so one transient hiccup wipes the visible thread and
// orphans the live one. Only a parsed response is authoritative.
if (text.length === 0) {
root.conversationsStale = true;
console.log("[Souveraine] empty conversation list response — leaving current conversation in place");
return;
}
let conversations = [];
try {
conversations = JSON.parse(text);
} catch (e) {
root.conversationsStale = true;
console.log("[Souveraine] Could not parse conversation list:", e);
return;
}
root.conversations = conversations;
root.conversationsStale = false;
if (listConversations.listOnly) return;
if (conversations.length === 0) {
// The server answered, and the answer is "none yet".
root.conversationId = "";
root.conversationResumed(root.currentAgentId, "", []);
return;
}
// Server sorts by updated_at descending — [0] is her latest.
const latest = conversations[0];
if (listConversations.offerOnly) {
// Announce, don't attach. conversationId stays empty, so a
// send without accepting mints a fresh thread on purpose.
root.offeredConversationId = latest.id;
root.offeredConversationTitle = latest.title ?? "";
root.offeredConversationUpdatedAt = latest.updated_at ?? "";
root.resumeOffered(listConversations.agentId, latest.id,
latest.title ?? "", latest.updated_at ?? "");
return;
}
root._loadConversation(listConversations.agentId, latest.id);
}
}
onExited: exitCode => {
root.conversationsLoading = false;
// A failed list fetch is transient (server busy, network blip).
// Do NOT clear conversationId — that orphans the live thread and
// forces the next send to mint a fresh conversation. Log and leave
// state alone; the next refresh or /resume retries.
if (exitCode !== 0 && listConversations.agentId === root.currentAgentId) {
root.conversationsStale = true;
console.log("[Souveraine] conversation list fetch failed (exit " + exitCode + ") — leaving current conversation in place");
}
}
}
Process {
id: loadConversation
property string agentId: ""
property string requestedConversationId: ""
stdout: StdioCollector {
onStreamFinished: {
if (loadConversation.agentId !== root.currentAgentId) return;
// Same rule as the list above: no body means the fetch failed.
// Attaching to the id anyway and announcing an empty transcript
// would blank the surface while claiming the thread is loaded.
if (text.length === 0) {
console.log("[Souveraine] empty transcript response — leaving current conversation in place");
return;
}
try {
const messages = JSON.parse(text);
root.conversationId = loadConversation.requestedConversationId;
root.conversationResumed(root.currentAgentId, root.conversationId, messages);
// If the shell reloaded in the middle of a turn, the POST
// socket that started it is gone but the turn belongs to
// the server and is still running. Replay its journal and
// follow the live tail without posting another message.
Qt.callLater(root._reattachActiveTurn);
} catch (e) {
console.log("[Souveraine] Could not parse conversation transcript:", e);
}
}
}
onExited: exitCode => {
// Transient failure (e.g. a 5xx on GET /messages). Don't clear —
// see listConversations.onExited. Leaving conversationId alone keeps
// an already-attached thread reachable instead of forcing a new one.
if (exitCode !== 0 && loadConversation.agentId === root.currentAgentId) {
console.log("[Souveraine] conversation transcript fetch failed (exit " + exitCode + ") — leaving current conversation in place");
}
}
}
// offerOnly: fetch the latest thread but announce it instead of attaching.
// Defaults to false so every existing caller keeps its old behaviour.
function resumeLatestConversation(offerOnly) {
if (!root.serverUp || root.currentAgentId.length === 0 || root.turnActive
|| listConversations.running) return false;
listConversations.offerOnly = (offerOnly === true);
listConversations.listOnly = false;
listConversations.agentId = root.currentAgentId;
listConversations.command = [
"curl", "-sf", "--max-time", "5",
`${root.serverBase}/v1/conversations?agent_id=${encodeURIComponent(root.currentAgentId)}`
];
root.conversationsLoading = true;
listConversations.running = true;
return true;
}
function refreshConversations() {
if (!root.serverUp || root.currentAgentId.length === 0
|| listConversations.running) return false;
listConversations.offerOnly = false;
listConversations.listOnly = true;
listConversations.agentId = root.currentAgentId;
listConversations.command = [
"curl", "-sf", "--max-time", "5",
`${root.serverBase}/v1/conversations?agent_id=${encodeURIComponent(root.currentAgentId)}`
];
root.conversationsLoading = true;
listConversations.running = true;
return true;
}
// Take up an offer made by resumeOffered. No-op if the offer has gone
// stale (a turn started, or something else attached in the meantime).
function acceptOfferedResume() {
if (root.offeredConversationId.length === 0 || root.turnActive) return false;
if (root.conversationId.length > 0) return false;
root._loadConversation(root.currentAgentId, root.offeredConversationId);
root.dismissOfferedResume();
return true;
}
// Decline. The thread stays on the server; we simply start fresh.
function dismissOfferedResume() {
root.offeredConversationId = "";
root.offeredConversationTitle = "";
root.offeredConversationUpdatedAt = "";
}
function loadConversationById(conversationId) {
if (!root.serverUp || root.currentAgentId.length === 0 || root.turnActive
|| conversationId.length === 0) return false;
root.dismissOfferedResume();
root._loadConversation(root.currentAgentId, conversationId);
return true;
}
function _loadConversation(agentId, conversationId) {
loadConversation.agentId = agentId;
loadConversation.requestedConversationId = conversationId;
loadConversation.command = ["bash", "-c",
root._tokenReadLine(agentId)
+ `curl -sf --max-time 10 "${root.serverBase}/v1/conversations/${conversationId}/messages"`
+ ` -H "Authorization: Bearer $TOKEN"`
];
loadConversation.running = true;
}
Process {
id: reattachRequester
property bool receivedEvent: false
stdout: SplitParser {
onRead: data => {
if (data.length === 0 || !data.startsWith("data:")) return;
let event;
try {
event = JSON.parse(data.slice(5).trim());
} catch (e) {
console.log("[Souveraine] Unparseable replay SSE line:", data);
return;
}
reattachRequester.receivedEvent = true;
root.streamEvent(event);
}
}
onExited: exitCode => {
if (root.turnStartedAt > 0)
root.turnElapsedMs = Date.now() - root.turnStartedAt;
root.turnActive = false;
// A 409 means there was no turn to recover. It is an ordinary
// resume, not a failed response and must not finish a blank card.
if (reattachRequester.receivedEvent)
root.streamClosed(exitCode);
}
}
function _reattachActiveTurn() {
if (root.conversationId.length === 0 || requester.running || reattachRequester.running)
return;
reattachRequester.receivedEvent = false;
reattachRequester.command = ["bash", "-c",
root._tokenReadLine(root.currentAgentId)
+ `curl --no-buffer -sf "${root.serverBase}/v1/conversations/${root.conversationId}/events"`
+ ` -H "Authorization: Bearer $TOKEN"`
];
root.turnStartedAt = Date.now();
root.turnElapsedMs = 0;
root.turnActive = true;
reattachRequester.running = true;
}
// ── Ambient sensorium ────────────────────────────────────────────────
// What the desktop feels like at the moment of speaking. Cheap,
// synchronous reads here; the cursor needs a hyprctl round-trip and is
// collected in the send chain. Device sensors (SouveraineOS positional
// data) extend collectAmbient().
property string _cursorPos: ""
function collectAmbient() {
if (!root.ambientEnabled) return "";
const lines = [];
const active = ToplevelManager.activeToplevel;
if (active) {
lines.push(`active window: ${active.appId ?? "?"} — "${active.title ?? ""}"`);
}
const tops = ToplevelManager.toplevels?.values ?? [];
if (tops.length > 0) {
const apps = tops.map(t => t.appId).filter(Boolean);
const counts = {};
apps.forEach(a => counts[a] = (counts[a] ?? 0) + 1);
const summary = Object.entries(counts)
.map(([app, n]) => n > 1 ? `${app} (${n})` : app)
.join(", ");
lines.push(`open: ${summary}`);
}
if (root._cursorPos.length > 0) {
lines.push(`cursor: ${root._cursorPos}`);
}
// Joining the face loads its skill; leaving unloads it. Attached here
// because ambient is already the per-send block the surface owns, so
// the cost of not being joined is exactly zero tokens rather than a
// flag the server has to remember to check.
if (typeof Face !== "undefined" && Face.joined) {
lines.push(Face.skill);
}
if (typeof HidController !== "undefined" && HidController.active) {
lines.push(HidController.skill);
}
return lines.join("\n");
}
// Compositor-specific cursor read. hyprctl on Hyprland, kdotool on KDE;
// anything else just skips the cursor line — ambient degrades gracefully,
// it never blocks the send.
//
// 2026-08-12: `command -v hyprctl` tests whether the tool is *installed*,
// not whether it *answered*. On the phone hyprctl is present (a Lua-eval
// shim) but there is no Hyprland under viewtop, so it printed
// "HYPRLAND_INSTANCE_SIGNATURE not set!" and StdioCollector stored that
// sentence as the cursor position — shipped in every ambient block, every
// turn. Validate the shape of the reply; an answer that isn't a
// coordinate pair is not an answer.
Process {
id: cursorProc
command: ["bash", "-c",
`if command -v hyprctl >/dev/null; then hyprctl cursorpos 2>/dev/null;
elif command -v kdotool >/dev/null; then kdotool getmouselocation 2>/dev/null;
fi`]
stdout: StdioCollector {
onStreamFinished: {
const reply = text.trim();
// "1234, 567" from hyprctl; kdotool's x:N y:N is normalised
// by the caller below. Anything else is discarded.
const pair = reply.match(/^(-?\d+)\s*,\s*(-?\d+)$/);
const kde = reply.match(/x:\s*(-?\d+)\s+y:\s*(-?\d+)/);
if (pair) {
root._cursorPos = `${pair[1]}, ${pair[2]}`;
} else if (kde) {
root._cursorPos = `${kde[1]}, ${kde[2]}`;
} else {
root._cursorPos = "";
}
}
}
onExited: {
root._ensureConversationThenRequest();
}
}
// ── Send chain: cursor → conversation → stream ───────────────────────
property string _queuedText: ""
/* Send a user message with ambient context. Returns false if the
server is down or no agent is selected. Returns "step-up" if
step-up auth is required but no valid grant exists — the caller
should trigger StepUpAuth.requestAuth("send") and retry. */
function send(text) {
if (!root.serverUp || root.currentAgentId.length === 0) return false;
if (text.length === 0) return false;
if (root.turnActive) return false;
// Step-up gate: if enabled and no valid send grant exists, block
// the send and emit a signal so the UI can trigger auth + retry.
// Break-glass grants bypass normal step-up — they are one-time,
// short-lived, and journaled.
if (Config.options?.lock?.stepUp?.enabled
&& typeof StepUpAuth !== "undefined"
&& !StepUpAuth.isGranted("send")
&& !StepUpAuth.isBreakGlass("send")) {
root.stepUpRequired("send", text);
return "step-up";
}
root._queuedText = text;
if (root.ambientEnabled) {
cursorProc.running = true; // chain continues in onExited
} else {
root._ensureConversationThenRequest();
}
return true;
}
Process {
id: createConversation
stdout: StdioCollector {
onStreamFinished: {
try {
const conv = JSON.parse(text);
root.conversationId = conv.id;
root._makeRequest();
} catch (e) {
console.log("[Souveraine] conversation create failed:", text);
root.streamClosed(1);
}
}
}
}
function _ensureConversationThenRequest() {
if (root.conversationId.length > 0) {
root._makeRequest();
return;
}
// Speaking instead of accepting the offered thread is an explicit
// fresh start. Retire the offer before minting the new conversation so
// the footer cannot keep advertising "Continue" over an active one.
root.dismissOfferedResume();
createConversation.command = [
"curl", "-sf", "-X", "POST",
`${root.serverBase}/v1/conversations`,
"-H", "Content-Type: application/json",
"--data", JSON.stringify({ "agent_id": root.currentAgentId })
];
createConversation.running = true;
}
property string requestScriptFilePath: "/tmp/quickshell/ai/souveraine-request.sh"
FileView {
id: requesterScriptFile
}
function _tokenReadLine(agentId) {
// Bearer token read at request time so rotation works.
return `TOKEN=$(cat "$HOME/.souveraine/server/agents/${agentId}/api_token" 2>/dev/null)\n`;
}
function _makeRequest() {
const data = {
"messages": [{ "role": "user", "content": root._queuedText }],
"stream": true
};
const ambient = root.collectAmbient();
if (ambient.length > 0) data["ambient"] = ambient;
root._queuedText = "";
const scriptContent = "#!/usr/bin/env bash\n"
+ root._tokenReadLine(root.currentAgentId)
+ `curl --no-buffer -sS -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/messages"`
+ ` -H 'Content-Type: application/json'`
+ ` -H "Authorization: Bearer $TOKEN"`
+ ` --data '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
+ "\n";
const shellScriptPath = CF.FileUtils.trimFileProtocol(root.requestScriptFilePath);
requesterScriptFile.path = Qt.resolvedUrl(shellScriptPath);
requesterScriptFile.setText(scriptContent);
requester.command = ["bash", shellScriptPath];
root.turnStartedAt = Date.now();
root.turnElapsedMs = 0;
root.turnActive = true;
requester.running = true;
}
Process {
id: requester
stdout: SplitParser {
onRead: data => {
if (data.length === 0 || !data.startsWith("data:")) return;
let event;
try {
event = JSON.parse(data.slice(5).trim());
} catch (e) {
console.log("[Souveraine] Unparseable SSE line:", data);
return;
}
root.streamEvent(event);
}
}
onExited: (exitCode, exitStatus) => {
// Freeze the clock on the real total — the 100ms tick can be up to
// one interval behind when the process exits.
if (root.turnStartedAt > 0)
root.turnElapsedMs = Date.now() - root.turnStartedAt;
root.turnActive = false;
root.streamClosed(exitCode);
}
}
// ── Backchannel ──────────────────────────────────────────────────────
Process {
id: backchannelProc
property string script: ""
command: ["bash", "-c", script]
}
function cancelTurn() {
if (root.conversationId.length === 0) return;
backchannelProc.script = root._tokenReadLine(root.currentAgentId)
+ `curl -sf -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/cancel"`
+ ` -H "Authorization: Bearer $TOKEN"`;
backchannelProc.running = true;
}
function interject(text) {
if (root.conversationId.length === 0 || text.length === 0) return;
const body = JSON.stringify({ "text": text });
backchannelProc.script = root._tokenReadLine(root.currentAgentId)
+ `curl -sf -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/interject"`
+ ` -H 'Content-Type: application/json'`
+ ` -H "Authorization: Bearer $TOKEN"`
+ ` --data '${CF.StringUtils.shellSingleQuoteEscape(body)}'`;
backchannelProc.running = true;
}
}

View file

@ -0,0 +1,350 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import Quickshell
import Quickshell.Io
import QtQuick
/**
* Speech — TTS egress for the shell (the Read Aloud seam, TASK-18).
*
* Souveraine owns the who→voice mapping. The TTS endpoint (tts_url) is a
* substrate concern, read from VoiceConfig at /v1/config. The VOICE is a
* per-agent concern: each agent's [_souveraine].voice_id rides on the public
* agent list, and on every speak()/prefetch() this service resolves it — the
* active agent's own voice wins, the system voice_id (/v1/config) is the
* fallback for an agent who hasn't set one. The shell picks no voice of its
* own; souveraine decides how each agent sounds, and they need not match.
*
* Config.options.speech.tts.enable is the user's kill switch (settings →
* Speech); Config.options.speech.tts.endpoint, when set, overrides the
* server-mapped tts_url.
*
* Prefetch: the latest assistant reply is synthesized into the single tmpfs
* file in the background the moment it finishes streaming, so tapping Speak
* is near-instant when the audio is already ready. Prefetch NEVER auto-plays
* — the user always initiates playback. A prefetch that fails is not retried
* (the manual Speak path retries on its own when tapped). Only the latest
* reply is kept; a new/edited reply invalidates the cached file because
* readiness is keyed on the exact text.
*/
Singleton {
id: root
readonly property bool enabled: Config.options?.speech?.tts?.enable ?? false
// `speaking` is the OR that surfaces have always read — kept as-is so
// nothing downstream changes meaning under them.
property bool speaking: synthProc.running || playProc.running
// But one boolean cannot distinguish "waiting on the synthesizer" from
// "audio is coming out of the speaker", and those look nothing alike to a
// person: synthesis of a short line measured ~11s against the service,
// and during all of it the shell showed the same state it shows while
// actually talking. That is the whole reason a press feels unacknowledged
// and gets pressed again.
//
// Split, so a surface can show a spinner for one and a level for the
// other, and so a stop button can say which thing it is about to stop.
readonly property bool synthesizing: synthProc.running
readonly property bool playing: playProc.running
property string lastError: ""
property string _pendingText: ""
// Resolved endpoint + voice from the last /v1/config lookup, kept so a
// transient synth failure can be retried without re-resolving.
property string _synthUrl: ""
// The substrate's system voice, from /v1/config. This is the fallback —
// the voice of "no agent picked" or an agent who hasn't set her own.
property string _systemVoice: ""
// The voice actually used this turn: the active agent's own voice if she
// has one ([_souveraine].voice_id via the agent list), else the system
// voice. Recomputed at every speak()/prefetch() so switching agents
// switches voice without a re-fetch — the agent list is already local.
property string _synthVoice: ""
// Retry cap: the VibeVoice server can intermittently return 500 (concurrency
// on its shared model — now server-locked, but a collision still costs one
// failed request) or time out on a slow synth. One retry recovers the common
// case; a second failure surfaces a human message.
property int _synthAttempts: 0
readonly property int _synthMaxAttempts: 2
readonly property string _outFile: (Quickshell.env("XDG_RUNTIME_DIR") || "/tmp") + "/souveraine-speech.mp3"
// What the file on disk currently holds (the exact text it was synthesized
// from), or "" if nothing valid is cached. Keyed on text so a new or edited
// reply naturally misses → Speak synthesizes fresh. Set only on a successful
// synth; cleared by stop()/speak()/prefetch() of different text.
property string _readyText: ""
// speak — synthesize and play `text` in the agent's voice. A new call
// replaces any in-flight synthesis or playback. If `text` is already
// cached on disk (from a prefetch), skip synthesis and play instantly.
// Check the cache BEFORE stopping — a prefetch that just completed should
// be honored, not killed and re-requested.
function speak(text) {
const t = String(text ?? "").trim();
if (t.length === 0 || !root.enabled) return;
root.lastError = "";
// Prefetch hit: audio for exactly this text is already on disk — play
// it straight away, no synth round-trip. Don't call stop() here;
// if a prefetch is still running for a *different* message, stop()
// would kill it — but our text matches, so we know the file is ours.
if (root._readyText === t) {
stop();
root._pendingText = t;
root._play();
return;
}
// No cache hit — stop anything in-flight and synthesize fresh.
stop();
root._pendingText = t;
root._synthAttempts = 0;
root._readyText = "";
// If the endpoint is already resolved, skip the /v1/config round-trip
// and go straight to synthesis. Voice is recomputed locally — the
// agent may have switched since the last turn — so it's never the gate.
if (root._synthUrl.length > 0) {
root._applyVoice();
root._fireSynth(false /*interactive*/);
} else {
voiceLookup.running = true;
}
}
// prefetch — synthesize `text` into the cache file in the background so a
// later speak() of the same text plays instantly. Does NOT play. A prefetch
// failure is silent and not retried; the eventual manual speak() will
// synthesize (with its own retry) if the text still isn't ready.
function prefetch(text) {
const t = String(text ?? "").trim();
if (t.length === 0 || !root.enabled) return;
// Already have exactly this text ready — don't re-request it.
if (root._readyText === t) return;
// Don't let a prefetch clobber a speak() the user just initiated.
if (root.speaking && root._pendingText !== t) return;
root._pendingText = t;
root._synthAttempts = 0;
// Mark this as a background prefetch so voiceLookup chains into
// _fireSynth(prefetch=true) instead of treating it as interactive.
synthProc._prefetch = true;
// If the endpoint is already resolved, go straight to synth; voice is
// recomputed locally for the current agent. Otherwise resolve via
// /v1/config first.
if (root._synthUrl.length > 0) {
root._applyVoice();
root._fireSynth(true /*prefetch*/);
} else {
voiceLookup.running = true;
}
}
function stop() {
voiceLookup.running = false;
synthProc.running = false;
playProc.running = false;
retryTimer.running = false;
}
// resynthesize — request fresh audio for text we may already have cached.
//
// speak() opens with a cache check keyed on the text itself, and
// re-synthesis is by definition the *same text* — so calling speak() to
// "try again" is guaranteed to hit the cache and replay the identical
// broken audio. The one control that exists for "that came out wrong"
// could not do the only thing it is for.
//
// Invalidating _readyText before delegating is the whole fix: it forces
// speak() down the synthesis path rather than the playback path.
function resynthesize(text) {
const t = String(text ?? "").trim();
if (t.length === 0 || !root.enabled) return;
stop();
root._readyText = "";
root.speak(t);
}
// The active agent's own voice, if she has one. The shell picks no voice
// of its own: souveraine owns the who→voice mapping, and that mapping is
// per-agent now — [_souveraine].voice_id rides on the public agent list.
// "" means "this agent sounds like the substrate" → system-voice fallback.
function _agentVoice() {
const a = Souveraine.agents[Souveraine.currentAgentId];
return (a && a.voice_id && a.voice_id.length > 0) ? a.voice_id : "";
}
// Recompute the turn's voice from the active agent + cached system voice.
// Called on every speak()/prefetch() (agent may have switched since last
// turn) and after a /v1/config fetch. No fetch here — both inputs are local.
function _applyVoice() {
root._synthVoice = root._agentVoice() || root._systemVoice;
}
// Play whatever is in the cache file.
function _play() {
playProc.running = true;
}
// Fire (or re-fire) the synth request with the already-resolved url+voice.
// `prefetch` marks the result as a background fill (no playback, no retry,
// populate _readyText on success) vs. an interactive speak().
function _fireSynth(prefetch) {
synthProc._prefetch = prefetch;
synthProc.command = [
"curl", "-sf", "--max-time", "180",
"-X", "POST", "-H", "Content-Type: application/json",
"-d", JSON.stringify({
input: root._pendingText,
voice: root._synthVoice,
model: "vibevoice-v1"
}),
"-o", root._outFile,
`${root._synthUrl.replace(/\/$/, "")}/audio/speech`
];
synthProc.running = true;
}
// Delay before a retry — short, just long enough for the server to clear.
Timer {
id: retryTimer
interval: 750
repeat: false
onTriggered: root._fireSynth(false /*interactive speak retries only*/)
}
// ── who → voice, from souveraine ─────────────────────────────────────
Process {
id: voiceLookup
command: ["curl", "-sf", "--max-time", "3", `${Souveraine.serverBase}/v1/config`]
stdout: StdioCollector {
onStreamFinished: {
let ttsUrl = Config.options?.speech?.tts?.endpoint ?? "";
let voice = "";
try {
const cfg = JSON.parse(text);
voice = cfg?.voice?.voice_id ?? "";
if (ttsUrl.length === 0)
ttsUrl = cfg?.voice?.tts_url ?? "";
} catch (e) {
console.log("[Speech] could not parse /v1/config:", e);
}
if (ttsUrl.length === 0) {
root.lastError = "no TTS endpoint (server unmapped, shell override empty)";
console.log("[Speech]", root.lastError);
return;
}
root._synthUrl = ttsUrl;
// `voice` here is the SYSTEM voice — the substrate speaking as
// itself. Stash it as the fallback, then let the active agent
// override it: she sounds like herself when she has a voice.
root._systemVoice = voice;
root._applyVoice();
// If speak() was the caller, synth+play; if prefetch() was, the
// flag is already on synthProc from _fireSynth — but voiceLookup
// can be the first hop of a prefetch, so default to interactive.
root._fireSynth(synthProc._prefetch ?? false);
}
}
onExited: (exitCode) => {
// Exit 15 = SIGTERM from stop(); not a real failure — suppress.
if (exitCode !== 0 && exitCode !== 15) {
root.lastError = "souveraine server unreachable for voice mapping";
console.log("[Speech]", root.lastError);
}
}
}
// ── synthesis ────────────────────────────────────────────────────────
Process {
id: synthProc
// True when this synth is a background prefetch (no playback, no retry).
property bool _prefetch: false
onExited: (exitCode) => {
if (exitCode === 0) {
// Success: the file now holds _pendingText.
root._readyText = root._pendingText;
if (synthProc._prefetch) {
// Prefetch only fills the cache; never auto-plays.
synthProc._prefetch = false;
return;
}
root._play();
return;
}
// Prefetch failures are silent and not retried — per design, we
// don't burn a second request on background fill. The manual
// speak() path will synthesize (and retry) when actually tapped.
if (synthProc._prefetch) {
synthProc._prefetch = false;
console.log("[Speech] prefetch failed (curl exit " + exitCode + ") — will synth on demand when spoken");
return;
}
// Exit 15 = SIGTERM — stop() killed this synth (user tapped stop,
// or a new speak() replaced it). This is always intentional, never
// an error. Suppress silently.
if (exitCode === 15) {
return;
}
// Transient failures: exit 22 = HTTP ≥400 (the server's intermittent
// 500 under model concurrency), exit 28 = timeout (slow synth).
// Retry once; a collision or a slow first attempt usually clears.
const transient = (exitCode === 22 || exitCode === 28);
root._synthAttempts += 1;
if (transient && root._synthAttempts < root._synthMaxAttempts) {
console.log(`[Speech] synth exit ${exitCode}, retry ${root._synthAttempts}/${root._synthMaxAttempts - 1}`);
retryTimer.running = true;
return;
}
if (exitCode === 28) {
root.lastError = root._synthAttempts > 1
? "voice synthesis timed out twice — the server may be busy or the reply too long"
: "voice synthesis timed out";
} else if (exitCode === 22) {
root.lastError = root._synthAttempts > 1
? "voice synthesis failed twice — the TTS server returned an error (may be busy)"
: "voice synthesis failed (server error)";
} else {
root.lastError = `voice synthesis failed (curl exit ${exitCode})`;
}
console.log("[Speech]", root.lastError);
}
}
// ── playback ─────────────────────────────────────────────────────────
//
// No `sh -c` wrapper, deliberately. The previous form was
//
// ["sh", "-c", "mpv ... || ffplay ..."]
//
// and a compound command means sh does NOT exec-replace itself: it forks
// the player as a child and waits. So `playProc.running = false` sends
// SIGTERM to *sh*, sh dies, and the player keeps making noise as an
// orphan. Reproduced directly: killing the wrapper left the child alive.
//
// That is why stop() never stopped anything, and why two speak() calls in
// a row played over each other instead of replacing one another.
//
// One player, invoked directly, so the pid quickshell holds is the pid
// making sound. The ffplay fallback is dropped rather than fixed: its
// invocation was already wrong (raw input needs -i) and a fallback is
// exactly what forced the shell wrapper that broke the kill. mpv is
// present on both the laptop and the phone; if it is ever missing, the
// honest outcome is a named error, not silent audio nobody can stop.
//
// --keep-open=no --idle=no is not cosmetic. mpv can reach the end of a
// stream, print (Paused), and never exit — which, since `speaking` is
// derived from playProc.running, renders as speaking forever.
Process {
id: playProc
command: ["mpv", "--no-video", "--really-quiet",
"--keep-open=no", "--idle=no", root._outFile]
onExited: (exitCode) => {
// Exit 15 = SIGTERM from stop(); not a real failure — suppress.
// This now actually reaches mpv rather than a shell wrapper.
if (exitCode !== 0 && exitCode !== 15) {
root.lastError = `audio playback failed (exit ${exitCode}) — is mpv installed?`;
console.log("[Speech]", root.lastError);
}
}
}
}

View file

@ -0,0 +1,420 @@
// Step-up authentication singleton for the Souveraine shell.
//
// Provides short-lived, in-memory grants for sensitive operations (send, push,
// delete, payment, physical access, admin). A grant is minted after a separate
// PAM conversation succeeds via the `souveraine-stepup` PAM service. This
// service never unlocks the session and never accepts a boolean from an agent
// as proof.
//
// Architecture reference: SESSION-TRUST-ARCHITECTURE.md — "Step-up
// authentication is a separate PamContext, using a dedicated PAM service such
// as souveraine-stepup. It never unlocks the session and it never accepts a
// boolean from an agent as proof. A successful result mints a short-lived,
// in-memory grant bound to the local action family."
//
// The grant TTL is a deliberate policy setting (grantTtlMs), not an
// implementation accident. It defaults to 5 minutes and is configurable via
// Config.options.lock.stepUp.grantTtlMs.
//
// Grants are cleared on: lock, session end, PAM failure, and expiry. The
// expiry timer runs every 30 seconds; the precision of expiry is intentionally
// coarse because step-up is a convenience layer, not a security kernel.
//
// Integration:
// - GlobalStates.onScreenLockedChanged -> revokeAll()
// - Session actionFailed / lock verbs -> revokeAll()
// - 30-second Timer -> expire stale grants
//
// The PAM service file `/etc/pam.d/souveraine-stepup` is NOT shipped by the
// shell — it is root-owned system config, delivered by the souveraine package.
// Without it every request refuses at start() rather than falling through to
// something weaker.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import Quickshell.Services.Pam
import qs
import qs.services
import qs.modules.common
import qs.modules.common.functions
Singleton {
id: root
// --- Action families ----------------------------------------------------
// These are the semantic groupings of operations that require step-up.
// A grant for "send" covers message sends and content pushes; "delete"
// covers irreversible removals; "payment" covers financial transactions;
// "physical" covers door locks, device unlock beyond session; "admin"
// covers system administration that session-level lock does not gate.
readonly property string familySend: "send"
readonly property string familyDelete: "delete"
readonly property string familyPayment: "payment"
readonly property string familyPhysical: "physical"
readonly property string familyAdmin: "admin"
// --- Grant model --------------------------------------------------------
// actionFamily -> { granted: timestamp_ms, expires: timestamp_ms }
// Grants are plain objects, not QML types, because the set of families is
// open-ended and callers only need the two timestamps.
property var grants: ({})
// Default 5 minutes. Overridable via Config.options.lock.stepUp.grantTtlMs
// so the policy knob lives in the user's config, not in source.
property int grantTtlMs: Config.options?.lock?.stepUp?.grantTtlMs ?? 300000
// --- Signals ------------------------------------------------------------
signal authSucceeded(string actionFamily)
signal authFailed(string actionFamily)
signal grantExpired(string actionFamily)
signal grantRevoked(string actionFamily)
// PAM needs an answer. `secret` is false for prompts that should be echoed.
// An empty respond() is valid — the FPC factor prompts "Touch and hold" and
// consumes its ticket on a blank answer.
signal promptRequired(string actionFamily, string message, bool secret)
// --- Internal state -----------------------------------------------------
// The callback for the in-flight auth request. Only one auth conversation
// runs at a time; a second requestAuth while one is pending is refused.
// This matches PAM's own serial conversation model — you cannot interleave
// two pam_authenticate calls on the same handle.
property var _pendingCallback: null
property string _pendingFamily: ""
// --- PAM conversation ---------------------------------------------------
// A second, narrower PamContext than the lock surface's (doctrine §3),
// pointed at the system souveraine-stepup service. It never unlocks the
// session. Same mechanism LockContext already uses for fprintd.conf.
//
// The prompt is not owned here: pamMessage raises promptRequired and a
// surface answers with respond(). One conversation at a time, which is
// PAM's own model and the reason a second requestAuth is refused.
PamContext {
id: stepUpPam
config: "souveraine-stepup"
property string activeFamily: ""
onPamMessage: {
if (stepUpPam.responseRequired) {
root.promptRequired(stepUpPam.activeFamily, stepUpPam.message,
!stepUpPam.responseVisible);
} else if (stepUpPam.message.length > 0) {
console.log(`[step-up] ${stepUpPam.message}`);
}
}
onCompleted: result => {
const family = stepUpPam.activeFamily;
stepUpPam.activeFamily = "";
if (family.length === 0) {
console.log("[step-up] PAM completed with no active family (stale?)");
return;
}
const callback = root._pendingCallback;
root._pendingCallback = null;
root._pendingFamily = "";
if (result === PamResult.Success) {
root._mintGrant(family);
root.authSucceeded(family);
console.log(`[step-up] auth succeeded for ${family}`);
if (callback) callback(true);
} else {
root._clearGrant(family);
root.authFailed(family);
console.log(`[step-up] auth failed for ${family} (${PamResult.toString(result)})`);
if (callback) callback(false);
}
}
}
// --- Public API ---------------------------------------------------------
// requestAuth — initiate a step-up authentication for the given action
// family. The callback receives a boolean: true if the user authenticated
// successfully and a grant was minted, false otherwise.
//
// Returns { ok: true } if the auth flow started, or { ok: false, reason }
// if it was refused (auth already in progress, or empty family).
function requestAuth(actionFamily, callback) {
const family = String(actionFamily || "").trim();
if (!family) return { ok: false, reason: "empty action family" };
if (stepUpPam.active) return { ok: false, reason: "auth in progress" };
// If a valid grant already exists, skip the PAM conversation entirely.
// The caller gets an immediate true and the TTL is not extended — this
// is intentional: repeated requests within the window do not refresh
// the clock, so the grant expires on schedule regardless of how often
// it is checked. A caller that wants a fresh window must revoke first.
if (root.isGranted(family)) {
console.log(`[step-up] grant for ${family} still valid, skipping auth`);
if (callback) callback(true);
return { ok: true, reason: "already granted" };
}
root._pendingCallback = callback;
root._pendingFamily = family;
stepUpPam.activeFamily = family;
if (!stepUpPam.start()) {
stepUpPam.activeFamily = "";
root._pendingCallback = null;
root._pendingFamily = "";
console.warn(`[step-up] PAM refused to start for ${family}`
+ " — is /etc/pam.d/souveraine-stepup installed?");
if (callback) callback(false);
return { ok: false, reason: "pam unavailable" };
}
console.log(`[step-up] auth started for ${family}`);
return { ok: true };
}
// respond — answer the current prompt. Empty text is a legitimate answer,
// not a cancel: that is how the fingerprint factor is accepted.
function respond(text) {
if (!stepUpPam.active) return { ok: false, reason: "no auth in progress" };
stepUpPam.respond(String(text ?? ""));
return { ok: true };
}
// cancel — abandon the in-flight conversation. Completion still fires, so
// the pending callback is answered rather than dropped.
function cancel() {
if (!stepUpPam.active) return { ok: false, reason: "no auth in progress" };
stepUpPam.abort();
return { ok: true };
}
// isGranted — check if a valid (non-expired) grant exists for the given
// action family. Returns true only if the grant exists and has not yet
// exceeded its TTL. Does NOT trigger re-auth; it is a pure read.
function isGranted(actionFamily) {
const family = String(actionFamily || "").trim();
const grant = root.grants[family];
if (!grant) return false;
const now = Date.now();
if (now - grant.granted >= root.grantTtlMs) {
// Grant has expired. Clear it synchronously so the next call does
// not re-read a stale entry, and emit the signal so surfaces can
// react (e.g. disable a send button).
root._clearGrant(family);
root.grantExpired(family);
return false;
}
return true;
}
// revokeGrant — explicitly revoke a single grant. Used when an operation
// completes (the grant served its purpose) or when the caller decides the
// context has changed. Does nothing if no grant exists for that family.
function revokeGrant(actionFamily) {
const family = String(actionFamily || "").trim();
if (!root.grants[family]) return;
root._clearGrant(family);
root.grantRevoked(family);
console.log(`[step-up] grant revoked for ${family}`);
}
// revokeAll — clear every active grant. Called on lock, session end, and
// any condition that invalidates the entire trust surface. This is the
// fail-closed path: if something goes wrong that we cannot characterize
// per-family, all grants die.
function revokeAll() {
const families = Object.keys(root.grants);
const hadBreakGlass = root._breakGlassGrant !== null;
if (families.length === 0 && !hadBreakGlass) return;
root.grants = ({});
root._clearBreakGlass();
families.forEach(family => root.grantRevoked(family));
console.log(`[step-up] all grants revoked (${families.length} families`
+ (hadBreakGlass ? ", break-glass cleared" : "") + ")");
}
// state — return the current grant state as a plain object for IPC
// projection. This is what an agent reads when it needs to know which
// action families are currently authorized. Every field is re-derived at
// call time; nothing is cached trust.
function state() {
const now = Date.now();
const active = {};
const families = Object.keys(root.grants);
families.forEach(family => {
const grant = root.grants[family];
if (grant && (now - grant.granted < root.grantTtlMs)) {
active[family] = {
granted: grant.granted,
expires: grant.granted + root.grantTtlMs,
remainingMs: (grant.granted + root.grantTtlMs) - now
};
}
});
return {
grantTtlMs: root.grantTtlMs,
activeGrants: active,
authInProgress: stepUpPam.active,
pendingFamily: root._pendingFamily,
breakGlassActive: root._breakGlassGrant !== null
};
}
// --- Break-glass grant -------------------------------------------------
// A one-time, short-lived, journaled override for emergency operations.
// This is NOT a config toggle. It is a per-decision, reasoned, logged
// bypass that exists because real emergencies happen and the user must
// be able to act.
//
// Properties:
// - Requires a non-empty reason string (the "why")
// - Short TTL (60 seconds by default, not the normal 5 minutes)
// - One-time: consumed on use, cannot be reused
// - Prominently logged with the reason
// - Cannot be issued while the session is locked
// - Cleared on lock, like all other grants
//
// The break-glass grant is tracked separately from normal grants so
// that audit tools can distinguish "user authenticated normally" from
// "user declared an emergency override".
property var _breakGlassGrant: null
property int breakGlassTtlMs: 60000 // 60 seconds
signal breakGlassIssued(string reason, int expiresAt)
signal breakGlassConsumed(string reason)
signal breakGlassExpired(string reason)
// breakGlass — issue a one-time emergency grant for the given action
// family. Returns { ok: true, expiresAt } or { ok: false, reason }.
//
// The reason is mandatory and logged. If the caller cannot explain why
// they need break-glass, they should use normal step-up auth instead.
function breakGlass(actionFamily, reason) {
const family = String(actionFamily || "").trim();
const why = String(reason || "").trim();
if (!family) return { ok: false, reason: "empty action family" };
if (!why) return { ok: false, reason: "break-glass requires a reason" };
if (GlobalStates.screenLocked)
return { ok: false, reason: "break-glass unavailable while locked" };
if (root._breakGlassGrant)
return { ok: false, reason: "break-glass already active" };
const now = Date.now();
root._breakGlassGrant = {
family: family,
reason: why,
granted: now,
expires: now + root.breakGlassTtlMs
};
console.log(`[step-up] BREAK-GLASS issued for ${family}: "${why}" `
+ `(expires in ${root.breakGlassTtlMs / 1000}s)`);
root.breakGlassIssued(why, now + root.breakGlassTtlMs);
return { ok: true, expiresAt: now + root.breakGlassTtlMs };
}
// isBreakGlass — check if a valid break-glass grant exists for the
// given action family. Unlike normal grants, break-glass is one-time:
// calling this function consumes it. Returns true if the grant was
// valid and consumed, false otherwise.
function isBreakGlass(actionFamily) {
const family = String(actionFamily || "").trim();
const bg = root._breakGlassGrant;
if (!bg) return false;
if (bg.family !== family) return false;
if (Date.now() >= bg.expires) {
root._breakGlassGrant = null;
root.breakGlassExpired(bg.reason);
return false;
}
// Consume the grant — one-time use.
root._breakGlassGrant = null;
root.breakGlassConsumed(bg.reason);
console.log(`[step-up] BREAK-GLASS consumed for ${family}: "${bg.reason}"`);
return true;
}
// Revoke break-glass on lock (fail-closed).
function _clearBreakGlass() {
if (!root._breakGlassGrant) return;
root._breakGlassGrant = null;
}
// --- Internal -----------------------------------------------------------
function _mintGrant(family) {
const now = Date.now();
const next = Object.assign({}, root.grants);
next[family] = { granted: now, expires: now + root.grantTtlMs };
root.grants = next;
}
function _clearGrant(family) {
if (!root.grants[family]) return;
const next = Object.assign({}, root.grants);
delete next[family];
root.grants = next;
}
// --- Lock integration ---------------------------------------------------
// On lock, every grant dies. The session is no longer in a state where
// step-up can meaningfully authorize anything — the user is behind a
// credential gate and any grant minted before the lock would be
// meaningless after it. This is the fail-closed path.
Connections {
target: GlobalStates
function onScreenLockedChanged() {
if (GlobalStates.screenLocked) {
root.revokeAll();
}
}
}
// --- Session end integration --------------------------------------------
// Session.logout() terminates the compositor session. Grants are
// in-memory only and die with the process, but explicit revocation on
// session end ensures the signal fires so surfaces can update their state
// before the session tears down, rather than discovering the grants are
// gone only when they try to read them after the fact.
Connections {
target: Session
function onActionFailed(action, exitCode) {
// If a lock action failed, that means we might be in an
// ambiguous state — the session intended to lock but did not.
// Revoking all grants is the conservative choice: a failed lock
// is a trust anomaly.
if (action === "lock") {
root.revokeAll();
}
}
}
// --- Expiry timer -------------------------------------------------------
// Checks every 30 seconds for grants that have exceeded their TTL. The
// granularity is deliberately coarse: step-up is a convenience layer for
// the user, not a security kernel. Sub-second precision would add
// complexity for no meaningful security gain — the TTL itself is a policy
// setting with a 5-minute default, and 30 seconds of drift on a
// 300-second window is acceptable.
Timer {
id: expiryTimer
interval: 30000
repeat: true
running: true
onTriggered: {
const now = Date.now();
const families = Object.keys(root.grants);
families.forEach(family => {
const grant = root.grants[family];
if (grant && (now - grant.granted >= root.grantTtlMs)) {
root._clearGrant(family);
root.grantExpired(family);
console.log(`[step-up] grant expired for ${family}`);
}
});
}
}
}

View file

@ -0,0 +1,263 @@
pragma Singleton
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Wayland
Singleton {
id: root
function isPinned(appId) {
return Config.options.dock.pinnedApps.indexOf(appId) !== -1;
}
function togglePin(appId) {
if (root.isPinned(appId)) {
Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.filter(id => id !== appId)
} else {
Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.concat([appId])
}
}
// --- Fan-out stacks ------------------------------------------------
// Backing store: Config.options.dock.stacks, a list<string> — string
// list because nested-object arrays don't survive the JsonAdapter.
// Each entry is a JSON object string:
// {"id":"stack-1","name":"Stack 1","members":["appId","appId"]}
// JSON-per-entry is escaping-safe (the first iteration's "name|app,app"
// format corrupted silently on a | or , in a name). id is the stable
// lookup key; name is the display label (rename never breaks lookups).
// Legacy pipe entries still parse (id doubles as name) and get
// rewritten as JSON on the next stacks write.
function parseStack(entry) {
if (entry.startsWith("{")) {
try {
const o = JSON.parse(entry);
return { id: o.id ?? "", name: o.name ?? o.id ?? "", members: o.members ?? [] };
} catch (e) {
return { id: entry, name: entry, members: [] };
}
}
// Legacy "stackId|appId,appId" format.
const bar = entry.indexOf("|");
if (bar === -1) return { id: entry, name: entry, members: [] };
const id = entry.slice(0, bar);
const rest = entry.slice(bar + 1).trim();
const members = rest.length ? rest.split(",").map(s => s.trim()).filter(s => s.length) : [];
return { id: id, name: id, members: members };
}
function stacksList() {
return (Config.options?.dock.stacks ?? []).map(root.parseStack);
}
// appId -> the stackId that contains it, or "" if none.
function stackContaining(appId) {
const low = appId.toLowerCase();
for (const s of root.stacksList()) {
if (s.members.some(m => m.toLowerCase() === low)) return s.id;
}
return "";
}
function encodeStack(s) {
return JSON.stringify({ id: s.id, name: s.name, members: s.members });
}
// Rewrite the whole stacks list from a parsed [{id, name, members}]
// array, dropping any that end up empty.
function writeStacks(parsed) {
Config.options.dock.stacks = parsed
.filter(s => s.members.length > 0)
.map(root.encodeStack);
}
function addToStack(stackId, appId) {
const parsed = root.stacksList();
const existing = parsed.find(s => s.id === stackId);
if (existing) {
if (!existing.members.some(m => m.toLowerCase() === appId.toLowerCase()))
existing.members = existing.members.concat([appId]);
} else {
parsed.push({ id: stackId, members: [appId] });
}
root.writeStacks(parsed);
}
function removeFromStack(stackId, appId) {
const parsed = root.stacksList();
const existing = parsed.find(s => s.id === stackId);
if (!existing) return;
existing.members = existing.members.filter(m => m.toLowerCase() !== appId.toLowerCase());
root.writeStacks(parsed);
}
// --- Drag interactions (Tier 1) ------------------------------------
// Mint a stable id + display name for a fresh stack. Ids scan for the
// max existing numeric suffix, never reuse after a delete (the old
// count-based scheme collided: delete "Stack 1" of two and the next
// combine minted a second "Stack 2" — and id is the lookup key, so two
// stacks silently shared members).
function mintStack() {
let n = 0;
for (const s of root.stacksList()) {
const mi = /^stack-(\d+)$/.exec(s.id);
if (mi) n = Math.max(n, parseInt(mi[1]));
const mn = /^Stack (\d+)$/.exec(s.name);
if (mn) n = Math.max(n, parseInt(mn[1]));
}
return { id: "stack-" + (n + 1), name: "Stack " + (n + 1) };
}
// Rename a stack's display label. The id (lookup key) never changes.
function renameStack(stackId, newName) {
if (!newName) return;
const parsed = root.stacksList();
const s = parsed.find(x => x.id === stackId);
if (!s) return;
s.name = newName;
root.writeStacks(parsed);
}
// Drag `draggedAppId` onto `targetAppId` -> combine. If target is
// already a stack (stackId non-empty), add into it; else make a new
// stack containing target then dragged (target stays on top = first).
function combineIntoStack(targetAppId, draggedAppId, targetStackId) {
if (!draggedAppId || draggedAppId.toLowerCase() === targetAppId.toLowerCase()) return;
// If the dragged app is currently in some stack, pull it out first.
const from = root.stackContaining(draggedAppId);
if (from) root.removeFromStack(from, draggedAppId);
if (targetStackId) {
root.addToStack(targetStackId, draggedAppId);
} else {
const fresh = root.mintStack();
const parsed = root.stacksList();
parsed.push({ id: fresh.id, name: fresh.name, members: [targetAppId, draggedAppId] });
root.writeStacks(parsed);
}
// An app lives in one place: its stack. The standalone pin actually
// leaves config here (it used to be only render-suppressed, leaving
// a phantom pin string behind); unstackMember re-pins on the way out.
const dropPins = [draggedAppId.toLowerCase()];
if (!targetStackId) dropPins.push(targetAppId.toLowerCase());
Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.filter(
id => !dropPins.includes(id.toLowerCase()));
}
// Replace a stack's member order wholesale (arc-reorder commit).
function setStackOrder(stackId, members) {
const parsed = root.stacksList();
const s = parsed.find(x => x.id === stackId);
if (!s) return;
s.members = members;
root.writeStacks(parsed);
}
// Reorder a pinned app within the pinnedApps array. targetPinnedIndex
// is the desired final index (0-based) in Config.options.dock.pinnedApps.
// The app is removed from its current position and spliced in at the
// target; other pins shift to fill / make room.
function reorderPinned(appId, targetPinnedIndex) {
const pinned = Config.options.dock.pinnedApps.slice();
const srcIdx = pinned.findIndex(id => id.toLowerCase() === appId.toLowerCase());
if (srcIdx < 0 || srcIdx === targetPinnedIndex) return;
pinned.splice(srcIdx, 1);
pinned.splice(targetPinnedIndex, 0, appId);
Config.options.dock.pinnedApps = pinned;
}
// Pull a member out of its stack back to a standalone pinned app.
function unstackMember(stackId, appId) {
root.removeFromStack(stackId, appId);
if (!root.isPinned(appId)) root.togglePin(appId);
}
property list<var> apps: {
var map = new Map();
// Fan-out stacks come first, in their configured order. Their
// member appIds are suppressed as standalone pinned entries below
// so an app lives in one place: its stack.
const stacks = root.stacksList();
const stackedMembers = new Set();
const memberToStackKey = new Map();
for (const s of stacks) {
for (const m of s.members) {
stackedMembers.add(m.toLowerCase());
memberToStackKey.set(m.toLowerCase(), "STACK:" + s.id);
}
map.set("STACK:" + s.id, {
pinned: true, toplevels: [], isStack: true, members: s.members,
name: s.name
});
}
// Pinned apps (skip any already living in a stack)
const pinnedApps = Config.options?.dock.pinnedApps ?? [];
for (const appId of pinnedApps) {
if (stackedMembers.has(appId.toLowerCase())) continue;
if (!map.has(appId.toLowerCase())) map.set(appId.toLowerCase(), ({
pinned: true,
toplevels: []
}));
}
// Separator
if (map.size > 0) {
map.set("SEPARATOR", { pinned: false, toplevels: [] });
}
// Ignored apps
const ignoredRegexStrings = Config.options?.dock.ignoredAppRegexes ?? [];
const ignoredRegexes = ignoredRegexStrings.map(pattern => new RegExp(pattern, "i"));
// Open windows
for (const toplevel of ToplevelManager.toplevels.values) {
if (ignoredRegexes.some(re => re.test(toplevel.appId))) continue;
// A running app that belongs to a stack contributes its windows
// to the STACK entry (so tapping the member focuses the open
// window) instead of appearing as a separate icon.
const stackKey = memberToStackKey.get(toplevel.appId.toLowerCase());
if (stackKey) {
map.get(stackKey).toplevels.push(toplevel);
continue;
}
if (!map.has(toplevel.appId.toLowerCase())) map.set(toplevel.appId.toLowerCase(), ({
pinned: false,
toplevels: []
}));
map.get(toplevel.appId.toLowerCase()).toplevels.push(toplevel);
}
var values = [];
for (const [key, value] of map) {
values.push(appEntryComp.createObject(null, {
appId: value.isStack ? key.slice(6) : key,
toplevels: value.toplevels,
pinned: value.pinned,
isStack: value.isStack ?? false,
members: value.members ?? [],
stackName: value.name ?? ""
}));
}
return values;
}
component TaskbarAppEntry: QtObject {
id: wrapper
required property string appId
required property list<var> toplevels
required property bool pinned
property bool isStack: false
property list<var> members: []
property string stackName: ""
}
Component {
id: appEntryComp
TaskbarAppEntry {}
}
}

View file

@ -0,0 +1,112 @@
// One typed view over sessiond's USB posture and mode verbs.
//
// usb-signaller owns configfs, souveraine-upower supplies adjacent charging
// evidence, and federation/probe leases will supply who is attached and who is
// already working there. The shell owns none of it; it asks sessiond and shows
// the answer.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
property bool available: false
property bool busy: false
property string lastError: ""
property string mode: "unknown"
property string rawMode: "unknown_mode"
property var availableModes: []
property string dataRole: "unknown"
property bool chargerKnown: false
property bool chargerOnline: false
property var attachedIdentity: null
property var probeOwner: null
signal refreshed()
signal changeFailed(string reason)
function refresh() {
root._send({ op: "usb" });
}
function setMode(mode) {
if (!["developer", "hid", "kvm", "charging_only"].includes(mode)) {
root.changeFailed("unsupported USB mode: " + mode);
return;
}
root.busy = true;
root._send({ op: "set_usb_mode", mode: mode });
}
function hasMode(rawMode) {
return root.availableModes.includes(rawMode);
}
property var _queued: null
function _send(message) {
if (sock.connected) {
sock.write(JSON.stringify(message) + "\n");
return;
}
root._queued = message;
sock.connected = true;
}
Socket {
id: sock
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/sessiond.sock"
onConnectionStateChanged: {
if (connected && root._queued) {
const message = root._queued;
root._queued = null;
sock.write(JSON.stringify(message) + "\n");
} else if (!connected && root._queued) {
root._queued = null;
root.available = false;
root.busy = false;
root.lastError = "sessiond socket unavailable";
root.changeFailed(root.lastError);
}
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
let reply;
try {
reply = JSON.parse(message);
} catch (error) {
root.lastError = "unparseable USB reply";
root.busy = false;
root.changeFailed(root.lastError);
return;
}
if (reply.ok !== true) {
root.lastError = reply.reason || "USB request refused";
root.busy = false;
root.changeFailed(root.lastError);
return;
}
root.mode = reply.mode ?? "unknown";
root.rawMode = reply.raw_mode ?? "unknown_mode";
root.availableModes = reply.available_modes ?? [];
root.dataRole = reply.data_role ?? "unknown";
root.chargerKnown = reply.charger_online !== null
&& reply.charger_online !== undefined;
root.chargerOnline = reply.charger_online === true;
root.attachedIdentity = reply.attached_identity ?? null;
root.probeOwner = reply.probe_owner ?? null;
root.available = true;
root.busy = false;
root.lastError = "";
root.refreshed();
}
}
}
}

View file

@ -0,0 +1,584 @@
// The shell's door into viewtop's control socket.
//
// The compositor serves one JSON object per line — `{"op":…}` in,
// `{"ok":true,…}` or `{"ok":false,"code":…,"reason":…}` back — the same house
// grammar sessiond speaks, so this is shaped like SessiondPolicy rather than
// inventing a second idea of what talking to a daemon looks like.
//
// **This is the only place the shell addresses the scene.** Every window verb
// the hand can reach — close, kill, place, pose, raise, focus — goes through
// `scene()`. The alternative is what the dial does today: `hyprctl dispatch`
// baked into a surface, which stopped existing under viewtop and took the
// dial's "Kill window" with it. A verb spelled out inside a widget is a verb
// that dies when the compositor changes.
//
// A short-lived connection per request, deliberately. viewtop's socket is
// request/response and holds no session; there is no heartbeat to protect here
// the way SessiondBridge's EOF is load-bearing, so nothing is gained by
// keeping it open and a dropped long-lived socket would need reconnect logic
// to be correct.
//
// Failures are LOUD. A window verb that silently did nothing is exactly the
// bug this exists to close: `overview toggle` shelled out to a handler that
// did not exist and the tap did nothing, silently, for weeks.
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
// The last refusal, for a surface that wants to show one. `close` is a
// request the client may refuse; the hand deserves to see that rather than
// watch nothing happen.
property string lastError: ""
signal refused(string intent, string reason)
signal succeeded(string intent)
// What the compositor last said is on the canvas: `[{id, workspace}, …]`.
// Queried, never cached across opens — a chooser showing a window that
// closed a minute ago is worse than one that takes a moment to fill.
property var windows: []
signal windowsChanged_()
// The serialised form of the last canvas we published, so an unchanged
// answer is not republished.
//
// This is not a micro-optimisation, it is a correctness fix. `windows` is a
// `var` holding a fresh array on every reply, so assigning it fires
// `windowsChanged` whether or not anything changed. A surface that binds a
// ListView model to it therefore had its entire delegate tree — and every
// `ScreencopyView` inside it — destroyed and rebuilt on the poll interval,
// which is a view that flickers and loses its place while you are reading
// it. Republish on *difference*, the same level-not-edge discipline
// `lockhint.rs` applies to `LockedHint`.
property string _lastCanvas: ""
// Which zone is in front, as the compositor last reported it. -1 is
// "not asked yet" and is deliberately not 0: home is 0, so defaulting to it
// would make every surface believe it was on home before the first reply.
property int activeZone: -1
property int zoneCount: 0
// Home is zone 0, matching `workspace::HOME_ZONE` in the compositor. Named
// here rather than written as a literal at each call site so the two ends
// of the wire have one place to disagree if it ever moves.
readonly property int homeZone: 0
// Ask what is open, and where we are. Answers into `windows`/`activeZone`.
function refreshWindows() {
root._send({ op: "workspaces" });
}
// The colour ramp in force, as the compositor last reported it. `-1` is
// "not asked yet" and is deliberately not 100: 100 is identity, a real
// answer, so defaulting to it would have every surface believe the panel
// was untinted before the first reply.
//
// Read back rather than remembered because the compositor is the writer of
// record. A compositor restart resets its ramp to identity, and a shell
// that cached its own idea of the ramp across that would draw a night-light
// indicator over a panel that is no longer warm.
property int gammaValue: -1
property int gammaTemperature: 0
// The output carrying ViewTop's seat attention. Empty means the state
// reply has not arrived yet (or the compositor predates the field).
property string activeOutputName: ""
property var outputs: []
// Panel power, outputs, borders and the ramp. Answers into `gammaValue` /
// `gammaTemperature`.
function refreshState() {
root._send({ op: "state" });
}
// True once the compositor has accepted a `subscribe` and is pushing canvas
// changes down the second socket below.
property bool subscribed: false
// A compositor that does not serve `subscribe` will not start serving it
// while it is running. Latch the refusal, or the disconnect that follows it
// re-arms the retry that the refusal just stopped — which is a reconnect
// loop at timer speed against a socket that will keep saying no. Cleared by
// the shell restarting, which is also when the compositor has changed.
property bool _pushRefused: false
// The poll, which now exists only as the fallback for a compositor too old
// to push.
//
// TASK-60 Q4: *"Either the compositor pushes zone/window changes, or this
// surface asks synchronously when it opens and stops guessing in between."*
// Polling was the proximate cause of the multitasking view scaling the
// wrong windows — a two-second answer is wrong for the whole of every
// gesture, and a gesture is exactly when something asks. The push channel
// is the answer; this stays because the phone can be running a compositor
// that predates it, and a shell that hard-depends on an op the running
// compositor does not serve is a shell that breaks on the deploy ordering
// TASK-28 is made of.
Timer {
interval: 2000
running: !root.subscribed
repeat: true
triggeredOnStart: true
onTriggered: root.refreshWindows()
}
// Take one reply's canvas facts, whether it arrived as an answer or as a
// push. Both carry the same shape by construction — the compositor
// serialises the `workspaces` payload once and uses it for both — so there
// is one reader here rather than two that can drift.
function _ingest(reply) {
if (reply.windows !== undefined) {
// Compare before publishing. See `_lastCanvas`.
const encoded = JSON.stringify(reply.windows);
if (encoded !== root._lastCanvas) {
root._lastCanvas = encoded;
root.windows = reply.windows;
root.windowsChanged_();
}
}
// `active` is an index and 0 is a real, meaningful value — it is home —
// so this must test for presence, not truthiness. `if (reply.active)`
// would silently ignore every report that we are on home, which is the
// one zone anything here cares about.
if (reply.active !== undefined)
root.activeZone = reply.active;
if (reply.count !== undefined)
root.zoneCount = reply.count;
// A `state` reply carries the ramp. `temperature` is null when the
// channels are balanced, which is not the same as 0 K — flatten it to 0
// here so a consumer can test one number.
if (reply.gamma !== undefined) {
root.gammaValue = reply.gamma.value;
root.gammaTemperature = reply.gamma.temperature || 0;
}
if (reply.outputs !== undefined)
root.outputs = reply.outputs;
if (reply.active_output !== undefined && reply.active_output !== null)
root.activeOutputName = reply.active_output;
}
// The push channel: a second, long-lived connection that carries canvas
// changes as they happen.
//
// Separate from the request socket on purpose. viewtop answers one request
// per connection and then closes; a subscription is the opposite shape — it
// is written to, never read from, and outlives every request. Multiplexing
// both onto one socket would mean interleaving a push into the middle of
// somebody's reply, which is how a request/response client learns to
// distrust its own parser.
Socket {
id: feed
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/viewtop.sock"
connected: true
onConnectionStateChanged: {
if (connected) {
feed.write(JSON.stringify({ op: "subscribe" }) + "\n");
} else {
// Either the compositor went away or it never served the op.
// Both mean the poll is the truth again until we get back in.
root.subscribed = false;
if (!root._pushRefused)
resubscribe.restart();
}
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
let reply;
try {
reply = JSON.parse(message);
} catch (e) {
console.error("[viewtop-control] unparseable push: " + message);
return;
}
if (reply.ok === false) {
// A compositor that does not serve `subscribe` says so with
// a code, which is the whole point of the codes. Stop
// asking, say it once, and let the poll carry it — this is
// the ordinary state of a phone between a shell update and
// the compositor package that follows it.
console.log("[viewtop-control] no push channel ("
+ (reply.code || "refused")
+ "); falling back to the 2 s poll");
root.subscribed = false;
root._pushRefused = true;
resubscribe.stop();
feed.connected = false;
return;
}
root.subscribed = true;
root._ingest(reply);
}
}
}
// Reconnect the feed after the compositor restarts. A session restart is
// the ordinary case: the shell outlives individual compositor runs, and a
// subscription that never came back would leave every surface reading a
// canvas frozen at the moment of the crash.
Timer {
id: resubscribe
interval: 5000
repeat: false
onTriggered: if (!root.subscribed && !root._pushRefused) feed.connected = true
}
// Requests waiting on a connection. A queue rather than SessiondPolicy's
// single slot: the sheet can fire two verbs in a row (kill after a close
// that was refused), and dropping the second would be silent.
property var _queue: []
property var _inflight: null
// --- Verbs that must arrive ---------------------------------------------
//
// A transport error is not the compositor refusing. It is the message not
// being delivered, and for a handful of verbs those two have completely
// different consequences: a lost `overview_commit` leaves the compositor
// still carrying windows the shell believes it released, so the app stays
// shrunk and **nothing in the system will ever put it back**. Casey,
// 2026-08-16: *"once I put a window in multitasking sometimes it glitches
// and then I have to drag it back down manually… once I reopen it"* —
// reopening starts a fresh carry, and it is that carry's commit that
// finally releases the stranded one.
//
// Measured the same morning on blueline, one boot, ~90 minutes of ordinary
// use: **52** transport failures, each of which executed `_queue = []`, and
// 113 `PeerClosedError`s behind them. The odds of one of those landing on a
// commit are not small, which is exactly the "sometimes".
//
// All three are idempotent — committing a released carry is `Idle`, and
// going to the zone you are already on is a no-op — so redelivering costs
// nothing and losing one costs the gesture. Everything else still drops:
// `overview_progress` is a level and the next one supersedes it.
//
// One retry, not a loop. A compositor that is genuinely gone must not turn
// this into a spin.
readonly property var _mustArrive: ["overview_commit", "overview_cancel", "workspace"]
function _mustBeDelivered(msg) {
if (!msg)
return false;
const name = msg.intent || msg.op;
return root._mustArrive.indexOf(name) !== -1 && (msg._tries || 0) < 1;
}
// Whether the in-flight request was answered before the socket closed.
// The compositor serves exactly one request per connection and then hangs
// up, so a disconnect is the *normal* end of every exchange — and telling
// that apart from a compositor that died mid-request is the difference
// between a silent success and a spurious "socket unavailable".
property bool _replied: false
function _send(msg) {
root._queue.push(msg);
root._pump();
}
// One request per connection, because that is what the other end serves.
//
// This used to write the whole queue down a single socket, which worked for
// exactly one request: `handle()` in `control.rs` reads one line, answers,
// and drops the stream. The second verb of any pair — `kill` after a
// refused `close`, the one case the queue exists for — was written into a
// socket that had already been closed, and surfaced as a refusal of a verb
// that was never delivered. So the connection is re-established per
// request, and a close with nothing in flight is silence rather than an
// error.
function _pump() {
if (root._inflight !== null || root._queue.length === 0)
return;
if (!sock.connected) {
sock.connected = true;
return;
}
root._inflight = root._queue.shift();
root._replied = false;
sock.write(JSON.stringify(root._inflight) + "\n");
}
// Address the scene. `intent` is one of the compositor's own — the table it
// returns from `{"op":"describe"}` is authoritative, and this deliberately
// does not keep a second copy of it to validate against.
function scene(intent, args) {
const msg = { op: "scene", intent: intent };
for (const k in args)
msg[k] = args[k];
root._send(msg);
}
// Ask the client to close. It may refuse or prompt; that is the protocol,
// not a bug, and `refused` carries it.
function close(id) {
root.scene("close", { id: id });
}
// End it regardless. The floor under close(), for a client that is hung or
// says no — "two apps and no way to turn them off" is what this answers.
// Unsaved work is lost, so a surface offering this should mean it.
function kill(id) {
root.scene("kill", { id: id });
}
// Position and size a window. The shell owns layout; the compositor owns
// whether a placement is legal and will refuse one that is not.
function place(id, x, y, width, height) {
root.scene("place", {
id: id,
at: { x: x, y: y },
size: { width: width, height: height }
});
}
// Give a window back to the layout.
function unplace(id) {
root.scene("unplace", { id: id });
}
// Let a window out of its zone.
//
// TASK-60 Q6. A zone tiles what is on it, which is right for the thing you
// are doing and wrong for the thing you are keeping — a video that should
// survive going somewhere else, a call, anything picture-in-picture. Those
// want to leave the zone's confinement rather than take a half of it.
//
// Distinct from `place`: a placed window is still the zone's, put somewhere
// specific in it. A floated one has stopped being the zone's business, so
// it is not counted when the strip decides whether a zone still has
// anything on it. That is also why it must be visible on a card — a float
// is a window you can no longer find by remembering which zone you left it
// on.
function float(id) {
root.scene("float", { id: id });
}
function unfloat(id) {
root.scene("unfloat", { id: id });
}
// Compose a window's visual geometry — the verb that makes a live app a
// scaled, floating, still-touchable thing rather than a picture of one.
// The compositor maps input back through the inverse, so a shrunken window
// still receives touch where it is drawn.
function pose(id, scale, rotation, anchorX, anchorY) {
root.scene("pose", {
id: id,
scale: scale,
rotation: rotation ?? 0.0,
anchor: { x: anchorX ?? 0.5, y: anchorY ?? 0.5 }
});
}
// Hand a window to the finger. While grabbed, one-finger drags move it;
// `drop` ends the mode. This is Move as a *mode* rather than a computed
// geometry — the three-finger carry that used to do it lost a race with
// the three-finger tap every time, so it is entered on purpose now.
function grab(id) {
root.scene("grab", { id: id });
}
function drop() {
root.scene("drop", {});
}
// The overview carry: the compositor takes the real windows and puts them
// on their cards.
//
// These replace `poseActiveZone` / `clearPose` / `_posed`, which were the
// shell's half of TASK-60's two-writer bug — the rail scaled the windows
// here while `ZoneOverview` scaled its cards independently, and a transform
// the shell applied is a transform some path out of the gesture has to
// remember to undo. There was always a path that forgot, and tapping a card
// was it.
//
// What makes this different is not that it is tidier: the transform is
// released *by the compositor*, on `commit` or `cancel`, and there is no
// third way for a carry to end. A window cannot outlive the gesture that
// carried it.
//
// `ZoneTransition` is the one caller. It owns the rects, the in-flight
// flag, and the end-target table; this is only the door.
function overviewBegin(targets) {
root._send({ op: "scene", intent: "overview_begin", to: targets });
}
function overviewProgress(shift) {
root._send({ op: "scene", intent: "overview_progress", progress: shift });
}
// `target` is the wire's `EndTarget`: "home", "overview", "last_zone", or
// {zone: {zone: N}}.
function overviewCommit(target) {
root._send({ op: "scene", intent: "overview_commit", target: target });
}
function overviewCancel() {
root._send({ op: "scene", intent: "overview_cancel" });
}
function raise(id) {
root.scene("raise", { id: id });
}
// Go to a zone. Not a `scene` intent — zones are the canvas, not a surface
// on it, so the compositor serves this as its own op.
//
// "Zone", not "workspace", in everything we name: viewtop's canvas is a
// large scalable space of states rather than Hyprland's numbered desks, and
// the vocabulary is being moved off Hyprland's deliberately. The wire op is
// still spelled `workspace` — renaming that is a separate sweep, and doing
// it halfway would leave the shell calling an op the compositor does not
// serve.
function zone(to) {
root._send({ op: "workspace", to: to });
}
// Move a window to a zone without going there. Same op, its other half —
// `workspace` takes an optional surface and moves it before it looks.
function moveToZone(id, to) {
root._send({ op: "workspace", to: to, surface: id });
}
function focus(id) {
root.scene("focus", { id: id });
}
// Set the panel's colour ramp. `value` is 0..=100 and scales the curve;
// 100 with no temperature is identity. `temperature` is Kelvin.
//
// Gamma is a pixel claim on the glass, so the compositor owns it — not
// sessiond, which owns device *states*. The slider used to reach `hyprctl
// hyprsunset`, which stopped existing with Hyprland and took both the
// dimming and the night-light with it, silently, since the viewtop move.
//
// One writer, one ramp: brightness scaling and the evening warmth are two
// curve generators composed into a single LUT on the far side rather than
// two callers racing for the same hardware slot. That is why this takes
// both arguments at once instead of offering a `temperature()` of its own.
//
// Refused with `unavailable` when the panel is off — there is nothing to
// ramp — so a caller must not read a refusal here as the verb missing.
function gamma(value, temperature) {
const msg = { value: Math.round(Math.max(0, Math.min(100, value))) };
if (temperature)
msg.temperature = Math.round(temperature);
root.scene("gamma", msg);
}
Socket {
id: sock
path: Quickshell.env("XDG_RUNTIME_DIR") + "/souveraine/viewtop.sock"
onConnectionStateChanged: {
if (connected) {
root._pump();
return;
}
// Answered, then hung up: the exchange completed. Carry on with
// whatever is behind it.
if (root._inflight === null && root._replied) {
root._pump();
return;
}
// Nothing was in flight and nothing is waiting — an idle socket
// closing is not news.
if (root._inflight === null && root._queue.length === 0)
return;
const failed = root._inflight;
const intent = (failed && failed.intent) || (failed && failed.op) || "?";
root.lastError = "viewtop socket unavailable";
// Keep what must arrive, drop what was only a level. See
// `_mustArrive` — this used to be `_queue = []` unconditionally,
// which is how a commit went missing and a window stranded.
const keep = [];
for (const msg of [failed].concat(root._queue)) {
if (!root._mustBeDelivered(msg))
continue;
msg._tries = (msg._tries || 0) + 1;
keep.push(msg);
}
const dropped = root._queue.length + (failed ? 1 : 0) - keep.length;
// Name the verb and the counts. The old wording claimed the
// compositor could not be reached, which was the one thing it never
// established — on 2026-08-13 this fired once per shell start while
// viewtop answered a hand-written request on that same socket in
// the same second. A transport error is not a diagnosis, and one
// that does not say what it ate cannot be traced to the symptom it
// caused three days later.
console.error("[viewtop-control] " + intent + " was not delivered:"
+ " the socket at " + sock.path + " closed with the request in"
+ " flight (" + keep.length + " redelivered, "
+ dropped + " dropped)");
root._inflight = null;
root._queue = keep;
if (keep.length === 0)
root.refused(intent, root.lastError);
else
root._pump();
}
parser: SplitParser {
splitMarker: "\n"
onRead: message => {
const sent = root._inflight;
const intent = (sent && sent.intent) || (sent && sent.op) || "?";
root._inflight = null;
root._replied = true;
let reply;
try {
reply = JSON.parse(message);
} catch (e) {
console.error("[viewtop-control] unparseable reply: " + message);
root.lastError = "unparseable reply";
root.refused(intent, root.lastError);
if (sock.connected)
sock.connected = false;
else
root._pump();
return;
}
if (reply.ok !== true) {
// `gone` is the ordinary one: the window closed between the
// sheet opening and the button being pressed. Still a
// refusal, still surfaced, because a sheet acting on a dead
// id should say so rather than appear to work.
const why = reply.reason || reply.code || "refused without a reason";
console.log("[viewtop-control] " + intent + " refused: " + why);
root.lastError = why;
root.refused(intent, why);
} else {
root.lastError = "";
// A `workspaces` reply carries the canvas rather than a
// verb's outcome. Captured here so a chooser has something
// real to list instead of a guess at what is open.
root._ingest(reply);
root.succeeded(intent);
}
// Hang up rather than wait to discover we have been hung up on.
//
// `handle()` in control.rs answers one request and returns, so
// the connection is spent the moment the reply is parsed. But
// `connected` is this end's belief and it lags the peer's
// close: measured 2026-08-13, the poll's second tick wrote to
// the socket a full two seconds after the reply and the write
// still went out, dying as PeerClosedError. That surfaced as
// "could not reach the compositor" for a compositor that was
// answering by hand at that same moment, and it cost the queued
// verb. Closing here makes the next `_pump` start from a state
// we set rather than one we inferred.
if (sock.connected)
sock.connected = false; // the disconnect handler re-pumps
else
root._pump();
}
}
}
}

View file

@ -0,0 +1,32 @@
// Aspect-aware wallpaper selection for unlike display shapes. This retains the
// original as canonical and selects only explicit local derivatives.
pragma Singleton
import QtQuick
import Quickshell
import qs.modules.common
Singleton {
id: root
function aspectFor(screen) {
if (!screen || !screen.height) return 1;
return screen.width / screen.height;
}
function pathFor(screen) {
const aspect = root.aspectFor(screen);
const portrait = Config.options.background.portraitVariantPath;
const landscape = Config.options.background.landscapeVariantPath;
if (aspect < 0.9 && portrait) return portrait;
if (aspect > 1.1 && landscape) return landscape;
return Config.options.background.wallpaperPath;
}
function focalPoint() {
return {
x: Math.max(0, Math.min(1, Config.options.background.wallpaperFocalX)),
y: Math.max(0, Math.min(1, Config.options.background.wallpaperFocalY))
};
}
}

View file

@ -0,0 +1,110 @@
pragma Singleton
// Souveraine wallpaper download service. Owns fetching a random wallhaven
// image and handing it to the shell's wallpaper apply path. Repo-owned end to
// end: the script lives in this surface (scripts/wallpaper/), and applying
// calls the shell-owned Wallpapers singleton directly.
//
// Deliberately does NOT call ii's switchwall.sh directly: when the ii base is
// vendored away, only the `wallpapers` IPC target has to move with it; this
// service and its script are already ours.
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import Quickshell
import Quickshell.Io
Singleton {
id: root
// busy while a download is in flight — bind a button's enabled/spinner to it.
property bool downloading: false
// last error string for the UI (empty = ok).
property string lastError: ""
// Purity flags — which wallhaven categories the random pick may include.
// Sourced from ~/.config/illogical-impulse/config.json (background.wallhaven
// .purity, a CSV of sfw/sketchy/nsfw), the same place the download script
// reads. Not in the Config.options schema upstream ships, so we read/write
// the JSON directly rather than binding a schema key that doesn't exist.
property bool puritySfw: true
property bool puritySketchy: false
property bool purityNsfw: false
readonly property string configPath:
FileUtils.trimFileProtocol(Quickshell.env("HOME") + "/.config/illogical-impulse/config.json")
// The CSV the script's [purity] arg expects, or "sfw" if nothing is on
// (never fetch an empty purity — that would 400 the API).
readonly property string purityCsv: {
const parts = [];
if (puritySfw) parts.push("sfw");
if (puritySketchy) parts.push("sketchy");
if (purityNsfw) parts.push("nsfw");
return parts.length > 0 ? parts.join(",") : "sfw";
}
signal downloaded(string path)
signal failed(string message)
readonly property string scriptPath:
FileUtils.trimFileProtocol(Quickshell.shellPath("scripts/wallpaper/download_wallhaven.sh"))
Component.onCompleted: readPurity.running = true
// Load current purity from config.json into the three flags.
Process {
id: readPurity
command: ["jq", "-r", ".background.wallhaven.purity // \"sfw,sketchy\"", root.configPath]
stdout: StdioCollector {
onStreamFinished: {
const csv = text.trim();
root.puritySfw = csv.indexOf("sfw") >= 0;
root.puritySketchy = csv.indexOf("sketchy") >= 0;
root.purityNsfw = csv.indexOf("nsfw") >= 0;
}
}
}
// Persist the current flags back to config.json (jq in-place via temp).
function savePurity() {
savePurityProc.exec(["bash", "-c",
"f=" + Quickshell.env("HOME") + "/.config/illogical-impulse/config.json; " +
"tmp=$(mktemp); jq --arg p " + JSON.stringify(root.purityCsv) +
" '.background.wallhaven.purity = $p' \"$f\" > \"$tmp\" && mv \"$tmp\" \"$f\""]);
}
Process { id: savePurityProc }
// Fetch one random wallpaper honoring the current purity flags.
function download() {
if (root.downloading) return;
root.downloading = true;
root.lastError = "";
proc.stdoutText = "";
proc.stderrText = "";
proc.exec(["bash", root.scriptPath, root.purityCsv]);
}
Process {
id: proc
property string stdoutText: ""
property string stderrText: ""
stdout: StdioCollector { onStreamFinished: proc.stdoutText = text }
stderr: StdioCollector { onStreamFinished: proc.stderrText = text }
onExited: (exitCode, exitStatus) => {
root.downloading = false;
const path = proc.stdoutText.trim();
if (exitCode === 0 && path.length > 0) {
// Same in-process path as the picker: no nested qs instance.
Wallpapers.apply(path);
Config.options.background.wallpaperPath = path;
root.downloaded(path);
} else {
const msg = proc.stderrText.trim() || Translation.tr("Download failed");
root.lastError = msg;
root.failed(msg);
}
}
}
}

View file

@ -0,0 +1,619 @@
// The multitasking transition, and the one thing that owns it.
//
// TASK-60. The fault this replaces was **two writers over one geometry**: the
// rail scaled the real windows through `pose` while `ZoneOverview` scaled its
// cards through its own `scale`, both reading `zonePullProgress` and running
// opposite curves. At the handoff the window was at 0.6 of the glass and the
// card arrived near full bleed. Every symptom — the size pop, the doubled
// frame, and the strand — was that one fault.
//
// So there is one owner and it is this file. It holds:
//
// 1. **The card rect**, computed once here and read by both ends. `ZoneOverview`
// lays its card out at `cardRect`; the carry names `cardRect` as the
// destination. They cannot disagree, because there is nothing to keep in
// agreement — it is the same number.
// 2. **The four verbs**, so the compositor carries the *real* window onto that
// rect. Nothing in the shell transforms a window any more.
// 3. **The end-target table**, so arriving somewhere is one function with one
// branch per destination rather than a set of booleans each caller sets in
// its own order.
//
// ## Releasing is arriving
//
// quickstep's `GestureState` resolves every swipe to exactly one of
// `HOME / RECENTS / NEW_TASK / LAST_TASK / ALL_APPS`, and that is structurally
// why Android cannot strand a window and we could. The old shape was a
// transform one surface applied and a *different* surface had to remember to
// undo — so tapping a card, which never touches the rail, left a window shrunk
// with no way back (Casey's strand repro: btop, multitasking, Home,
// multitasking, tap btop, and btop is a small card in the middle of the
// screen).
//
// Here the transform is released by `commit`/`cancel` inside the compositor,
// and *every* exit is one of those two. `LastZone` is a destination with a
// name, not an abandon branch, for the same reason.
//
// ## Why the geometry is a scaled zone and not a rectangle that looks nice
//
// `Viewtop::carried` takes its scale from the width alone
// (`leg.size.width / size.width`) and applies it uniformly. A destination rect
// of some other aspect would therefore letterbox the real window inside the
// card it is supposed to *be*. So a card is the whole zone drawn small — one
// scale factor, origin included — and a window's target is its own position
// and size run through that same factor. The window lands exactly where the
// card's picture of it is, by construction rather than by tuning.
//
// ## It is a verb, because she gives the tour
//
// Casey, 2026-08-07: an agent asked for a tour of the phone means *"even these
// little gesture steps will be possible."* `shift` and the end target are both
// reachable from the compositor's verb table, and `Overview.qml`'s `swipe` IPC
// drives this whole file without a finger. An agent-driven run stays
// `Origin::Agent` throughout — the compositor never sees a contact — so it
// raises no evidence and buys no idle budget, which is the rule TASK-60 sets
// for the tour and `input.rs` already enforces for her hand.
pragma Singleton
import QtQuick
import Quickshell
import qs
import qs.services
Singleton {
id: root
// --- The geometry, defined once ------------------------------------------
//
// Logical pixels, in the panel's coordinates — which are the output's,
// because the overview's `PanelWindow` is anchored to all four edges. The
// compositor's `at`/`size` are in the same space, so a rect computed here
// is directly a `TransitionTarget` with no conversion to get wrong.
//
// `Quickshell.screens[0]`, not a window's `screen`: this is a singleton and
// has no window, and the phone has one output. The fallbacks are the
// Pixel's logical size so a first frame before the screen list populates
// lays out at the right scale rather than at zero.
readonly property var screen: Quickshell.screens.length > 0 ? Quickshell.screens[0] : null
readonly property real panelWidth: root.screen ? root.screen.width : 540
readonly property real panelHeight: root.screen ? root.screen.height : 1080
// The overview surface's *measured* geometry, reported by the surface
// itself. Assuming it equalled the screen was wrong and wrong in the way
// that hides: the overview `PanelWindow` respects exclusive zones, so the
// status bar's 40 px makes it 1040 tall on a 1080 screen and puts its origin
// 40 px down the output. Computing the card against the raw screen made it
// about 4% too large and 40 px low — close enough to look right in a still
// frame and wrong in exactly the way that reads as a bad hand-off in motion.
//
// Measured 2026-08-07: `loaderPos.h = 811.2`, which is `1040 * 0.78`, not
// `1080 * 0.78`. The surface knows; nothing here should guess.
//
// `surfaceTop` is the output-space y of the surface's origin. Derived from
// what the surface lost to exclusive zones, which is top-anchored here (the
// bar reserves, the pill reserves nothing, the dock is on Overlay). It
// should equal the `at.y` the compositor reports for any tiled window — a
// free cross-check if this is ever suspected.
property real surfaceWidth: root.panelWidth
property real surfaceHeight: root.panelHeight
property real surfaceTop: 0
property real surfaceLeft: 0
// Whether any of the four above came from the surface rather than from the
// defaults. `dragLength` is the one consumer that must know the difference.
property bool surfaceReported: false
// Report the overview surface's real geometry. Called by the surface; the
// only writer of these four.
function measuredAt(left, top, width, height) {
root.surfaceLeft = left;
root.surfaceTop = top;
root.surfaceWidth = Math.max(1, width);
root.surfaceHeight = Math.max(1, height);
root.surfaceReported = true;
}
// `ZoneOverview`'s own frame, restated here because the destination and the
// layout have to be the same arithmetic. Changing one of these moves the
// card and the window it carries together; that is the point.
//
// `listMargin` is the `ListView`'s inset, `labelHeight` the strip under the
// card that names the zone. The loader's height is reported rather than
// derived from a share, for the reason above.
readonly property real listMargin: 12
readonly property real labelHeight: 34
// The band under the strip: verbs that act on the card in front, and the
// line she speaks on. Reserved here rather than overlaid, so the cards
// shrink to make room and `dragLength` re-derives itself — the tray moves
// the destination, and the gesture has to know that.
readonly property real trayHeight: 132
// The box a card is laid out inside, in surface-local coordinates.
readonly property real _availX: root.listMargin
readonly property real _availY: root.listMargin
readonly property real _availW: root.surfaceWidth - 2 * root.listMargin
readonly property real _availH: root.surfaceHeight
- 2 * root.listMargin - root.labelHeight - root.trayHeight
// --- What a card is a picture *of* ---------------------------------------
//
// The zone's usable area, not the whole output. A card drawn as the full
// panel carries the bar's reserved strip inside it as dead space, and a
// tiled window — whose `at.y` starts below that reservation — therefore
// sits with a band of empty card above it. Casey, 2026-08-16: *"each window
// currently has a gap on the top for where the top bar section would go."*
// That gap is `surfaceTop * cardScale` and it was drawn faithfully; the
// card was just a picture of the wrong rectangle.
//
// `surfaceTop` is what this surface lost to exclusive zones, and the header
// above already states the invariant that makes it the right number: it
// equals the `at.y` the compositor reports for any tiled window. Assumes
// reservations stay top-anchored, which is true today (the bar reserves,
// the pill reserves nothing, the dock is on Overlay). A bottom reservation
// would need its own term, and the cross-check that would catch it is the
// same one — a card whose windows no longer reach its bottom edge.
readonly property real contentTop: root.surfaceTop
readonly property real contentWidth: root.panelWidth
readonly property real contentHeight: Math.max(1, root.panelHeight - root.contentTop)
// How much of the space a card is allowed to fill.
//
// Fit-to-box made a card that nearly touched its neighbours, so the strip
// read as one thing at a time with slivers either side. Casey, 2026-08-16:
// *"scaled a little bit more / at least tighter together… we should be able
// to smoothly scroll between them, floating cards and such."* Under one is
// what makes them cards floating in a strip rather than a stack of screens.
readonly property real cardFill: 0.84
// Between neighbours. The ListView's own `spacing` is 0 so this is the one
// number that says how far apart cards sit.
readonly property real cardGutter: 16
// One factor. `min` so the card keeps the *content's* aspect — which is what
// a window's reported rect is measured against — and the carried window
// fills it exactly. See the header on why a uniform scale is not a choice
// here but a consequence of how `carried` works.
readonly property real cardScale: Math.max(0.05, root.cardFill * Math.min(
root._availW / Math.max(1, root.contentWidth),
root._availH / Math.max(1, root.contentHeight)))
readonly property real cardWidth: root.contentWidth * root.cardScale
readonly property real cardHeight: root.contentHeight * root.cardScale
// --- Where the resting card sits -----------------------------------------
//
// The delegate is the card plus its gutter, and the `ListView` holds the
// current one centred (`StrictlyEnforceRange`, highlight range below), so a
// neighbour shows on each side and the strip scrolls between them. It used
// to be a full-width delegate pinned at x = 0 with the card centred inside
// it, which is why the cards sat a screen apart.
//
// All four numbers live here and nowhere else. `ZoneOverview` lays the card
// out at `cardInset*` and the carry names `cardX`/`cardY`; they are the same
// rectangle in two coordinate spaces, not two rectangles kept in agreement.
readonly property real delegateWidth: root.cardWidth + root.cardGutter
readonly property real highlightBegin: Math.max(0,
(root._availW - root.delegateWidth) / 2)
// Delegate-local: what `ZoneOverview` positions the frame at.
readonly property real cardInsetX: (root.delegateWidth - root.cardWidth) / 2
readonly property real cardInsetY: (root._availH - root.cardHeight) / 2
// Surface-local: what the carry's destination is measured in.
readonly property real cardX: root._availX + root.highlightBegin + root.cardInsetX
readonly property real cardY: root._availY + root.cardInsetY
// How far the thumb has to climb for the window to *become* its card.
//
// The destination and the distance to it are one question, and quickstep
// answers them in one call — `getSwipeUpDestinationAndLength(dp, ctx,
// TEMP_RECT, …)` returns the task rect and the drag length together, and
// `LauncherActivityInterface` computes that length as `dp.heightPx -
// outRect.bottom`: how far the window's bottom edge travels.
//
// The rail asked `screen.height * 0.18` instead — TASK-60 named it *"a
// number with no relationship to where the card actually is"* and left it.
// Measured on blueline 2026-08-16: the card's bottom sits at 805 logical px
// of 1080, so the true travel is 275 px and the old detent fired at 194.
// The gesture committed with the window 40% short of its card and the
// hand-off covered the rest in one frame — Casey's *"the swipe to this swap
// is awkward"*, reported six times and tuned at from every direction except
// this one.
//
// Until the surface has reported, this is the old 18% — not a floor, and
// not a guess dressed up as arithmetic. `ZoneOverview` lives inside a gated
// Loader, so nothing has measured anything until the overview has been
// realised once, and computing the card against the *defaults* yields a
// card the size of the panel and a drag length near zero. Behaving exactly
// as yesterday until the real number exists is the honest fallback; the
// rail latches whichever it got at the press, so no gesture ever changes
// detent halfway through.
readonly property real dragLength: root.surfaceReported
? Math.max(120, root.panelHeight
- (root.surfaceTop + root.cardY + root.cardHeight))
: root.panelHeight * 0.18
// Where one window goes: its own place in the zone, drawn small.
//
// `cardX`/`cardY` are surface-local because that is what `ZoneOverview` lays
// out in; the compositor speaks output coordinates, so the surface's origin
// is added here. One conversion, at the one boundary where the two spaces
// meet — the alternative is every caller remembering an offset, which is
// the shape of bug this file exists to stop.
// `contentTop` comes off the window's own `at.y` before scaling, because the
// card is a picture of the usable zone and not of the whole output — the
// same subtraction `ZoneOverview` makes when it lays the pane out. Miss it
// in one place and the carried window lands a bar's height off its picture.
function targetFor(w) {
return {
id: w.id,
at: {
x: root.surfaceLeft + root.cardX + w.at.x * root.cardScale,
y: root.surfaceTop + root.cardY
+ (w.at.y - root.contentTop) * root.cardScale
},
size: {
width: w.size.width * root.cardScale,
height: w.size.height * root.cardScale
}
};
}
// --- One clock, two strategies -------------------------------------------
//
// The gesture reports one number: how far the thumb has climbed. Everything
// visible is a curve over it, and every curve lives here.
//
// This is TASK-52's rule — *one attention model, one clock, effects as
// strategies over it* — and it is the same rule as TASK-60's "one owner",
// one level up. The carry's `shift` and the destination's `presence` are
// genuinely different curves: the window keeps travelling as the climb
// continues past the multitasking detent toward home, while the destination
// has to *recede*, because a preview that stayed would be showing somewhere
// the release is no longer going to take you. Two curves is correct. Two
// *places* is the bug, and it is exactly how the original was built — a
// scale on the rail and a scale on the card, tuned by hand to agree.
//
// `pose` is a gravity well borrowed from the atmosphere primitives, so this
// is her felt environment and not merely navigation chrome. It answers to
// the same discipline.
// How far the thumb has climbed, and where the multitasking detent sits.
// The rail owns the gesture's geometry and reports both; nothing here
// measures a finger.
property real travel: 0
property real detent: 1
// The clock. 0 at rest, 1 at the multitasking detent, and it keeps counting
// past it — the climb toward home is more of the same motion, not a second
// gesture.
readonly property real clock: root.travel / Math.max(1, root.detent)
// Strategy one: what the compositor carries the window on.
//
// Past the detent this used to be a hard `Math.min(1, …)` — the thumb kept
// travelling toward home and the window stopped dead. Android never goes
// dead there, it goes *heavy*: `AnimatorControllerWithResistance` is
// literally two playback controllers, one running 0→1 and a second that
// *"seamlessly continues that animation but starts applying resistance"*,
// with `DECELERATE` on scale (`RECENTS_SCALE_RESIST_INTERPOLATOR`) and
// `FROM_APP(0.75f, 0.5f, 1f, false)` bounding how far it can go.
//
// Same shape. Past 1 the carry keeps extrapolating beyond the card rect —
// the window shrinks further as the pull heads for home — but on a curve
// that gives back less and less, so the last of the travel is felt as
// weight rather than as a wall.
//
// A compositor older than the overshoot clamps this to 1 and the gesture is
// exactly what it is today, which is what makes it safe to ship ahead of
// the package (TASK-28).
readonly property real maxOvershoot: 1.22
readonly property real shift: root.clock <= 1
? Math.max(0, root.clock)
: 1 + (root.maxOvershoot - 1) * root._decelerate(Math.min(1, root.clock - 1))
// Android's DECELERATE, which is `1 - (1-t)²`.
function _decelerate(t) {
const inv = 1 - t;
return 1 - inv * inv;
}
// Strategy two: how present the destination is. Rises to the detent, then
// falls away over the same distance, so the two halves of the climb are
// symmetric and a home-bound pull never arrives at a multitasking view the
// release would not commit.
readonly property real presence: root.clock <= 1
? Math.max(0, root.clock)
: Math.max(0, 1 - (root.clock - 1))
// Advance the gesture. One call per motion event.
function pullTo(travel, detent) {
root.detent = Math.max(1, detent);
root.travel = Math.max(0, travel);
}
// Back to rest. The chrome settles through its own Behavior; the carry is
// released by whatever destination the gesture arrived at.
function rest() {
root.travel = 0;
}
// The compositor is written from the derived value, not from the callers.
//
// A level, not an edge: `travel` moves under a thumb *and* under the settle
// animation below, and both must reach the glass. Pushing from each caller
// instead would mean the settle silently did nothing — which is precisely
// the class of bug where a transform is applied by one path and undone by
// another that forgot.
onShiftChanged: root.progress(root.shift)
// --- The settle ----------------------------------------------------------
//
// **Releasing continues the motion.** This is the piece that was missing,
// and it is why the swipe into multitasking read as awkward no matter how
// the fades were tuned: a lift at 60% of the climb committed *at* 60%, so
// the window jumped from wherever the thumb left it to its final state with
// no travel in between. Two motions with a cut between them.
//
// quickstep does not do that. `AbsSwipeUpHandler.handleNormalGestureEnd`:
//
// float endShift = endTarget.isLauncher ? 1 : 0;
// long expectedDuration = Math.abs(Math.round((endShift - currentShift)
// * MAX_SWIPE_DURATION * SWIPE_DURATION_MULTIPLIER));
// duration = Math.min(MAX_SWIPE_DURATION, expectedDuration);
// startShift = currentShift;
//
// The shift animates from where the thumb left it to where the destination
// is, over a duration proportional to the distance still to cover, and the
// gesture lands only when it gets there. Same numbers here: 350 ms cap, and
// the multiplier is `min(1/0.7, 1/0.3)` from `MIN_PROGRESS_FOR_OVERVIEW`.
//
// `last_zone` is the one target whose end shift is 0 — going back to the app
// means the window travels *down* to full size rather than the view coming
// up. It animates like every other destination instead of being the branch
// that snaps.
// What was still missing after the travel landed: a duration and an
// easing curve cannot know how fast the thumb was going when it let go, so
// a flick and a drift settled identically. Android's home path is
// `RectFSpringAnim` with `DefaultSpringConfig` (`SwipeUpAnimationLogic:364`)
// — a spring takes the release velocity as an *initial condition*, which is
// the whole difference between a window that travels and one that was
// thrown.
//
// So: a critically damped spring, integrated per frame, seeded with the
// velocity the rail measures over the last 100 ms of the gesture.
//
// Per *frame*. `FrameAnimation` ticks on the render clock, so the carry
// advances once per painted frame instead of once per whatever the socket
// managed — which is what the old `NumberAnimation` on `travel` amounted
// to, since every step of it went out through `progress()`.
//
// Critically damped, never underdamped: a navigation surface that rings
// reads as a toy. It still overshoots once on a hard throw, and that part
// is the point.
// ω in 1/ms. Critical damping settles in about 6.6/ω, so 0.026 is ~250 ms.
readonly property real settleOmega: 0.026
// Nothing may outlive this. The commit is what releases the carry, so a
// settle that never converged has to arrive anyway or the window strands —
// which is the one failure TASK-60 exists to make impossible.
readonly property int maxSettleMs: 600
// px/ms. A thrown release helps; a wild one does not get to launch the
// window off the glass.
readonly property real maxSeedVelocity: 8
property bool settling: false
property var _settleTarget: null
FrameAnimation {
id: settleTick
running: false
property real velocity: 0
property real to: 0
property real elapsed: 0
onTriggered: {
// Clamped: a dropped frame must not integrate a large step and
// fling the window across the panel.
const dt = Math.min(48, settleTick.frameTime * 1000);
settleTick.elapsed += dt;
const w = root.settleOmega;
const offset = root.travel - settleTick.to;
settleTick.velocity += (-2 * w * settleTick.velocity - w * w * offset) * dt;
root.travel = Math.max(0, root.travel + settleTick.velocity * dt);
if ((Math.abs(root.travel - settleTick.to) < 0.5
&& Math.abs(settleTick.velocity) < 0.02)
|| settleTick.elapsed >= root.maxSettleMs) {
settleTick.running = false;
root.travel = settleTick.to;
root._finishSettle();
}
}
}
function _finishSettle() {
root.settling = false;
const t = root._settleTarget;
root._settleTarget = null;
// Commit *before* zeroing, and the order is load-bearing. Zeroing
// first would drive `shift` to 0 with the carry still in flight —
// the window snapping back to full size for a frame, which is the
// very pop this whole task exists to remove. After `commit` the
// carry is released, so `progress` refuses and the zero is inert
// bookkeeping for the next gesture.
root.commit(t);
root.travel = 0;
}
// Release the gesture at a named destination, travelling there first.
// `velocity` is px/ms along the rail, upward positive, as the rail measured
// it over the tail of the gesture. Omitted means a release with no throw in
// it, which is a legitimate way to let go.
function settleTo(target, velocity) {
// Where on the *clock* each destination lives — not on `shift`, which
// saturates at the card and cannot express home at all.
//
// `presence` above is written to rise to the detent and then fall away
// "over the same distance", which puts home at clock 2. This settled it
// at 1 for both home and overview, so a long swipe ran the short
// swipe's motion, held the multitasking view fully present, and then
// teleported. Measured on the glass 2026-08-15: *"the long full swipe up
// is completely fucked, only the short swipe partially works."*
const endClock = target === "last_zone" ? 0 : target === "home" ? 2 : 1;
settleTick.running = false;
root._settleTarget = target;
root.settling = true;
settleTick.to = endClock * root.detent;
settleTick.elapsed = 0;
// Projected onto the way out, not applied raw. The gesture has already
// resolved to one end target, so a hard release means "get there", not
// "carry on past it and be pulled back" — which is what a raw upward
// seed would do to a `last_zone` return and would read as a bounce.
const toward = settleTick.to >= root.travel ? 1 : -1;
settleTick.velocity = toward * Math.min(root.maxSeedVelocity,
Math.max(0, velocity ?? 0));
settleTick.running = true;
}
// --- The carry -----------------------------------------------------------
// True between `begin` and the `commit`/`cancel` that releases it. Held so
// a progress update with nothing in flight is silence rather than a refusal
// per motion event — the compositor is right to refuse it, and a drag emits
// one of those per frame.
property bool inFlight: false
// Last shift actually written, and how little a change has to be before it
// is not worth sending.
//
// This was 0.01 — a hundred steps for the whole carry, inherited from
// `poseActiveZone` on the reasoning that a round-trip per motion event was
// expensive. **Measured on blueline 2026-08-16, it is not:** 200 samples of
// connect → write → event-loop hop → reply → close, `{"op":"workspaces"}`,
// came back at 0.47 ms median and 1.04 ms worst — under 3% of a 16.7 ms
// frame. A hundred steps across 275 px of travel is a step every 2 logical
// pixels, and on a scaling window that staircase is visible.
//
// 500 steps costs the same 0.5 ms per frame, because it is still one send
// per motion event — the quantiser was never what bounded the rate.
readonly property real shiftEpsilon: 0.002
property real _lastShift: -1
// Take the windows on the zone in front and name where each is going.
//
// The active zone only, which is quickstep's model too: `TaskViewSimulator`
// transforms the *live* window of the running task onto its card, and every
// other task in the strip is a snapshot. Carrying all of them would also be
// wrong here for a concrete reason — the rects are named once at `begin`,
// and the strip scrolls, so a window carried onto a card that then slides
// sideways would come adrift from it.
//
// An empty zone carries nothing and says so: home has no windows, and the
// compositor refuses an empty `begin` on purpose ("a carry with no windows
// would release nothing on commit"). The gesture still works — the overview
// is drawn, there is simply no window to bring with it.
function begin() {
const targets = [];
for (const w of ViewtopControl.windows) {
if (w.workspace === ViewtopControl.activeZone && w.at && w.size)
targets.push(root.targetFor(w));
}
if (targets.length === 0)
return false;
root.inFlight = true;
root._lastShift = -1;
ViewtopControl.overviewBegin(targets);
return true;
}
// 0 is where the windows live, 1 is each on its card, and past 1 is the
// resisted overshoot toward home. A compositor that predates the overshoot
// clamps it back to 1 on arrival, which is the old behaviour exactly.
function progress(shift) {
if (!root.inFlight)
return;
const s = Math.max(0, Math.min(root.maxOvershoot, shift));
if (Math.abs(s - root._lastShift) < root.shiftEpsilon)
return;
root._lastShift = s;
ViewtopControl.overviewProgress(s);
}
// Finish at a named destination: release the carry, then go there.
//
// Both halves, always, and in that order. The compositor releases the
// transforms and reports back where it was told to arrive, but it does not
// navigate — performing the arrival is the caller's, which is this. Doing
// it here rather than at each call site is what makes a card tap and a pill
// release the same machinery, which is precisely what the strand repro
// broke.
//
// Safe with nothing in flight: a card tapped from an already-open overview
// has no carry to release, and still has somewhere to go.
function commit(target) {
if (root.inFlight) {
root.inFlight = false;
root._lastShift = -1;
ViewtopControl.overviewCommit(target);
}
root.arrive(target);
}
// Give up. Named apart from `commit("last_zone")` so the caller says which
// one it meant, matching the compositor's own `cancel`.
function cancel() {
if (root.inFlight) {
root.inFlight = false;
root._lastShift = -1;
ViewtopControl.overviewCancel();
}
root.arrive("last_zone");
}
// The end-target table: one branch per destination, and every gesture
// resolves to exactly one of them.
//
// `target` is the wire's own shape — `"home"`, `"overview"`, `"last_zone"`,
// or `{zone: {zone: N}}` — so the thing sent to the compositor and the
// thing branched on here cannot drift apart into two vocabularies.
function arrive(target) {
if (target === "home") {
// Square one, with the last screen's furniture cleared before the
// strip moves rather than arrived at still wearing it.
GlobalStates.missionControlOpen = false;
GlobalStates.overviewOpen = false;
GlobalStates.dockRevealed = false;
GlobalStates.oskOpen = false;
// `homeZone`, not a config lookup and not the literal 1: home is
// zone 0 (`workspace::HOME_ZONE`), and pointing this at 1 put Home
// on the first *app* zone — invisible while only home existed,
// because the compositor clamps `to` against `count - 1`.
ViewtopControl.zone(ViewtopControl.homeZone);
return;
}
if (target === "overview") {
GlobalStates.missionControlOpen = true;
return;
}
if (target === "last_zone") {
// Already there. The windows went back where they live when the
// compositor released them, and that is the whole of it — this
// branch exists so that "the swipe was given up" is a destination
// with a name rather than the one path nobody wrote.
return;
}
if (target && target.zone !== undefined) {
ViewtopControl.zone(target.zone.zone);
GlobalStates.missionControlOpen = false;
GlobalStates.overviewOpen = false;
return;
}
console.log("[ZoneTransition] arrival with no destination:", JSON.stringify(target));
}
// A zone, in the wire's shape. Spelled once so no call site hand-builds the
// nesting and gets it subtly wrong.
function zoneTarget(zone) {
return { zone: { zone: zone } };
}
}

View file

@ -0,0 +1,78 @@
BooruResponseData 1.0 BooruResponseData.qml
singleton AgentSessions 1.0 AgentSessions.qml
singleton AccessoryPresentation 1.0 AccessoryPresentation.qml
singleton Ai 1.0 Ai.qml
singleton AppSearch 1.0 AppSearch.qml
singleton Audio 1.0 Audio.qml
singleton Battery 1.0 Battery.qml
singleton BluetoothStatus 1.0 BluetoothStatus.qml
singleton Booru 1.0 Booru.qml
singleton Brightness 1.0 Brightness.qml
singleton Cellular 1.0 Cellular.qml
singleton ChargeRate 1.0 ChargeRate.qml
singleton ClaudeUsage 1.0 ClaudeUsage.qml
singleton Cliphist 1.0 Cliphist.qml
singleton ConflictKiller 1.0 ConflictKiller.qml
singleton CrashReporter 1.0 CrashReporter.qml
singleton DateTime 1.0 DateTime.qml
singleton DeviceEvidence 1.0 DeviceEvidence.qml
singleton EasyEffects 1.0 EasyEffects.qml
singleton Emojis 1.0 Emojis.qml
singleton FileSearch 1.0 FileSearch.qml
singleton FirstRunExperience 1.0 FirstRunExperience.qml
singleton Gestures 1.0 Gestures.qml
singleton GlobalFocusGrab 1.0 GlobalFocusGrab.qml
singleton GoogleCloud 1.0 GoogleCloud.qml
singleton Haptics 1.0 Haptics.qml
singleton HidController 1.0 HidController.qml
singleton HyprlandAntiFlashbangShader 1.0 HyprlandAntiFlashbangShader.qml
singleton HyprlandConfig 1.0 HyprlandConfig.qml
singleton HyprlandData 1.0 HyprlandData.qml
singleton HyprlandKeybinds 1.0 HyprlandKeybinds.qml
singleton HyprlandXkb 1.0 HyprlandXkb.qml
singleton Hyprsunset 1.0 Hyprsunset.qml
singleton Idle 1.0 Idle.qml
singleton IdleCoordinator 1.0 IdleCoordinator.qml
singleton KeyringStorage 1.0 KeyringStorage.qml
singleton LatexRenderer 1.0 LatexRenderer.qml
singleton LauncherApps 1.0 LauncherApps.qml
singleton LauncherSearch 1.0 LauncherSearch.qml
singleton Lens 1.0 Lens.qml
singleton LockContentPolicy 1.0 LockContentPolicy.qml
singleton MaterialThemeLoader 1.0 MaterialThemeLoader.qml
singleton MprisController 1.0 MprisController.qml
singleton Network 1.0 Network.qml
singleton NetworkTraffic 1.0 NetworkTraffic.qml
singleton Notifications 1.0 Notifications.qml
singleton NotifyEvents 1.0 NotifyEvents.qml
singleton PhysicalKeyboard 1.0 PhysicalKeyboard.qml
singleton PolkitService 1.0 PolkitService.qml
singleton Privacy 1.0 Privacy.qml
singleton ResourceUsage 1.0 ResourceUsage.qml
singleton Selection 1.0 Selection.qml
singleton SessionAudit 1.0 SessionAudit.qml
singleton SessionEvents 1.0 SessionEvents.qml
singleton SessionWarnings 1.0 SessionWarnings.qml
singleton SessiondBridge 1.0 SessiondBridge.qml
singleton SessiondPolicy 1.0 SessiondPolicy.qml
singleton SongRec 1.0 SongRec.qml
singleton Face 1.0 Face.qml
singleton FingerprintPreview 1.0 FingerprintPreview.qml
singleton Souveraine 1.0 Souveraine.qml
singleton Speech 1.0 Speech.qml
singleton StepUpAuth 1.0 StepUpAuth.qml
singleton SystemInfo 1.0 SystemInfo.qml
singleton TaskbarApps 1.0 TaskbarApps.qml
singleton TimerService 1.0 TimerService.qml
singleton Todo 1.0 Todo.qml
singleton Translation 1.0 Translation.qml
singleton TrayService 1.0 TrayService.qml
singleton Updates 1.0 Updates.qml
singleton UsbState 1.0 UsbState.qml
singleton ViewtopControl 1.0 ViewtopControl.qml
singleton WallpaperAssets 1.0 WallpaperAssets.qml
singleton WallpaperDownload 1.0 WallpaperDownload.qml
singleton Wallpapers 1.0 Wallpapers.qml
singleton Weather 1.0 Weather.qml
singleton Ydotool 1.0 Ydotool.qml
singleton ZoneTransition 1.0 ZoneTransition.qml