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:
+5
-2
@@ -13,9 +13,12 @@ const MAX = 3;
|
||||
|
||||
/** Split on separators and camelCase humps: "pet-project-server", "outlineMcp". */
|
||||
function segments(name) {
|
||||
// Unicode-aware: splitting on [^a-zA-Z0-9] makes every Cyrillic letter a
|
||||
// separator, so "проект" reduces to nothing and every non-Latin project
|
||||
// ends up sharing the label "?".
|
||||
return name
|
||||
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
||||
.split(/[^a-zA-Z0-9]+/)
|
||||
.replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, '$1 $2')
|
||||
.split(/[^\p{L}\p{N}]+/u)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -12,7 +12,11 @@ export function formatAge(seconds) {
|
||||
if (m < 60)
|
||||
return `${m}m`;
|
||||
const h = Math.floor(m / 60);
|
||||
return `${h}h ${m % 60}m`;
|
||||
if (h < 24)
|
||||
return `${h}h ${m % 60}m`;
|
||||
// Days, or a session left over the weekend reads as "120h 0m" and widens
|
||||
// the very row the chip cap exists to keep narrow.
|
||||
return `${Math.floor(h / 24)}d ${h % 24}h`;
|
||||
}
|
||||
|
||||
/** Last path segment, with ~ collapsed. Two worktrees of one repo share a
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
// State glyphs, drawn with cairo alone.
|
||||
//
|
||||
// The panel is monochrome, so shape is the only channel left and these four
|
||||
// have to stay apart at 14 px. Kept free of St/Clutter/GNOME imports so the
|
||||
// shapes can be rendered to a file and looked at, rather than guessed about.
|
||||
// The panel is monochrome, so shape is the only channel left and the three
|
||||
// states have to stay apart at 14 px. Kept free of St/Clutter/GNOME imports so
|
||||
// the shapes can be rendered to a file and looked at, rather than guessed at.
|
||||
|
||||
/** Draw `state` filling the given box, in the colour passed as {r,g,b,a} 0..1. */
|
||||
export function drawState(cr, state, width, height, color) {
|
||||
@@ -21,7 +21,7 @@ export function drawState(cr, state, width, height, color) {
|
||||
|
||||
switch (state) {
|
||||
case 'blocked':
|
||||
// Disc inside a ring: the most ink of the four, for the only state
|
||||
// Disc inside a ring: the most ink of the three, for the only state
|
||||
// where a session is stuck until you act. The inner disc has to be
|
||||
// big enough to register at 14 px, or this reads as plain "working".
|
||||
cr.arc(cx, cy, radius, 0, 2 * Math.PI);
|
||||
|
||||
+41
-14
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
+39
-11
@@ -33,6 +33,17 @@ const UNKNOWN_PID_MAX_AGE = 36 * 3600; // seconds
|
||||
// lose that update.
|
||||
const TMP_MAX_AGE = 300; // seconds
|
||||
|
||||
// A state file is a few hundred bytes. Anything larger is corrupt or hostile,
|
||||
// and reading it whole would happen inside the compositor: a symlink to
|
||||
// /dev/zero took a test process past 4 GB in three seconds, which in
|
||||
// gnome-shell is the session ending. The size is checked before the read.
|
||||
const MAX_STATE_BYTES = 64 * 1024;
|
||||
|
||||
// Work here is on the compositor's main loop, and every session costs a menu
|
||||
// row of five actors. Well past any real use, and cheap insurance against a
|
||||
// directory someone filled up.
|
||||
const MAX_SESSIONS = 64;
|
||||
|
||||
export function stateRank(state) {
|
||||
const i = STATES.indexOf(state);
|
||||
return i < 0 ? STATES.length : i;
|
||||
@@ -134,16 +145,20 @@ export const SessionStore = GObject.registerClass({
|
||||
let enumerator;
|
||||
try {
|
||||
enumerator = await this._dir.enumerate_children_async(
|
||||
'standard::name,time::modified', Gio.FileQueryInfoFlags.NONE,
|
||||
'standard::name,standard::size,time::modified', Gio.FileQueryInfoFlags.NONE,
|
||||
GLib.PRIORITY_DEFAULT, cancellable);
|
||||
} catch (e) {
|
||||
// No directory yet means no sessions have ever run; not an error.
|
||||
if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
|
||||
// NOT_DIRECTORY means something took the path -- also not worth a
|
||||
// stack trace every 20 seconds for as long as it stays that way.
|
||||
if (e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND) ||
|
||||
e.matches?.(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_DIRECTORY))
|
||||
return [];
|
||||
throw e;
|
||||
}
|
||||
|
||||
const names = [];
|
||||
const locks = [];
|
||||
for (;;) {
|
||||
const batch = await enumerator.next_files_async(
|
||||
32, GLib.PRIORITY_DEFAULT, cancellable);
|
||||
@@ -153,15 +168,29 @@ export const SessionStore = GObject.registerClass({
|
||||
const name = info.get_name();
|
||||
// Only ".json" is state. ".lock" belongs to the hook, "debug"
|
||||
// is its opt-in event log, and ".tmp" is an interrupted write.
|
||||
if (name.endsWith('.json'))
|
||||
if (name.endsWith('.json')) {
|
||||
if (info.get_size() > MAX_STATE_BYTES) {
|
||||
// Not read at all: the point is to never allocate it.
|
||||
continue;
|
||||
}
|
||||
names.push(name);
|
||||
else if (name.endsWith('.tmp'))
|
||||
this._sweepTemp(info, name);
|
||||
} else if (name.endsWith('.tmp')) {
|
||||
this._sweepStale(info, name);
|
||||
} else if (name.endsWith('.json.lock')) {
|
||||
locks.push({ info, name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A lock whose state file is gone belongs to nothing; the hook only
|
||||
// removes the pair together, so nobody else would ever clear it.
|
||||
for (const { info, name } of locks) {
|
||||
if (!names.includes(name.slice(0, -'.lock'.length)))
|
||||
this._sweepStale(info, name);
|
||||
}
|
||||
|
||||
const sessions = [];
|
||||
for (const name of names) {
|
||||
for (const name of names.slice(0, MAX_SESSIONS)) {
|
||||
const session = await this._readOne(name, cancellable);
|
||||
if (session)
|
||||
sessions.push(session);
|
||||
@@ -169,8 +198,9 @@ export const SessionStore = GObject.registerClass({
|
||||
return sessions;
|
||||
}
|
||||
|
||||
/** Delete an abandoned temporary file, once it is old enough to be sure. */
|
||||
_sweepTemp(info, name) {
|
||||
/** Delete an abandoned file, once it is old enough to be sure nobody is
|
||||
* part-way through writing it. */
|
||||
_sweepStale(info, name) {
|
||||
const modified = info.get_modification_date_time?.();
|
||||
if (!modified)
|
||||
return;
|
||||
@@ -235,12 +265,10 @@ export const SessionStore = GObject.registerClass({
|
||||
state,
|
||||
cwd: String(raw.cwd ?? ''),
|
||||
since: Number(raw.since) || 0,
|
||||
started: Number(raw.started) || 0,
|
||||
pid,
|
||||
message: String(raw.message ?? ''),
|
||||
agents: Math.max(0, Number(raw.agents) || 0),
|
||||
notificationType: String(raw.notification_type ?? ''),
|
||||
zellijSession: String(raw.zellij_session ?? ''),
|
||||
zellijPane: String(raw.zellij_pane ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+33
-5
@@ -20,12 +20,18 @@ Gio._promisify(Gio.Subprocess.prototype, 'communicate_utf8_async');
|
||||
// does not spawn a zellij process every time it runs.
|
||||
const CACHE_TTL = 120; // seconds
|
||||
|
||||
// One subprocess per distinct zellij session named in the state directory.
|
||||
// In real use that is one or two; the bound is there because the names come
|
||||
// from files, and a directory full of them would fork a process per name.
|
||||
const MAX_SESSIONS = 8;
|
||||
|
||||
export class ZellijTabs {
|
||||
constructor() {
|
||||
this._cache = new Map(); // zellij session -> { at, tabs: [{name, cwds}] }
|
||||
this._inFlight = new Map();
|
||||
this._available = null;
|
||||
this._cancellable = new Gio.Cancellable();
|
||||
this._children = new Set();
|
||||
}
|
||||
|
||||
/** Tab name for a working directory, or null when unknown. */
|
||||
@@ -58,7 +64,7 @@ export class ZellijTabs {
|
||||
return;
|
||||
const now = GLib.get_monotonic_time() / 1e6;
|
||||
const work = [];
|
||||
for (const name of new Set(zellijSessions)) {
|
||||
for (const name of [...new Set(zellijSessions)].slice(0, MAX_SESSIONS)) {
|
||||
if (!name)
|
||||
continue;
|
||||
const entry = this._cache.get(name);
|
||||
@@ -97,10 +103,15 @@ export class ZellijTabs {
|
||||
const proc = Gio.Subprocess.new(
|
||||
['zellij', '--session', session, 'action', 'dump-layout'],
|
||||
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_SILENCE);
|
||||
const [stdout] = await proc.communicate_utf8_async(null, this._cancellable);
|
||||
if (!proc.get_successful())
|
||||
return null;
|
||||
return stdout ?? '';
|
||||
this._children.add(proc);
|
||||
try {
|
||||
const [stdout] = await proc.communicate_utf8_async(null, this._cancellable);
|
||||
if (!proc.get_successful())
|
||||
return null;
|
||||
return stdout ?? '';
|
||||
} finally {
|
||||
this._children.delete(proc);
|
||||
}
|
||||
} catch (e) {
|
||||
// zellij not installed, or not on the shell's PATH: stop trying.
|
||||
if (e.matches?.(GLib.SpawnError, GLib.SpawnError.NOENT))
|
||||
@@ -112,6 +123,11 @@ export class ZellijTabs {
|
||||
/** Abandon any layout dump still running; the extension is going away. */
|
||||
destroy() {
|
||||
this._cancellable.cancel();
|
||||
// Cancelling only abandons the read; the child keeps running. A wedged
|
||||
// zellij server would otherwise outlive the extension being disabled.
|
||||
for (const proc of this._children)
|
||||
proc.force_exit();
|
||||
this._children.clear();
|
||||
this._cache.clear();
|
||||
this._inFlight.clear();
|
||||
}
|
||||
@@ -135,11 +151,23 @@ export function parseLayout(text) {
|
||||
tabs.push(current);
|
||||
continue;
|
||||
}
|
||||
// A dump ends with new_tab_template and swap_tiled_layout blocks, whose
|
||||
// own `tab` lines carry no name. Their panes belong to no tab at all;
|
||||
// left attached to whatever came before, they make the last tab in the
|
||||
// dump answer for every unmatched directory -- confidently and wrongly.
|
||||
if (/^\s*(tab\s|tab\s*\{|new_tab_template|swap_tiled_layout|swap_floating_layout)/.test(line)) {
|
||||
current = null;
|
||||
continue;
|
||||
}
|
||||
if (!current)
|
||||
continue;
|
||||
const paneMatch = line.match(/^\s*pane\s.*?\bcwd="([^"]*)"/);
|
||||
if (paneMatch) {
|
||||
const cwd = paneMatch[1];
|
||||
// A relative pane cwd is meaningless without the layout-level one;
|
||||
// joining against "" yields a relative path that matches nothing.
|
||||
if (!cwd.startsWith('/') && !base)
|
||||
continue;
|
||||
const absolute = cwd.startsWith('/')
|
||||
? cwd
|
||||
: GLib.build_filenamev([base, cwd]);
|
||||
|
||||
Reference in New Issue
Block a user