// 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', 'idle']; 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 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', 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)) return []; throw e; } const names = []; 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(); // ".tmp" files are half-written state; "debug" is the hook's // opt-in event log and is not a session. if (name.endsWith('.json')) names.push(name); } } const sessions = []; for (const name of names) { const session = await this._readOne(name, cancellable); if (session) sessions.push(session); } return sessions; } 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 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) : 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; } const state = KNOWN.has(raw.state) ? raw.state : 'idle'; return { sessionId: String(raw.session_id ?? name.replace(/\.json$/, '')), state, cwd: String(raw.cwd ?? ''), since: Number(raw.since) || 0, pid, message: String(raw.message ?? ''), notificationType: String(raw.notification_type ?? ''), zellijSession: String(raw.zellij_session ?? ''), zellijPane: String(raw.zellij_pane ?? ''), }; } 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 = []; } }); function isAlive(pid) { return pid > 0 && GLib.file_test(`/proc/${pid}`, GLib.FileTest.EXISTS); }