// 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; } }