9 Commits
Author SHA1 Message Date
av d48a18ae75 Ask the session what is running instead of counting it
A session whose main agent had stopped while a background subagent worked
showed as waiting. The count was kept by hand -- +1 on PreToolUse matched
to ^(Agent|Task)$, -1 on SubagentStop -- and the two halves do not see the
same thing. A launch reaches the hook only at the top level: a subagent
spawning its own subagents does it through events carrying agent_id, which
are dropped. SubagentStop arrives for every subagent at every depth. Each
nested one subtracted from a batch it had never joined.

Measured on the live session that showed it: one background agent, then
fourteen nested stops, the first of which took the count to zero and turned
the chip white with the batch still running. Replaying those recorded
events through the old hook reproduces it exactly, and through the new one
holds busy throughout, rising to two while two background agents ran.

The events carry the answer themselves. Stop and SubagentStop -- the two
that can end a turn, and the only two where it matters -- come with
background_tasks: every running task with its type and status. The count is
now read from there and nothing accumulates, so it cannot drift, and a
subagent that dies without sending SubagentStop no longer leaks a count
that pins the chip at busy. Events without the field leave the stored value
alone, which is what keeps an idle_prompt nudge from freeing a working
session.

A background shell is deliberately not counted. A dev server left running
says nothing about whether the session needs you, and treating it as work
would hold the chip at "working" for as long as it lives.

PreToolUse is no longer registered: counting was the only thing it was for.
It stays listed as a legacy event so that both install and uninstall sweep
it out of settings.json rather than leaving it there to spawn the hook on
every agent launch for nothing.
2026-08-23 09:19:30 +03:00
av 67d7b14cf5 Keep headless runs out of the panel
`claude -p` was showing up as a session. It prints one answer and exits:
there is no input line, it cannot be blocked on you, and there is nowhere
to walk over to. A script that runs a few dozen of them turned the panel
into a flicker of chips that were gone before they could be read. The Agent
SDK and editor integrations drive claude the same way and are covered by
the same rule.

The flag is looked for in the arguments of the already-identified claude
process, by exact token, so nothing new has to be discovered -- the pid was
resolved on every event anyway. That resolution moved from apply_event up
into main, which is where the decision has to be made: a headless run is
dropped before the state file is touched at all, so it never creates one
and correspondingly never deletes one on SessionEnd. Its events still reach
the debug log, otherwise "why is my session missing from the panel" would
have nothing to answer with.

Arguments are now read by splitting /proc/<pid>/cmdline on its NUL
separators rather than on spaces. A prompt is an ordinary argument, and
`claude "when do I need -p"` is an interactive session that keeps its chip;
the old space-joined string could not tell the two apart. looks_like_claude
takes the list too, which is what it always wanted -- it was splitting the
joined string back apart itself.

Verified end to end as well as in the classifier: a fake claude runs the
hook as a child through /proc, with -p leaving no file and without -p
leaving one. A real `claude -p` against the installed hook added nothing to
the state directory.
2026-08-09 21:35:04 +03:00
av 2565d45bb5 Act on four reviews: state machine, resource bounds, teardown
Four agents reviewed this in parallel -- correctness, GNOME integration,
edge cases, security. Everything below was reproduced before being fixed;
several findings that survived the first reading did not survive a probe
and are not here.

State machine, the two that mattered most. A pending permission prompt was
erased by any subagent bookkeeping event: SubagentStop or the next
PreToolUse recomputed the state from scratch, so a session sat at "working"
with a dialog open and nothing ever raised it again. Blocked now outlives
everything except evidence the question was answered. Separately, the
stale-event guard refused whole events, including the subagent counter's
increments and decrements -- but those are deltas and deltas commute, so a
"+1" that lost a timestamp race left the count short and the batch freed
the session while a subagent was still running. The guard now gates the
state decision only.

Corrupt or hostile state files could wedge the panel or take the hook down
for every session: a non-numeric pid raised inside sweep_dead before the
hook wrote its own file, so one bad byte stopped new sessions appearing at
all. Numbers read back from disk are coerced, one unreadable file no longer
aborts the sweep, and a stored timestamp far in the future -- corruption, or
a clock stepped backwards by NTP -- no longer refuses every later event
forever.

Resource bounds, all in the compositor process. A state file was read whole
with no size check: a symlink to /dev/zero took a test process past 4 GB in
three seconds, which in gnome-shell ends the session. Sizes are checked
before the read, sessions and zellij subprocesses are capped, labels
ellipsize, and cwd and messages are truncated at the hook.

Teardown hung off an overridden destroy(), which only runs when JS calls
it. An actor destroyed any other way -- another extension rebuilding the
panel boxes -- left the timer and the file monitor running against a
disposed actor. It is a destroy signal now. The zellij child is killed
rather than merely abandoned.

The glyph was pinned to physical pixels and rendered half-size on HiDPI;
size comes from the stylesheet, and the foreground colour is normalised by
inspection rather than assuming which colour struct the shell hands back.

Chip labels: non-Latin names all collapsed to "?", because the split
treated every Cyrillic letter as a separator -- notable for a tool whose
own README is Russian. Seniority also ranked by time-in-state rather than
session age, so after a shell restart the older session could take the
digit; the hook now records when the session began.

zellij: a dump ends with new_tab_template and swap_tiled_layout blocks
whose tab lines carry no name, and their panes were being attached to the
last real tab -- which then answered for every unmatched directory,
confidently and wrongly.

install.py no longer widens the mode of a settings.json someone narrowed to
0600, no longer overwrites the pristine .bak on a second run, no longer
replaces a symlink out of a dotfiles repository with a regular file, and
quotes the hook path. The debug log is capped and README now says plainly
that it records prompts verbatim.

Not fixed, deliberately: the panel does push the clock about 70 px left
with three labelled chips, which is inherent to putting them in the centre
box; two different projects abbreviating alike still read as one project
with a digit; GNOME 48 remains unverified for the colour struct and for
St.BoxLayout's vertical property, both flagged rather than guessed at.
2026-08-09 20:20:45 +03:00
av f4a06cdc44 Identify the claude process by argument, not by substring
Testing the reboot case exposed a live regression, and the identity check
added for reboots is what made it visible.

Matching "claude" anywhere in an ancestor's command line was too loose. The
hook is spawned as `/bin/sh -c /.../claude-status-hook.py`, so its parent's
command line contains "claude" -- in the path to this very script -- while
being a shell that exits milliseconds later. That shell's pid was being
recorded as the session's. Existence checks alone hid it: the pid was dead,
the file was deleted, the next event recreated it, and the session flickered.
Once the pid was pinned to a process start time the session vanished
outright.

The rule was broadened in the first place to cover npm-style installs that
run `node .../claude-code/cli.js`, which was a real gap. It is now matched
per argument instead: argv[0] named claude, or a cli.js under a claude path.
Both installs pass, and the spawning shell does not.

The pid is also resolved before the unchanged-check rather than after, so a
pid that has changed forces a write. A session resumed under a new pid --
which is exactly what --resume does, and what this session had done -- kept
its old pid for as long as its state happened not to change, and the reader
would drop it as dead.

The debug log now records the process ancestry, which is what made this
diagnosable at all rather than guessable.
2026-08-09 19:47:19 +03:00
av 75bfe77950 Survive a crash, and a reboot after one
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.
2026-08-09 19:43:31 +03:00
av 5398f32826 Count subagents, and keep a batch from looking free
Corrected from the previous commit, which had it backwards. When a batch
is running the session is working, not waiting: the main agent will pick
the results up and consolidate them itself, so sending you to that
terminal wastes the trip. That is the common shape of the work here --
ask for a batch, let it run.

Simply letting subagent tool calls set "busy" would mostly work and was
tempting, but it leaves a hole. Stop fires before the batch finishes, so
the session shows as free from the moment the turn ends until the first
subagent tool call lands -- and longer whenever the subagents are thinking
rather than calling tools. So subagents are counted instead:

  PreToolUse, matched to ^(Agent|Task)$   +1
  SubagentStop                            -1
  UserPromptSubmit                        reset to 0

While the count is above zero the session cannot read as waiting; Stop and
an idle_prompt nudge both leave it working. The session is freed by the
last subagent leaving, and only if the main agent has stopped by then.

The matcher is anchored because it is a regex: a bare "Task" also matches
TaskCreate and friends, which are not subagents. The hook re-checks the
tool name itself in case a future matcher behaves differently, and the
reset on UserPromptSubmit bounds a count that leaks because a subagent
died without its SubagentStop.

Measured, not assumed: a matched PreToolUse fires only on agent launches,
SubagentStop arrives once per subagent carrying agent_id, and a real
three-subagent run walks the count 0-1-2-3-2-0 before Stop frees it.

The menu shows the number, as asked. The panel does not: a batch of eight
is still one line saying "working 40 min", which is the right line.
2026-08-09 19:26:25 +03:00
av 09b9aae2eb Ignore subagent work, not subagent notifications
A subagent's tool calls do reach the parent session's hooks: measured, a
PostToolUse arrives carrying agent_id and agent_type. Only Stop was
guarded against that, and Stop was the case that mattered least.

With background subagents the ordering is the harmful one. The main agent
ends its turn first, so Stop lands and the session reads "waiting"; the
subagents keep working, and their PostToolUse arrives afterwards and puts
the session back to "busy". The panel then says a session is working when
its input line is free and it is waiting for you -- the precise confusion
this indicator exists to prevent, and reported from a live session doing
exactly that.

Every event carrying agent_id is now ignored. Synchronous subagents lose
nothing: the main agent is mid-turn, so its own earlier events already say
"busy".

Notification is deliberately exempt. It means a human is needed, and that
is as true when the agent that got stuck is a subagent -- ignoring it
would leave a session silently blocked.

Covered both ways in the hook tests, and checked once against a real
subagent event captured from a live run rather than a hand-written one.
2026-08-09 19:18:43 +03:00
av 626c2b6d2c Merge "idle" into "waiting"
"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.
2026-08-09 19:14:34 +03:00
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