"Idle" was set by SessionStart and by nothing else, and there was no path back into it. So it never meant "sitting unused" -- it meant "opened and never asked anything yet", a state a few seconds long that you would almost never catch. Meanwhile a session that finished an hour ago and was forgotten showed as waiting, which is correct but leaves the fourth state with nothing to describe. A session that has just opened is waiting for your first prompt exactly as one that finished a turn is waiting for your next. They are the same thing, and now they are the same state. Three glyphs instead of four, which also gives the remaining three more room to be told apart in a monochrome panel. Files written by the previous hook still say "idle", and a session open across the upgrade must not disappear, so unrecognised states now read as waiting rather than being treated as unknown. Covered by a test that feeds an "idle" file to the store and asserts it comes back as waiting, sorted by age among the others. The "hide when nothing is running" setting goes with it. Its condition was "no sessions, or all of them idle"; with idle gone the second half is unreachable and the first was already unconditional, so the switch could no longer change anything. A control that does nothing is worse than no control. The compaction test also got stronger in passing: it now checks that a mid-turn SessionStart leaves a *busy* session alone, which is the case that matters. It used to assert from waiting, where the state it was guarding against happened to be the state already stored.
239 lines
8.9 KiB
JavaScript
239 lines
8.9 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
|
|
|
|
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;
|
|
}
|
|
|
|
// 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 ?? ''),
|
|
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);
|
|
}
|