2565d45bb5952475fdca89fe16a0cab858407b48
13
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
ca42d69704
|
Cap the chips at three, and make the menu report-only
Two changes that pull in the same direction: the panel says less, and the menu stops pretending to do anything. The chip row had no bound. It sits in the centre box next to the clock, so enough open sessions would have shoved the clock off centre. It now shows the first N, settable and three by default, and counts the rest as "+N". Chips are already ordered by urgency, so the ones that survive the cut are the ones that need you soonest. Labels are still assigned across every session, including hidden ones, so a chip does not change when the cap does or when a session ahead of it disappears. Clicking a menu row used to switch the zellij tab and raise a terminal. That is gone. It cost real machinery for what it saved -- gnome-terminal runs every window under one shared server process, so windows cannot be matched by pid and the code fell back to matching the zellij session name against window titles, with all the ways that misses. The menu reports status; alt-tab is not the bottleneck. Rows are built inert rather than demoted after the fact, because PopupBaseMenuItem latches _activatable in its constructor. zellij tab lookup stays: naming the tab is the better half of that feature and costs one process every couple of minutes. The preferences test now asserts a control per settings key rather than a switch per key, so the new spin row counts and a future non-boolean setting cannot slip in without one. |
||
|
|
97783d43b8
|
Execute prefs.js in a test
prefs.js had never run. It lives in its own process rather than in the compositor, so enabling the extension in a nested shell does not reach it, and every other check in this repo was blind to it -- it would first have executed when someone opened the preferences and found it broken. It builds under real Adw, and the hook-status line correctly reports all nine registered events. The shell's ExtensionPreferences base class and its gettext are stubbed rather than loaded: both resolve an extension by walking the caller's URL up to a registered UUID, which needs the whole extension manager for no gain here. The stub keeps the test on this extension's own code. The switch count is asserted against the schema's key count, so a setting added without a row to change it fails the test. |
||
|
|
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. |
||
|
|
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. |
||
|
|
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. |
||
|
|
839c6f6b52
|
Drop colour from the panel, and the message that said nothing
Two changes that turned out to be related. The chips painted their own red, amber and green. An indicator that picks its own colours competes with the shell's accents and stops following the theme, so the panel now draws everything in the panel's text colour and lets shape carry the state. Only alpha still varies, to push idle sessions back. That put real weight on the four shapes being distinguishable at 14 px, which is not something to guess at, so the drawing moved to lib/glyph.js -- free of St, Clutter and shell imports precisely so the glyphs can be rendered to a file and looked at. They were, and the first attempt was wrong twice: a disc filled to the ring's centreline reads smaller than the ring beside it, and the inner disc of "blocked" was too small to tell from a plain "working" ring. Radii are now matched at the outer edge and the inner disc sits at 0.55, both settled by comparing renders. Separately, hooking a live session showed the Notification that accompanies a permission prompt carries only "Claude needs your permission" -- no tool, no command -- and arrives identically when the session is putting a question to the user rather than asking to run something. The menu was giving that string the line, pushing out the tab and the path: strictly less information in more space. It is gone, and the comment claiming it "says what is about to run" is corrected. Recovering the real command means reading the tail of the transcript when the prompt fires; it is not in the event. The same finding settles how far the states can split. "Blocked" cannot be divided into "asking permission" and "asking a question" from this data. |
||
|
|
a4e5d653de
|
Show one chip per session, right of the clock
The panel showed a single aggregate: the most urgent session, plus a "+N" for the others. That answers "what is the worst thing happening", which is not the question with five projects open -- "what is each of them doing" needs each of them on screen. Each session now gets a chip: state glyph plus a three-character project label, in the centre box just right of the clock. Time in state stays on the first chip only; five counters side by side are a row of numbers, not an answer. Labels are initials for multi-segment names, first letters otherwise. Initials rather than a prefix, because a prefix collapses dev-skills and dev-conventions onto the same "dev" -- exactly the pair that has to stay apart. Collisions, including two sessions in one project where the cwd is identical, take a digit: ds, ds2, ds3. Two rules keep a label still, and the feature is worthless without them. Assignment runs oldest-session-first, so a session starting now takes the suffix instead of displacing one already on screen; and a label belongs to its session until it ends, even after whatever forced the digit has closed. A label that moves under your hand is worse than one carrying a digit that no longer looks necessary. The zellij tab name was considered as the label source and dropped. It arrives asynchronously from dump-layout, so a sticky label would freeze whatever the project name produced first and never adopt it. Digits already separate same-project sessions, so the tab name buys nothing here and stays where it is useful, in the menu. Menu rows now lead with the same label, so the mapping from "ds" to dev-skills is read rather than guessed. |
||
|
|
58409106c3
|
Перевести README на русский
Единственный читатель этого расширения пишет и думает по-русски. Строки интерфейса, комментарии в коде и сообщения коммитов остаются английскими: их адресат — GNOME Shell и потенциальный сторонний читатель кода, а не владелец репозитория. |
||
|
|
223876a91a
|
Fold NOTES.md into README.md
NOTES.md was written before the code, to hold the intent and the checked
facts about the environment. Everything in it that still applies now
lives in README.md, and the parts that do not -- the order of work, the
fields to verify against real hook input -- were answered by building it.
Two notes were kept rather than dropped: the rejection of desktop
notifications, which is a decision that would otherwise be re-litigated,
and the reasoning for hiding sub-agents. The original text stays in
history at
|
||
|
|
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.
|