Files
claude-code-gnome-extension/lib/sessions.js
T
av 2565d45bb5 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.
2026-08-09 20:20:45 +03:00

321 lines
12 KiB
JavaScript

// Reads the per-session state files written by the Claude Code hook and keeps
// them in sync with the filesystem.
//
// The hook only writes on an actual state change, so a directory monitor is
// enough and there is nothing to poll. The timer here exists for two other
// reasons: displayed ages go stale on their own, and a session whose terminal
// was killed never sends SessionEnd, so liveness has to be rechecked.
import GObject from 'gi://GObject';
import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
Gio._promisify(Gio.File.prototype, 'enumerate_children_async');
Gio._promisify(Gio.FileEnumerator.prototype, 'next_files_async');
Gio._promisify(Gio.File.prototype, 'load_contents_async');
// Aggregation order: a session blocked on a permission prompt is the only one
// that is actually stuck, so it outranks one that merely finished its turn.
export const STATES = ['blocked', 'waiting', 'busy'];
const KNOWN = new Set(STATES);
const LIVENESS_INTERVAL = 20; // seconds
// Fallback for sessions whose process could not be identified (pid 0): there is
// nothing to test for liveness, so age is the only signal left. Long enough
// that a session genuinely left waiting overnight is still listed in the
// morning, which is exactly the case this indicator exists for.
const UNKNOWN_PID_MAX_AGE = 36 * 3600; // seconds
// A hook killed between writing its temporary file and renaming it leaves the
// temporary behind. Old ones are swept; recent ones are left alone, because a
// hook may be part-way through writing one right now and deleting it would
// 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;
}
export function stateDir() {
const base = GLib.getenv('XDG_STATE_HOME') ||
GLib.build_filenamev([GLib.get_home_dir(), '.local', 'state']);
return GLib.build_filenamev([base, 'claude-code-status']);
}
export const SessionStore = GObject.registerClass({
Signals: { 'changed': {} },
}, class SessionStore extends GObject.Object {
_init() {
super._init();
this._dir = Gio.File.new_for_path(stateDir());
this._sessions = [];
this._monitor = null;
this._debounceId = 0;
this._timerId = 0;
this._cancellable = new Gio.Cancellable();
this._loading = false;
this._loadAgain = false;
}
get sessions() {
return this._sessions;
}
start() {
// The directory is created by the first hook run, which may not have
// happened yet; monitoring a missing directory still reports its
// creation, so there is nothing to wait for.
try {
this._monitor = this._dir.monitor_directory(Gio.FileMonitorFlags.WATCH_MOVES, null);
this._monitor.connect('changed', () => this._scheduleLoad());
} catch (e) {
logError(e, 'claude-code-status: cannot monitor state directory');
}
this._timerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, LIVENESS_INTERVAL, () => {
// Ages advance and processes die without any file changing, so this
// tick is what makes a killed terminal disappear from the panel.
this._load();
return GLib.SOURCE_CONTINUE;
});
this._load();
}
// One atomic write lands as several monitor events (created, moved, changed).
// Collapsing them keeps a burst of five sessions from causing five reloads.
_scheduleLoad() {
if (this._debounceId)
GLib.Source.remove(this._debounceId);
this._debounceId = GLib.timeout_add(GLib.PRIORITY_DEFAULT, 120, () => {
this._debounceId = 0;
this._load();
return GLib.SOURCE_REMOVE;
});
}
async _load() {
if (this._loading) {
this._loadAgain = true;
return;
}
this._loading = true;
const cancellable = this._cancellable;
try {
const sessions = await this._readAll(cancellable);
if (cancellable.is_cancelled())
return;
sessions.sort((a, b) => {
const byState = stateRank(a.state) - stateRank(b.state);
// Oldest first within a state: the session you forgot about is
// the one that has been waiting longest, not the latest one.
return byState !== 0 ? byState : a.since - b.since;
});
this._sessions = sessions;
// Emitted unconditionally: even with no structural change the
// displayed ages have advanced, and redrawing a handful of labels
// is cheaper than tracking what moved.
this.emit('changed');
} catch (e) {
if (!cancellable.is_cancelled())
logError(e, 'claude-code-status: failed to read session state');
} finally {
this._loading = false;
if (this._loadAgain) {
this._loadAgain = false;
this._load();
}
}
}
async _readAll(cancellable) {
let enumerator;
try {
enumerator = await this._dir.enumerate_children_async(
'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.
// 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);
if (!batch.length)
break;
for (const info of batch) {
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 (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._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.slice(0, MAX_SESSIONS)) {
const session = await this._readOne(name, cancellable);
if (session)
sessions.push(session);
}
return sessions;
}
/** 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;
const age = GLib.DateTime.new_now_local().difference(modified) / 1e6;
if (age < TMP_MAX_AGE)
return;
this._dir.get_child(name).delete_async(GLib.PRIORITY_LOW, null, (obj, res) => {
try {
obj.delete_finish(res);
} catch (e) {
// Gone already, or not ours to remove.
}
});
}
async _readOne(name, cancellable) {
const file = this._dir.get_child(name);
let raw;
try {
const [contents] = await file.load_contents_async(cancellable);
raw = JSON.parse(new TextDecoder().decode(contents));
} catch (e) {
// A file replaced mid-read, or truncated by a crash: skip it and
// let the next monitor event pick up the good version.
return null;
}
if (!raw || typeof raw !== 'object')
return null;
const pid = Number(raw.pid) || 0;
const pidStart = Number(raw.pid_start) || 0;
const eventTs = Number(raw.event_ts) || 0;
const age = GLib.get_real_time() / 1e6 - eventTs;
// pid 0 is "the hook could not tell", not "dead": treating it as dead
// would hide a perfectly live session, so those fall back to an age
// cutoff instead.
const gone = pid > 0 ? !isAlive(pid, pidStart) : age > UNKNOWN_PID_MAX_AGE;
if (gone) {
// The terminal was killed without a SessionEnd hook. Removing the
// file here (rather than only hiding it) keeps the directory from
// growing forever across reboots.
// The hook's lock file goes with it; dropping only the state file
// would leave one empty ".lock" behind per session, forever.
for (const victim of [file, this._dir.get_child(`${name}.lock`)]) {
victim.delete_async(GLib.PRIORITY_LOW, null, (obj, res) => {
try {
obj.delete_finish(res);
} catch (e) {
// Already gone: the hook's own sweep got there first.
}
});
}
return null;
}
// Anything unrecognised reads as "waiting", which also migrates files
// left on disk by the older hook: those still say "idle", and a
// session open since before the upgrade must not vanish from the panel.
const state = KNOWN.has(raw.state) ? raw.state : 'waiting';
return {
sessionId: String(raw.session_id ?? name.replace(/\.json$/, '')),
state,
cwd: String(raw.cwd ?? ''),
since: Number(raw.since) || 0,
started: Number(raw.started) || 0,
pid,
agents: Math.max(0, Number(raw.agents) || 0),
zellijSession: String(raw.zellij_session ?? ''),
};
}
destroy() {
this._cancellable.cancel();
if (this._debounceId) {
GLib.Source.remove(this._debounceId);
this._debounceId = 0;
}
if (this._timerId) {
GLib.Source.remove(this._timerId);
this._timerId = 0;
}
this._monitor?.cancel();
this._monitor = null;
this._sessions = [];
}
});
/** Is this pid still the process the hook recorded?
*
* Existence alone is not enough. State files outlive reboots, and a pid from a
* previous boot is very likely to belong to something else now -- a session
* that died in a crash would otherwise sit in the panel forever, waiting for
* an answer nobody can give. The start time pins the pid to one process.
*/
function isAlive(pid, startTime) {
if (pid <= 0 || !GLib.file_test(`/proc/${pid}`, GLib.FileTest.EXISTS))
return false;
// Files written before start times were recorded have nothing to compare.
if (!startTime)
return true;
return readStartTime(pid) === startTime;
}
function readStartTime(pid) {
try {
const [ok, bytes] = GLib.file_get_contents(`/proc/${pid}/stat`);
if (!ok)
return 0;
const data = new TextDecoder().decode(bytes);
// The command name is parenthesised and may contain spaces and ')',
// so fields are counted from after the last one.
const tail = data.slice(data.lastIndexOf(')') + 2).split(' ');
return Number(tail[19]) || 0;
} catch (e) {
return 0;
}
}