kill, a closed window, a reboot: no SessionEnd arrives and the state file stays. Each case was tried rather than reasoned about, and one of the three was broken. A killed session was already handled -- the process is gone, so the file and its lock are removed within the 20 s liveness tick. An interrupted hook write left its temporary file behind forever; those are now swept once they are five minutes old, which is late enough that a hook part-way through writing one does not lose the update. The reboot case was the broken one. State files outlive a reboot and pids are handed out afresh, so "does /proc/<pid> exist" only answers "is some process wearing that number". Verified by giving an unrelated live process the pid of a dead session: the ghost sat in the panel as a session waiting for input, and would have stayed there forever, asking for an answer nobody could give. The pid is now pinned to the process start time from /proc/<pid>/stat, recorded when the state is written and compared when it is read. Files written before that field existed compare only on existence, as before, so a session open across the upgrade is not evicted. An abandoned flock needed nothing: the kernel drops it when the holder dies, so there is no deadlock to recover from.
293 lines
11 KiB
JavaScript
293 lines
11 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
|
|
|
|
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,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))
|
|
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();
|
|
// 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'))
|
|
names.push(name);
|
|
else if (name.endsWith('.tmp'))
|
|
this._sweepTemp(info, name);
|
|
}
|
|
}
|
|
|
|
const sessions = [];
|
|
for (const name of names) {
|
|
const session = await this._readOne(name, cancellable);
|
|
if (session)
|
|
sessions.push(session);
|
|
}
|
|
return sessions;
|
|
}
|
|
|
|
/** Delete an abandoned temporary file, once it is old enough to be sure. */
|
|
_sweepTemp(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,
|
|
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 ?? ''),
|
|
};
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|