Watch
1
0
Fork
You've already forked souveraine
0

agent sessions: one collector, one island

Collector emits a single JSON envelope for every agent session (Souveraine,
Claude Code, Codex) and always exits 0; a failing provider reports
available:false rather than taking the envelope down.

AgentSessions.qml owns the cadence and projects the envelope. Transport is not
the contract: when the TASK-69 daemon lands, only the source changes.

Island is a morph host for the bar - dot on a cramped bar, pill where there is
room, derived from the same threshold BarContent uses.

Inert until registered in services/qmldir and mounted; see patches/0005.
This commit is contained in:
Fimeg 2026-08-11 14:19:45 -04:00
commit 47ce2d1970
5 changed files with 742 additions and 0 deletions

View file

@ -0,0 +1,188 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
// Island the agent surface for the top bar. TASK-70, fed by TASK-69.
//
// A morph host: one element that changes shape with what the agents are doing,
// rather than a fixed widget that is mostly empty. States, in order of weight:
//
// hidden no sessions at all occupy nothing, not a placeholder
// dot something is active; a pulse and nothing else
// pill the primary session: provider glyph, label, state
// expanded tapped open every session, grouped
//
// DEVICE TYPES ARE A REAL DIFFERENCE, NOT A SETTING
// The Pixel 3 bar is ~1080px with a carrier readout already competing for the
// left side, and ii-phone/BarContent drops the entire leftCenterGroup for that
// reason. So the island's *resting* state is device-dependent: `dot` on a
// narrow bar, `pill` where there is room. That is derived from the same
// threshold the rest of the bar uses (Appearance.sizes.barShorten...), so the
// island narrows exactly when its neighbours do. One authority for "is this
// bar cramped"; the island is a rendering of it, not a second opinion.
//
// HOST-AGNOSTIC, like SubconsciousTicker
// This owns no overlay state and reaches into no manager. Expansion is a local
// state change; anything larger is a signal for whoever mounted it to handle.
// That is what lets the same file serve the desktop bar, the phone bar, and
// later a viewtop node without a fork.
Item {
id: root
// The host may pin a form; otherwise it is derived from the bar's own
// cramped-ness. Values: "auto" | "dot" | "pill".
property string restingForm: "auto"
property var screen: root.QsWindow.window?.screen
// Same test BarContent uses, so island and neighbours narrow together.
readonly property bool narrowBar: (Appearance.sizes.barShortenScreenWidthThreshold >= (screen?.width ?? 99999))
readonly property string form: {
if (root.restingForm !== "auto")
return root.restingForm;
return root.narrowBar ? "dot" : "pill";
}
// Nothing to say occupy nothing. An always-present empty chip trains the
// eye to ignore the spot, which costs us the one thing the island is for.
readonly property bool hasContent: AgentSessions.sessions.length > 0
property bool expanded: false
// For a host that wants to put the full session list somewhere better than
// an inline popout (a sidebar, a sheet, a notch overlay). If nobody
// connects it, the inline expansion below is the fallback the component
// is useful alone and better when hosted.
signal requestOpenPanel
visible: hasContent
implicitWidth: visible ? content.implicitWidth : 0
implicitHeight: Appearance.sizes.baseBarHeight
readonly property var primary: AgentSessions.primarySession
// Colour carries the state, so the island reads pre-attentively you know
// something is running before you read a word of it.
readonly property color stateColor: {
if (AgentSessions.stale)
return Appearance.colors.colOutlineVariant;
if (AgentSessions.anyActive)
return Appearance.colors.colPrimary;
if (root.primary?.state === "recent")
return Appearance.colors.colOnLayer0;
return Appearance.colors.colOutlineVariant;
}
Rectangle {
id: content
anchors.centerIn: parent
implicitWidth: row.implicitWidth + 16
implicitHeight: Math.max(20, Appearance.sizes.baseBarHeight - 10)
radius: height / 2
color: root.expanded ? Appearance.colors.colLayer2
: mouse.containsMouse ? Appearance.colors.colLayer1
: "transparent"
Behavior on implicitWidth {
NumberAnimation { duration: 180; easing.type: Easing.OutCubic }
}
Behavior on color {
ColorAnimation { duration: 120 }
}
RowLayout {
id: row
anchors.centerIn: parent
spacing: 6
// The pulse. Present in every form in `dot` it IS the island.
Rectangle {
id: pulse
Layout.alignment: Qt.AlignVCenter
implicitWidth: 8
implicitHeight: 8
radius: 4
color: root.stateColor
// Only animate while something is genuinely active. A dot that
// always breathes is decoration; a dot that breathes only when
// an agent is working is information.
SequentialAnimation on opacity {
running: AgentSessions.anyActive && !AgentSessions.stale
loops: Animation.Infinite
NumberAnimation { to: 0.35; duration: 900; easing.type: Easing.InOutSine }
NumberAnimation { to: 1.0; duration: 900; easing.type: Easing.InOutSine }
}
// Leaving the loop mid-fade would strand it dim.
onOpacityChanged: if (!AgentSessions.anyActive && opacity !== 1) opacity = 1
}
// Multiple agents at once is the case the old per-provider widgets
// could not show at all. A count is the cheapest honest summary.
StyledText {
Layout.alignment: Qt.AlignVCenter
visible: AgentSessions.activeCount > 1
text: AgentSessions.activeCount
color: root.stateColor
font.pixelSize: Appearance.font.pixelSize.smaller
font.weight: Font.DemiBold
}
MaterialSymbol {
Layout.alignment: Qt.AlignVCenter
visible: root.form === "pill" && root.primary !== null
text: AgentSessions.providerIcon(root.primary?.provider ?? "")
iconSize: Appearance.font.pixelSize.normal
color: root.stateColor
}
StyledText {
Layout.alignment: Qt.AlignVCenter
visible: root.form === "pill" && root.primary !== null
text: AgentSessions.sessionLabel(root.primary)
color: Appearance.colors.colOnLayer0
font.pixelSize: Appearance.font.pixelSize.smaller
elide: Text.ElideRight
Layout.maximumWidth: 110
}
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.LeftButton | Qt.RightButton
onClicked: (ev) => {
if (ev.button === Qt.RightButton) {
root.requestOpenPanel();
return;
}
root.expanded = !root.expanded;
}
}
StyledToolTip {
// Stale is worth saying out loud rather than only dimming: a dim
// island and a quiet one look identical at a glance.
content: AgentSessions.stale
? qsTr("Agent sessions — stale (%1)").arg(AgentSessions.lastError)
: AgentSessions.available
? qsTr("%1 agent session(s), %2 active").arg(AgentSessions.sessions.length).arg(AgentSessions.activeCount)
: qsTr("Agent sessions — collecting…")
extraVisibleCondition: mouse.containsMouse && !root.expanded
}
}
IslandExpansion {
id: expansion
anchors.top: content.bottom
anchors.topMargin: 6
anchors.horizontalCenter: content.horizontalCenter
visible: root.expanded
onDismissed: root.expanded = false
}
}

View file

@ -0,0 +1,151 @@
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
// The island opened: every session the collector sees, newest first.
//
// Deliberately a flat list rather than provider-grouped tabs. The question this
// answers is "what is running right now", and that is chronological, not
// taxonomic grouping by provider would bury a live Codex run under three idle
// Souveraine threads. The provider is a glyph on each row instead.
Rectangle {
id: root
signal dismissed
implicitWidth: 320
implicitHeight: Math.min(column.implicitHeight + 16, 360)
radius: Appearance.rounding.small
color: Appearance.colors.colLayer2
border.width: 1
border.color: Appearance.colors.colLayer0Border
opacity: visible ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 140 } }
ColumnLayout {
id: column
anchors.fill: parent
anchors.margins: 8
spacing: 4
RowLayout {
Layout.fillWidth: true
spacing: 6
StyledText {
Layout.fillWidth: true
text: qsTr("Agent sessions")
color: Appearance.colors.colOnLayer0
font.pixelSize: Appearance.font.pixelSize.smaller
font.weight: Font.DemiBold
}
// Provider health belongs here, not on a row: a provider that is
// unavailable has no rows to hang the message on, and "no sessions"
// must never be confused with "not looking". Same family as every
// empty-result bug this project has hit.
Repeater {
model: ["souveraine", "claude", "codex"]
delegate: MaterialSymbol {
required property string modelData
readonly property var p: AgentSessions.providers?.[modelData] ?? null
visible: p !== null && p.available === false
text: AgentSessions.providerIcon(modelData)
iconSize: Appearance.font.pixelSize.smaller
color: Appearance.colors.colOutlineVariant
StyledToolTip {
content: qsTr("%1 unavailable: %2")
.arg(AgentSessions.providerLabel(parent.modelData))
.arg(parent.p?.error ?? "unknown")
}
}
}
}
StyledText {
Layout.fillWidth: true
visible: AgentSessions.stale
text: qsTr("stale — %1").arg(AgentSessions.lastError)
color: Appearance.colors.colOutlineVariant
font.pixelSize: Appearance.font.pixelSize.smallest
elide: Text.ElideRight
}
ListView {
Layout.fillWidth: true
Layout.fillHeight: true
Layout.preferredHeight: contentHeight
clip: true
spacing: 2
model: AgentSessions.sessions
delegate: Item {
required property var modelData
width: ListView.view.width
implicitHeight: 30
RowLayout {
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
spacing: 6
Rectangle {
Layout.alignment: Qt.AlignVCenter
implicitWidth: 6
implicitHeight: 6
radius: 3
color: modelData.state === "active" ? Appearance.colors.colPrimary
: modelData.state === "recent" ? Appearance.colors.colOnLayer0
: Appearance.colors.colOutlineVariant
}
MaterialSymbol {
Layout.alignment: Qt.AlignVCenter
text: AgentSessions.providerIcon(modelData.provider)
iconSize: Appearance.font.pixelSize.smaller
color: Appearance.colors.colOnLayer0
}
StyledText {
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
text: AgentSessions.sessionLabel(modelData)
color: Appearance.colors.colOnLayer0
font.pixelSize: Appearance.font.pixelSize.smaller
elide: Text.ElideRight
}
// "" where a provider does not report tokens. Rendering a
// 0 would be a measurement claim we cannot back: the
// Souveraine substrate records TokenUsage but nothing calls
// assistant_with_usage yet (TASK-67). An em dash is the
// honest glyph for "not measured".
StyledText {
Layout.alignment: Qt.AlignVCenter
readonly property int tok: AgentSessions.sessionTokens(modelData)
text: tok < 0 ? "—"
: tok > 1000 ? (Math.round(tok / 100) / 10) + "k"
: String(tok)
color: Appearance.colors.colOutlineVariant
font.pixelSize: Appearance.font.pixelSize.smallest
}
}
}
}
StyledText {
Layout.fillWidth: true
visible: AgentSessions.sessions.length === 0
text: AgentSessions.available ? qsTr("No recent sessions")
: qsTr("Collecting…")
color: Appearance.colors.colOutlineVariant
font.pixelSize: Appearance.font.pixelSize.smaller
horizontalAlignment: Text.AlignHCenter
}
}
}

View file

@ -0,0 +1,2 @@
Island 1.0 Island.qml
IslandExpansion 1.0 IslandExpansion.qml

View file

@ -0,0 +1,222 @@
#!/usr/bin/env bash
# One spine for agent session state — TASK-69 / TASK-70.
#
# Emits a SINGLE JSON envelope describing every agent session on this machine,
# across providers: Souveraine, Claude Code, Codex. The shell reads this and
# renders projections of it; no surface fetches per-provider any more.
#
# WHY A SCRIPT AND NOT THE DAEMON YET
# TASK-69 calls for a Rust collector on a unix socket. This is the same
# envelope, produced by polling, so the surface can be built and proven against
# the real shape now. When the daemon lands, AgentSessions.qml swaps its source
# from this process to the socket and NOTHING downstream changes. The envelope
# is the contract; the transport is an implementation detail. Keep it that way.
#
# CONTRACT
# stdout: exactly one line of JSON, always. Never empty, never partial.
# exit: always 0. A provider that fails reports available:false and the
# reason; it never takes the envelope down with it. A blank bar is a
# worse failure than a stale one.
#
# Usage: agent-sessions.sh [--window-min N] (default 1440 = 24h)
set -uo pipefail # deliberately NOT -e: a failing provider must not abort
WINDOW_MIN=1440
[ "${1:-}" = "--window-min" ] && WINDOW_MIN="${2:-1440}"
NOW=$(date +%s)
# Emit a minimal valid envelope and leave, for the cases where we cannot even
# start (no jq). Downstream must never see malformed JSON.
if ! command -v jq >/dev/null 2>&1; then
printf '{"ts":%s,"sessions":[],"providers":{"claude":{"available":false,"error":"jq missing"},"codex":{"available":false,"error":"jq missing"},"souveraine":{"available":false,"error":"jq missing"}}}\n' "$NOW"
exit 0
fi
# ---------------------------------------------------------------- claude ----
# ~/.claude/projects/<slug>/<uuid>.jsonl — one file per session. Directory slug
# is the cwd with '/' -> '-'. Per-assistant `message.usage` carries
# input/output/cache_creation/cache_read. We read only files touched inside the
# window: a months-old session is not "a session", it is history.
claude_json() {
local dir="$HOME/.claude/projects"
[ -d "$dir" ] || { echo '{"available":false,"error":"no ~/.claude/projects"}'; return; }
local files
files=$(find "$dir" -name '*.jsonl' -mmin "-$WINDOW_MIN" 2>/dev/null | head -40)
[ -z "$files" ] && { echo '{"available":true,"sessions":[]}'; return; }
# tail -400: token totals are cumulative in intent but recorded per-message;
# reading whole multi-MB transcripts on a UI timer is not acceptable. We
# report recent-window tokens and say so, rather than pretending to a
# lifetime total we did not pay to compute.
local out="[]"
while IFS= read -r f; do
[ -n "$f" ] || continue
local slug session
slug=$(basename "$(dirname "$f")")
session=$(basename "$f" .jsonl)
local s
s=$(tail -n 400 "$f" 2>/dev/null | jq -c -s \
--arg id "$session" --arg slug "$slug" '
(map(select(.message.usage != null))) as $u
| (map(select(.timestamp != null) | .timestamp) | max) as $last
| {
provider: "claude",
id: $id,
cwd: ($slug | gsub("^-";"/") | gsub("-";"/")),
model: ([$u[].message.model] | map(select(. != "<synthetic>")) | last // ""),
lastActivity: ($last // ""),
tokensIn: ([$u[].message.usage.input_tokens] | add // 0),
tokensOut: ([$u[].message.usage.output_tokens] | add // 0),
cacheRead: ([$u[].message.usage.cache_read_input_tokens] | add // 0),
cacheCreate: ([$u[].message.usage.cache_creation_input_tokens] | add // 0),
turns: ($u | length),
windowed: true
}' 2>/dev/null)
[ -n "$s" ] && out=$(jq -c --argjson s "$s" '. + [$s]' <<<"$out" 2>/dev/null || echo "$out")
done <<<"$files"
jq -c '{available:true, sessions:.}' <<<"$out" 2>/dev/null \
|| echo '{"available":false,"error":"claude parse failed"}'
}
# ----------------------------------------------------------------- codex ----
# ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl. The LAST token_count event holds
# cumulative `total_token_usage` for the session plus the live `rate_limits`
# block (primary = 5h window, secondary = weekly). Cumulative here, unlike
# Claude — so no windowing caveat.
codex_json() {
local dir="$HOME/.codex/sessions"
[ -d "$dir" ] || { echo '{"available":false,"error":"no ~/.codex/sessions"}'; return; }
local files
files=$(find "$dir" -name '*.jsonl' -mmin "-$WINDOW_MIN" 2>/dev/null | head -40)
[ -z "$files" ] && { echo '{"available":true,"sessions":[],"limits":null}'; return; }
local out="[]" limits="null"
while IFS= read -r f; do
[ -n "$f" ] || continue
local id s
# rollout-2026-06-25T08-47-08-<uuid>.jsonl — take the uuid, not the
# date prefix, so the id is stable and actually identifies the session.
id=$(basename "$f" .jsonl | grep -oE '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
[ -n "$id" ] || id=$(basename "$f" .jsonl | sed 's/^rollout-//')
s=$(jq -c -s --arg id "$id" '
(map(select(.payload.type == "token_count")) | last) as $tc
| (map(select(.payload.type == "session_meta")) | last) as $meta
| if $tc == null then empty else {
provider: "codex",
id: $id,
cwd: ($meta.payload.cwd // ""),
model: ($meta.payload.model // ""),
lastActivity: ($tc.timestamp // ""),
tokensIn: ($tc.payload.info.total_token_usage.input_tokens // 0),
tokensOut: ($tc.payload.info.total_token_usage.output_tokens // 0),
cacheRead: ($tc.payload.info.total_token_usage.cached_input_tokens // 0),
cacheCreate: 0,
reasoning: ($tc.payload.info.total_token_usage.reasoning_output_tokens // 0),
contextWindow: ($tc.payload.info.model_context_window // 0),
rateLimits: ($tc.payload.rate_limits // null),
turns: 0,
windowed: false
} end' "$f" 2>/dev/null)
if [ -n "$s" ]; then
out=$(jq -c --argjson s "$s" '. + [$s]' <<<"$out" 2>/dev/null || echo "$out")
local rl
rl=$(jq -c '.rateLimits // empty' <<<"$s" 2>/dev/null)
[ -n "$rl" ] && limits="$rl" # newest file wins; loop is time-ordered by find
fi
done <<<"$files"
jq -c --argjson lim "$limits" '{available:true, sessions:., limits:$lim}' <<<"$out" 2>/dev/null \
|| echo '{"available":false,"error":"codex parse failed"}'
}
# ------------------------------------------------------------ souveraine ----
# ~/.souveraine/server/agents/<agent>/conversations/<conv>/conversation.json
# carries updated_at + message_count. It does NOT carry token usage: the
# substrate records `TokenUsage` but `assistant_with_usage` has zero callers
# (TASK-67). We report -1 for tokens rather than 0, because 0 is a measurement
# and -1 is an admission. The day TASK-67 lands, this starts reporting real
# numbers and nothing else has to change.
souveraine_json() {
local dir="$HOME/.souveraine/server/agents"
[ -d "$dir" ] || { echo '{"available":false,"error":"no ~/.souveraine"}'; return; }
local files
files=$(find "$dir" -name 'conversation.json' -mmin "-$WINDOW_MIN" 2>/dev/null | head -40)
[ -z "$files" ] && { echo '{"available":true,"sessions":[]}'; return; }
local out="[]"
while IFS= read -r f; do
[ -n "$f" ] || continue
local s
s=$(jq -c '
if .archived == true then empty else {
provider: "souveraine",
id: (.id // ""),
agentId: (.agent_id // ""),
cwd: "",
model: "",
lastActivity: (.last_message_at // .updated_at // ""),
tokensIn: -1, tokensOut: -1, cacheRead: -1, cacheCreate: -1,
turns: (.message_count // 0),
subconscious: ((.agent_id // "") | endswith("-sub")),
windowed: false
} end' "$f" 2>/dev/null)
[ -n "$s" ] && out=$(jq -c --argjson s "$s" '. + [$s]' <<<"$out" 2>/dev/null || echo "$out")
done <<<"$files"
jq -c '{available:true, sessions:.}' <<<"$out" 2>/dev/null \
|| echo '{"available":false,"error":"souveraine parse failed"}'
}
CLAUDE=$(claude_json); [ -n "$CLAUDE" ] || CLAUDE='{"available":false,"error":"collector crashed"}'
CODEX=$(codex_json); [ -n "$CODEX" ] || CODEX='{"available":false,"error":"collector crashed"}'
SOUV=$(souveraine_json); [ -n "$SOUV" ] || SOUV='{"available":false,"error":"collector crashed"}'
# Merge. `state` is derived here so every surface agrees on what "active" means
# — one authority, everything else a rendering.
# active : touched in the last 2 minutes
# recent : within the hour
# idle : older
# There is deliberately no "waiting" state. Knowing an agent awaits a permission
# decision requires the hook bridge (TASK-70 stage 2); inventing it from
# timestamps would be a guess wearing the costume of a measurement.
jq -c -n \
--argjson now "$NOW" \
--argjson claude "$CLAUDE" \
--argjson codex "$CODEX" \
--argjson souv "$SOUV" '
# jq'"'"'s fromdateiso8601 accepts ONLY %Y-%m-%dT%H:%M:%SZ. Every source here
# emits fractional seconds (Claude .179Z, Codex .238Z, Souveraine .126950960Z),
# so the naive parse fails on all of them — and a bare try/catch turns that
# into a silent 0, which renders as "idle" for a session that is live right
# now. Strip the fraction before parsing, and surface a parse miss as -1 so
# a future breakage is visible instead of quietly plausible.
def epoch:
if . == "" or . == null then 0
else (sub("\\.[0-9]+(?=Z$)"; "") | try fromdateiso8601 catch -1)
end;
def state($now): (.lastActivity | epoch) as $t
| if $t == 0 then "idle"
elif ($now - $t) < 120 then "active"
elif ($now - $t) < 3600 then "recent"
else "idle" end;
( ($claude.sessions // []) + ($codex.sessions // []) + ($souv.sessions // []) )
| map(. + {state: state($now), age: ($now - (.lastActivity | epoch))})
| sort_by(.age)
as $all
| {
ts: $now,
sessions: $all,
active: ($all | map(select(.state == "active")) | length),
providers: {
claude: ($claude | del(.sessions)) + {sessions: ($claude.sessions // [] | length)},
codex: ($codex | del(.sessions)) + {sessions: ($codex.sessions // [] | length)},
souveraine: ($souv | del(.sessions)) + {sessions: ($souv.sessions // [] | length)}
}
}' 2>/dev/null \
|| printf '{"ts":%s,"sessions":[],"active":0,"providers":{"claude":{"available":false,"error":"merge failed"},"codex":{"available":false,"error":"merge failed"},"souveraine":{"available":false,"error":"merge failed"}}}\n' "$NOW"

View file

@ -0,0 +1,179 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs
import qs.modules.common
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 agent, else a truncated id. Never an empty string: a blank chip
// is indistinguishable from a broken one.
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")
return s.subconscious ? "subconscious" : "agent";
return (s.id ?? "").slice(0, 8);
}
// Total tokens for a session, or -1 when the provider genuinely does not
// report them. -1 is deliberate: the Souveraine substrate records TokenUsage
// but `assistant_with_usage` has zero callers (TASK-67), so 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
command: [Quickshell.shellDir + "/scripts/agent/agent-sessions.sh"]
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()
}
}