Files
claude-code-gnome-extension/lib/sessions.js
T
av 7fc7f63842 Show Claude Code session status in the GNOME panel
Answers one question at a glance: is any session waiting for me, and
which one. With several sessions open the cost is not knowing what each
is doing, it is noticing that one stopped an hour ago.

Claude Code hooks write one JSON file per session under
~/.local/state/claude-code-status; the extension watches the directory
with Gio.FileMonitor, so nothing polls and there is no daemon.

Two distinctions carry the design:

  * blocked (permission prompt) is kept apart from waiting (turn done).
    Merged, a finished task looks as urgent as a stuck one, which is
    exactly the judgement the indicator exists to make.

  * the panel names the oldest session in the top state, not the latest.
    The session you forget is the one that has been waiting longest.

PostToolUse is registered although it looks redundant: it is the only
event that fires after a permission is granted, so without it a session
stays blocked in the panel for the rest of the turn. It writes only on
an actual state change, so the usual case costs no I/O.

Stop and SessionEnd are synchronous, unlike the rest. Both fire as the
process is about to go quiet, and an async hook racing that exit gets
killed before it writes -- claude -p left a session pinned at busy.
Concurrent hooks for one session serialise on an flock plus a timestamp
guard; tests/test-hook.sh covers each separately, because the burst test
passes on the timestamp guard alone.

Sessions running in zellij are located by tab name rather than by path,
matched through dump-layout on the working directory. The dump carries
no pane ids, so ZELLIJ_PANE_ID cannot be used; rows that do not resolve
stay inert instead of pretending a click does something.

lib/sessions.js deliberately imports nothing from the shell resource
namespace, which lets the riskiest logic -- liveness, ordering, partial
reads, monitoring -- run under plain gjs in tests/test-sessions.js.
2026-08-09 18:11:27 +03:00

236 lines
8.7 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', '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);
}