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:
parent
928bd8d143
commit
47ce2d1970
5 changed files with 742 additions and 0 deletions
188
surfaces/quickshell/modules/souveraine/island/Island.qml
Normal file
188
surfaces/quickshell/modules/souveraine/island/Island.qml
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
2
surfaces/quickshell/modules/souveraine/island/qmldir
Normal file
2
surfaces/quickshell/modules/souveraine/island/qmldir
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
Island 1.0 Island.qml
|
||||
IslandExpansion 1.0 IslandExpansion.qml
|
||||
222
surfaces/quickshell/scripts/agent/agent-sessions.sh
Executable file
222
surfaces/quickshell/scripts/agent/agent-sessions.sh
Executable 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"
|
||||
179
surfaces/quickshell/services/AgentSessions.qml
Normal file
179
surfaces/quickshell/services/AgentSessions.qml
Normal 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()
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue