Act on four reviews: state machine, resource bounds, teardown

Four agents reviewed this in parallel -- correctness, GNOME integration,
edge cases, security. Everything below was reproduced before being fixed;
several findings that survived the first reading did not survive a probe
and are not here.

State machine, the two that mattered most. A pending permission prompt was
erased by any subagent bookkeeping event: SubagentStop or the next
PreToolUse recomputed the state from scratch, so a session sat at "working"
with a dialog open and nothing ever raised it again. Blocked now outlives
everything except evidence the question was answered. Separately, the
stale-event guard refused whole events, including the subagent counter's
increments and decrements -- but those are deltas and deltas commute, so a
"+1" that lost a timestamp race left the count short and the batch freed
the session while a subagent was still running. The guard now gates the
state decision only.

Corrupt or hostile state files could wedge the panel or take the hook down
for every session: a non-numeric pid raised inside sweep_dead before the
hook wrote its own file, so one bad byte stopped new sessions appearing at
all. Numbers read back from disk are coerced, one unreadable file no longer
aborts the sweep, and a stored timestamp far in the future -- corruption, or
a clock stepped backwards by NTP -- no longer refuses every later event
forever.

Resource bounds, all in the compositor process. A state file was read whole
with no size check: a symlink to /dev/zero took a test process past 4 GB in
three seconds, which in gnome-shell ends the session. Sizes are checked
before the read, sessions and zellij subprocesses are capped, labels
ellipsize, and cwd and messages are truncated at the hook.

Teardown hung off an overridden destroy(), which only runs when JS calls
it. An actor destroyed any other way -- another extension rebuilding the
panel boxes -- left the timer and the file monitor running against a
disposed actor. It is a destroy signal now. The zellij child is killed
rather than merely abandoned.

The glyph was pinned to physical pixels and rendered half-size on HiDPI;
size comes from the stylesheet, and the foreground colour is normalised by
inspection rather than assuming which colour struct the shell hands back.

Chip labels: non-Latin names all collapsed to "?", because the split
treated every Cyrillic letter as a separator -- notable for a tool whose
own README is Russian. Seniority also ranked by time-in-state rather than
session age, so after a shell restart the older session could take the
digit; the hook now records when the session began.

zellij: a dump ends with new_tab_template and swap_tiled_layout blocks
whose tab lines carry no name, and their panes were being attached to the
last real tab -- which then answered for every unmatched directory,
confidently and wrongly.

install.py no longer widens the mode of a settings.json someone narrowed to
0600, no longer overwrites the pristine .bak on a second run, no longer
replaces a symlink out of a dotfiles repository with a regular file, and
quotes the hook path. The debug log is capped and README now says plainly
that it records prompts verbatim.

Not fixed, deliberately: the panel does push the clock about 70 px left
with three labelled chips, which is inherent to putting them in the centre
box; two different projects abbreviating alike still read as one project
with a digit; GNOME 48 remains unverified for the colour struct and for
St.BoxLayout's vertical property, both flagged rather than guessed at.
This commit is contained in:
av
2026-08-09 20:20:45 +03:00
parent f4a06cdc44
commit 2565d45bb5
12 changed files with 324 additions and 79 deletions
+41 -14
View File
@@ -38,8 +38,13 @@ function drawStateDot(area, state) {
try {
const [w, h] = area.get_surface_size();
const c = area.get_theme_node().get_foreground_color();
// Normalised by inspection rather than by assumption: the colour struct
// behind this changed between shell versions, and a wrong guess either
// way paints the glyph invisible or fully saturated.
const scale = Math.max(c.red, c.green, c.blue, c.alpha) > 1 ? 255 : 1;
drawState(cr, state, w, h, {
r: c.red / 255, g: c.green / 255, b: c.blue / 255, a: c.alpha / 255,
r: c.red / scale, g: c.green / scale,
b: c.blue / scale, a: c.alpha / scale,
});
} finally {
cr.$dispose();
@@ -63,6 +68,12 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
this._changedId = this._store.connect('changed', () => this._update());
this._settingsChangedId = this._settings.connect('changed', () => this._update());
// Connected, not overridden: clutter_actor_destroy is not a vfunc, so a
// destroy() override only runs when JS calls it. An actor torn down any
// other way -- another extension rebuilding the panel boxes -- would
// leave the timer and the file monitor running against a disposed
// actor, screaming into the log every 20 seconds.
this.connect('destroy', () => this._onDestroy());
this._store.start();
}
@@ -95,18 +106,24 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
style_class: 'ccs-dot',
y_align: Clutter.ActorAlign.CENTER,
});
dot.set_width(14);
dot.set_height(14);
// Size comes from the stylesheet so St scales it: setting it here would
// pin the glyph to physical pixels and halve it on a HiDPI display.
dot.connect('repaint', area => drawStateDot(area, session.state));
chip.add_child(dot);
let age = null;
if (this._settings.get_boolean('show-project-name')) {
chip.add_child(new St.Label({
const text = new St.Label({
style_class: 'ccs-chip-label',
y_align: Clutter.ActorAlign.CENTER,
text: label,
}));
});
// With shortening off the label is a whole project name, which can
// be arbitrarily long: an unbounded label in the panel pushes the
// clock aside and, past a point, hands Pango a width it cannot
// represent.
text.clutter_text.ellipsize = Pango.EllipsizeMode.END;
chip.add_child(text);
}
if (withAge) {
age = new St.Label({
@@ -163,7 +180,10 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
this._chipLabels = assignChips(
sessions.map(s => ({
sessionId: s.sessionId,
since: s.since,
// Session age, not time in the current state: seniority decides
// who keeps the clean label, and a session that changed state a
// second ago has not thereby become the youngest.
since: s.started || s.since,
base: projectName(s.cwd),
})),
this._chipLabels);
@@ -198,7 +218,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
const { chip, age } = this._buildChip(
session, labelFor(session), ageOnFirst && i === 0);
if (age)
this._ageLabel = { age, session };
this._ageLabel = { age, sessionId: session.sessionId };
this._chipBox.add_child(chip);
});
if (hidden > 0) {
@@ -211,8 +231,14 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
this._chipSignature = signature;
}
if (this._ageLabel)
this._ageLabel.age.text = formatAge(this._ageOf(this._ageLabel.session));
if (this._ageLabel) {
// Looked up again rather than captured: a session that returns to
// the same state within one refresh keeps the signature unchanged,
// and a captured object would then show an age that stopped moving.
const current = sessions.find(s => s.sessionId === this._ageLabel.sessionId);
if (current)
this._ageLabel.age.text = formatAge(this._ageOf(current));
}
}
_updateMenu(sessions) {
@@ -229,8 +255,9 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
this._rebuildRows(sessions);
this._rowSignature = signature;
}
const byId = new Map(sessions.map(s => [s.sessionId, s]));
for (const row of this._rows) {
const session = sessions.find(s => s.sessionId === row.sessionId);
const session = byId.get(row.sessionId);
if (!session)
continue;
row.age.text = formatAge(this._ageOf(session));
@@ -288,6 +315,7 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
style_class: `ccs-row-title ccs-${session.state}`,
x_expand: true,
});
title.clutter_text.ellipsize = Pango.EllipsizeMode.END;
const age = new St.Label({
text: formatAge(this._ageOf(session)),
style_class: 'ccs-row-age',
@@ -368,11 +396,11 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
.catch(e => logError(e, 'claude-code-status: zellij refresh failed'));
}
// ---- Visuals --------------------------------------------------------
// ---- Teardown -------------------------------------------------------
destroy() {
_onDestroy() {
if (this._destroyed)
return;
this._destroyed = true;
this._zellij.destroy();
if (this._changedId) {
@@ -384,6 +412,5 @@ class ClaudeStatusIndicator extends PanelMenu.Button {
this._settingsChangedId = 0;
}
this._store.destroy();
super.destroy();
}
});