From ee05490620ff07c47e93e7013496dab0eaf19525 Mon Sep 17 00:00:00 2001 From: x9x9x9x9x9x91 <131364915+x9x9x9x9x9x91@users.noreply.github.com> Date: Wed, 19 Aug 2026 01:03:31 +0200 Subject: [PATCH 1/2] docs(cookbook): add truthful-agent-lights recipe The Stop classifier and staleness sweeper from discussion #456, in the shape the verdict asked for: a recipe, not an installer change. Replaces the Stop hook's unconditional completed --auto-reset with a classifier over the tool subtrees still alive under the agent, and adds a scheduled sweeper that enforces no-glyph-means-idle in both directions. --- cookbook/README.md | 1 + cookbook/truthful-agent-lights/README.md | 184 +++++++++ .../truthful-agent-lights/lights-common.sh | 131 +++++++ .../truthful-agent-lights/machinery-paint.sh | 38 ++ .../truthful-agent-lights/session-note.sh | 52 +++ cookbook/truthful-agent-lights/set-status.sh | 42 +++ .../truthful-agent-lights/status-sweep.sh | 349 ++++++++++++++++++ .../truthful-agent-lights/turn-end-status.sh | 82 ++++ cookbook/truthful-agent-lights/work-scan.sh | 161 ++++++++ 9 files changed, 1040 insertions(+) create mode 100644 cookbook/truthful-agent-lights/README.md create mode 100644 cookbook/truthful-agent-lights/lights-common.sh create mode 100755 cookbook/truthful-agent-lights/machinery-paint.sh create mode 100755 cookbook/truthful-agent-lights/session-note.sh create mode 100755 cookbook/truthful-agent-lights/set-status.sh create mode 100755 cookbook/truthful-agent-lights/status-sweep.sh create mode 100755 cookbook/truthful-agent-lights/turn-end-status.sh create mode 100755 cookbook/truthful-agent-lights/work-scan.sh diff --git a/cookbook/README.md b/cookbook/README.md index 612200e2..943f05d2 100644 --- a/cookbook/README.md +++ b/cookbook/README.md @@ -43,6 +43,7 @@ its *needs* column, so searching this page for `claude` or `kiro` finds those di | [kimi-agent-status](kimi-agent-status/) | Kimi Code sessions report agent status onto their sidebar row | 0.3.1, Kimi Code | | [kiro-agent-status](kiro-agent-status/) | Kiro CLI sessions report active, blocked, and completed onto their sidebar row | 0.7.1, Kiro CLI | | [status-announcer](status-announcer/) | demo: speak agent status changes from a dedicated session | 0.16.0, jq | +| [truthful-agent-lights](truthful-agent-lights/) | the row reports what is still running after the turn ends, and stops claiming work that is gone | 0.17.0, jq, Claude Code | ### Panes, pickers and input diff --git a/cookbook/truthful-agent-lights/README.md b/cookbook/truthful-agent-lights/README.md new file mode 100644 index 00000000..8bcdc063 --- /dev/null +++ b/cookbook/truthful-agent-lights/README.md @@ -0,0 +1,184 @@ +# Truthful agent lights + +A Claude Code session's row reports what is still running when the turn ends, and a sweeper clears every glyph nothing backs. + +## What it does + +The stock hooks map Stop to `completed --auto-reset` unconditionally, which is right for a session that finished and wrong for one that did not. Three cases go wrong in agent-heavy use: + +- **The turn ends, the work does not.** A session that started a test run in the background, or dispatched a second agent as a worker, goes green at Stop while that work runs on. Visit the row once and the glyph clears, so it is now indistinguishable from an idle shell. +- **The agent dies, the glyph lives.** A killed or crashed agent leaves its last `active` pulsing forever, because a pushed status is trusted until something pushes another one. +- **Thinking, machinery and waiting look identical.** A thirty-minute test run, a model composing a reply, and a session waiting on a lock are all `active`, so "is it working, waiting, or wedged?" cannot be answered from the sidebar. + +This recipe replaces the Stop hook with a classifier that looks at what is still alive under the agent process and picks the glyph that matches, and adds a scheduled sweeper that enforces the same truth from the outside, in both directions: it clears a claim no process backs, and re-lights a row that is quietly working. + +The other agent-status recipes — `kimi-agent-status`, `kiro-agent-status`, `container-agent-status` — wire one more agent's lifecycle hooks to the stock four-state vocabulary. This one goes the other way: it keeps that package installed and replaces only the decision Stop makes, then adds a sweeper that runs out of band, on a timer, with no hook involved. If you also run `status-announcer`, note that it speaks `blocked`, `completed` and `idle`: the sweeper's own clears are status changes like any other, so a row it corrects at three in the morning is a sentence spoken out loud. + +Sub-states are drawn with the per-call `--color` and `--shape` overrides: + +| glyph | means | +|---|---| +| pulsing default | a dispatched worker or subagent is still running, or the model is producing | +| work color, running shape (square) | machinery is executing: tests, a build, a remote run | +| work color, mixed shape (diamond) | machinery is executing *and* something else is queued behind it | +| work color, queued shape (triangle) | only waiting: a lock queue, a poll loop, a log watcher | +| stuck color, stuck shape (star), pulsing | the row claims to be working, nothing is running under it, and the transcript has not moved | +| completed | the turn ended and the scan found nothing running | + +A per-call override beats what you picked in Settings ▸ Agent Status, for as long as that status stands. If a shape here collides with the vocabulary you configured there, change the shape variables in `lights-common.sh` rather than your settings — the recipe is the thing that should give way. + +## Requirements + +- agterm 0.17.0 or later — `agtermctl session status --shape` and the matching `statusShape` field on `tree` shipped there (#292, selectable status-glyph silhouettes picked per status in Settings or per call). Everything else the recipe uses is older: `--color` in 0.7.1 (#129), `--pane` and the injected `AGTERM_PANE` in 0.7.1 (#130), `--pane-id` in 0.13.0, the `statusBlink` read-back on `tree` in 0.10.0 (#169), and `--blink`, `--auto-reset`, `window list --json` and `tree --json` all predate the earliest tagged release. +- `jq`, and a BSD userland: the scripts use `ps -axo`, `ps eww`, `stat -f %m` and `find -mmin` as macOS provides them. +- Claude Code. The classifier reads a Claude Code implementation detail — see *Limits* — verified against 2.1.235. +- The agent-status hooks package (Help ▸ Install Agent Status Hooks…), optional but recommended: when its `agterm-agent-status.sh` is present the recipe posts through it, so socket, pane and pane-id handling stay upstream's. Without it the recipe calls `agtermctl` itself. +- `launchd` for the sweeper, or any scheduler that can run a command every couple of minutes. + +## Setup + +Copy the recipe somewhere stable and make the scripts executable: + +```sh +mkdir -p ~/.config/agterm/truthful-lights +cp truthful-agent-lights/* ~/.config/agterm/truthful-lights/ +chmod +x ~/.config/agterm/truthful-lights/*.sh +chmod -x ~/.config/agterm/truthful-lights/lights-common.sh +``` + +`lights-common.sh` is sourced by the others, never run, so it is the one file that does not want the executable bit. Everything machine-specific lives in it as a variable with a default: + +| variable | default | what it is | +|---|---|---| +| `AGTERMCTL` | `agtermctl` | the CLI that talks to the control socket | +| `AGT_LIGHTS_STATE` | `~/.local/state/agterm-lights` | pid notes, turn stamps, heartbeats | +| `AGT_STATUS_SCRIPT` | the hooks package's `agterm-agent-status.sh` | the stock script to post through, when it exists | +| `AGT_AGENT_PATTERN` | `claude\|codex\|kimi\|opencode\|pi` | command names that count as an agent | +| `AGT_WORK_COLOR` / `AGT_STUCK_COLOR` | `#4A9EFF` / `#FF3B30` | the two tints | +| `AGT_SHAPE_RUNNING` / `_MIXED` / `_QUEUED` / `_STUCK` | `square` / `diamond` / `triangle` / `star` | the silhouettes | +| `AGT_HB_FRESH_SECS` / `AGT_HB_STALE_SECS` | 300 / 1500 | how recently a hook fired for the sweeper to keep its hands off, and how long is long enough to call a glyph unbacked | +| `AGT_OWN_LIVE_SECS` | 900 | how long an unfinished turn may claim to be live without a transcript write | +| `AGT_STALL_SECS` | 1500 | how long a running claim may make no progress before it reads as stuck | +| `AGT_SSH_WORK_SECS` | 120 | above this age an `ssh` is a remote run, below it a probe | +| `AGT_MACHINERY_PATTERN` | test and build commands | what the optional PreToolUse paint matches; set in `machinery-paint.sh`, not in `lights-common.sh` | +| `AGT_LIGHTS_LOG` | `sweep.log` in the state directory | where the sweeper records what it changed | + +The hooks and the sweeper have to agree on `AGT_LIGHTS_STATE`, and the default is built so they cannot disagree: it hangs off `$HOME`, which is the same for both. `XDG_STATE_HOME` is deliberately not consulted, even though this is exactly the kind of file it names. The hooks run inside your shell, where an `XDG_STATE_HOME` exported from a shell rc is set; the sweeper runs from launchd, which starts with no such environment and would fall back to the default. Honoring the variable would therefore split the two halves across two directories on precisely the machines that set it, leaving the sweeper to read no stamps at all and report every live session as idle. If you do override `AGT_LIGHTS_STATE`, set it in both places, and keep it absolute. + +The sweeper talks to agterm's default control socket. If yours is somewhere else, point `AGTERMCTL` at a small wrapper that adds `--socket ` and execs the real binary — the recipe passes no socket flag of its own. + +Merge into `~/.claude/settings.json` (paths shown for the location above): + +```json +{ + "hooks": { + "SessionStart": [ + { "hooks": [{ "type": "command", "command": "~/.config/agterm/truthful-lights/session-note.sh" }] } + ], + "UserPromptSubmit": [ + { "hooks": [ + { "type": "command", "command": "~/.config/agterm/truthful-lights/session-note.sh" }, + { "type": "command", "command": "~/.config/agterm/truthful-lights/set-status.sh active --blink" } + ] } + ], + "PreToolUse": [ + { "matcher": "Bash", "hooks": [{ "type": "command", "command": "~/.config/agterm/truthful-lights/machinery-paint.sh" }] } + ], + "PostToolUse": [ + { "hooks": [{ "type": "command", "command": "~/.config/agterm/truthful-lights/set-status.sh active --blink" }] } + ], + "Stop": [ + { "hooks": [{ "type": "command", "command": "~/.config/agterm/truthful-lights/turn-end-status.sh completed --auto-reset" }] } + ], + "Notification": [ + { "matcher": "permission_prompt", "hooks": [{ "type": "command", "command": "~/.config/agterm/truthful-lights/set-status.sh blocked" }] } + ] + } +} +``` + +If the hooks package already wrote its own entries, **replace** them rather than adding these alongside. Leaving the stock `Stop` in place is the one mistake that breaks the recipe outright: two `Stop` entries fire on every turn, the stock one posting `completed --auto-reset` while the classifier posts what is actually running, and which of the two you end up looking at is a race. Repointing the other three events matters less but still matters — the recipe's heartbeat is written by `set-status.sh`, so an event still posting through the stock script leaves the sweeper half blind about whether this session's hooks are alive. The `PreToolUse` entry is optional; without it you see machinery only once the turn ends. + +Running Help ▸ Install Agent Status Hooks… again after that re-creates the stock entries *beside* these, reopening exactly that race. The installer decides an event is already wired by looking for its own script path inside the command string, and these commands no longer contain it, so all four stock entries come back. If you re-run the installer, remove the duplicates again. + +Then schedule the sweeper. Save as `~/Library/LaunchAgents/local.agterm-truthful-lights.plist`, replacing `USERNAME` with your own account: + +```xml + + + + + Labellocal.agterm-truthful-lights + ProgramArguments + + /Users/USERNAME/.config/agterm/truthful-lights/status-sweep.sh + + EnvironmentVariables + + PATH/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + StartInterval120 + RunAtLoad + + +``` + +```sh +launchctl load ~/Library/LaunchAgents/local.agterm-truthful-lights.plist +``` + +That `PATH` is load-bearing, and it is the same trap `status-announcer` documents for custom commands. A LaunchAgent runs under launchd's own `PATH` — the system directories plus `/usr/local/bin`, with no `/opt/homebrew/bin` — so `agtermctl` resolves only because **Help ▸ Install Command Line Tool…** symlinks it into `/usr/local/bin`, while a Homebrew `jq` does not resolve at all. The sweeper says so in its log and exits rather than half-working; the line above is what stops it happening. Add any variable you overrode to the same dict, so the sweeper and the hooks see the same values. + +To remove: `launchctl unload ~/Library/LaunchAgents/local.agterm-truthful-lights.plist` and delete the plist, delete the recipe's entries from `settings.json`, then run Help ▸ Install Agent Status Hooks… once. That last step is the same probe working in your favour: with no entry naming the stock script, the installer writes its four stock entries back, so you end up with the standard hook set instead of a session with no status at all. Then delete the state directory and the recipe directory. + +## Usage + +Nothing to run. Work as usual and read the row: + +Start a test suite as a background tool call and end the turn — the row stays lit in the work color with the running shape instead of going green, and the sweeper turns it green within two minutes of the suite finishing. Dispatch a worker agent and end the turn — the row keeps pulsing until the worker exits. Hold a session in a lock queue and it shows the queued shape, so a row waiting for a slot is distinguishable at a glance from one burning CPU. + +Green means the turn ended with nothing running, and it is posted with `--auto-reset`, so it stays on the row until you next visit that session and clears then. It is a flag you have not read yet, not a notification that times out. + +Kill an agent mid-run and its row clears itself within the stale window instead of pulsing for the rest of the day. Leave a session sitting at its prompt and the glyph goes away, so no glyph reliably means nothing is happening here. + +The sweeper's log records every change it made and why, and every reason it did nothing at all. It grows to two hundred lines and is then trimmed back to the last hundred, so it stays worth reading and never worth rotating. Read it when a row does something you did not expect: every branch that acts logs why it acted. + +## How it works + +Claude Code runs every tool command, foreground and background alike, under a recognizable wrapper: `zsh -c source …/shell-snapshots/snapshot-….sh && …`, a child of the agent process. MCP servers and harness helpers are direct children *without* that wrapper. So "is a tool still running" becomes a question about wrapper subtrees under the agent pid, and the helpers never pollute the answer. Answering it costs one `ps -axo pid=,ppid=,etime=,command=` pass and one `awk` program, with nothing watched or polled between calls; the sweeper is the part that runs on a timer. + +Each wrapper subtree is classified by its leaves, and the leaves decide by shape rather than by name: + +- a leaf that is another agent binary makes the subtree a worker: pulse +- `sleep`, `flock`, `pgrep`, `curl`, `date`, a short-lived `ssh` and friends make it a wait: something is queued, not executing +- `tail`, `grep` and their relatives make it a log watcher, counted apart from waits because a watcher rides along with a real run and must not make the row claim a queue +- anything else is real work +- `ssh` is disambiguated by age, not name: a reachability probe lives seconds, a remote build holds its pipe for minutes, so past `AGT_SSH_WORK_SECS` it counts as a run +- a subtree with no leaves at all is an idling loop shell, counted as a wait + +Three details cost hours to find: + +**The hook counts itself.** The Stop hook is itself a process under a wrapper under the agent, so the naive scan always finds work and never goes green. The caller's whole ancestor chain is excluded before anything is counted. + +**Transcript mtime does not mean a turn is running.** The harness keeps appending to the transcript after a turn ends — notifications, system events — and using mtime alone held a false "still working" glyph for minutes after every turn. The recipe stamps turn boundaries instead: `UserPromptSubmit` touches a start stamp, Stop touches an end stamp, a start newer than an end means a turn is live, and the transcript is used only to cap that claim so an interrupted turn that never fired Stop cannot hold the glyph forever. + +**A busy-looking subtree may only be waiting.** Before the sweeper re-lights a row as machinery it samples the subtree's summed CPU time twice, 1.5 seconds apart, and demands real movement. The turn-end classifier deliberately does not do this: at Stop the cost of a false "still running" is one extra glance, while a sweeper that re-lights on every poll loop would never let a row go quiet. + +The sweeper reads `window list --json` and `tree --window --json`, and needs `statusBlink` and `statusShape` from the tree to know what a row is currently claiming, so it can leave a correct glyph alone instead of rewriting it every two minutes. Agents are discovered by scanning `ps eww` for `AGTERM_SESSION_ID`, which covers sessions that started before the hooks were installed, and pid notes are identity-checked before they are trusted, because pids get reused and the state directory outlives a reboot. Surviving a reboot is safe rather than merely tolerable: a pid note is believed only when that pid is alive, still an agent binary, *and* started at the moment the note recorded — so a pid recycled onto a different agent process is rejected rather than mistaken for the original — a heartbeat older than `AGT_HB_STALE_SECS` reads as no heartbeat, and a turn-start stamp from last week loses to any turn-end stamp after it. Stale state fails closed, so there is nothing to clean up between sessions or after a crash. The sweeper still prunes its own directory at the start of each pass, deleting state files older than a week: correctness does not need it, but a machine that opens sessions for a year should not accumulate a file per session per kind forever. The same pass collects the temporary map file a sweep killed with `SIGKILL` leaves behind before its cleanup trap can run. + +Stuck detection is deliberately narrow. A row that claims to be working while the scan finds nothing running under it and the transcript has not moved for `AGT_STALL_SECS` is wedged, and gets the stuck glyph to summon you; any resumed write or turn boundary clears it on the next pass. An earlier version tried to detect this from the agent process's CPU time and it does not work — measured over seconds, a wedged agent is indistinguishable from a healthy idle one, because its runtime timers keep ticking. The transcript it stats is the `transcript_path` Claude Code puts in the hook payload, which `session-note.sh` records on the way past: a documented field handed to the hook, not a file path guessed from an internal directory layout, so this costs the recipe no dependency beyond the wrapper shape already named above. + +## Limits + +The sweeper writes a status for every session in every open window, so it will overwrite a glyph set by hand, by another recipe, or by a tool that pushes status for its own reasons — including clearing it to `idle` when nothing backs it. It closes nothing, kills nothing and touches no session content; the only thing it changes is the glyph. Its state lives under your home directory, readable by you rather than by everyone on the machine, and holds session ids, agent pids and the path of each session's transcript file — paths that name the directories you work in. + +- **The classifier depends on an undocumented Claude Code implementation detail.** It discriminates tool subtrees by the `zsh -c source …/shell-snapshots/snapshot-….sh` wrapper Claude Code puts around every tool command. That shape is not part of any documented interface and is free to change between Claude Code versions. It was verified against Claude Code 2.1.235. If a later version drops or renames the wrapper, no subtree is ever recognized: turn-end falls back to the stock `completed --auto-reset`, and the sweeper never re-lights a row — the failure is silent and looks exactly like "nothing was running". +- **Leaves are classified by shape, so some commands are read wrong.** `rg` over a large tree is counted as a log watcher and reads as waiting although it is doing real work, and `tail -f` on a build log reads as a watcher when it is the only thing you are waiting on. A shell script that spends its time in `git`, `curl` or `jq` reads as a poll loop. The lists are in `work-scan.sh` and are meant to be edited for the commands you actually run. +- **Sub-state colors and shapes are per-call overrides, and per-call beats Settings.** While one of these statuses stands, the silhouette you configured in Settings ▸ Agent Status for that state is not what you see. Change the shape variables if that collides with your own vocabulary. +- **A worker agent spawned inside a session inherits the session's `AGTERM_*` environment**, so everything keyed by session id is written by the worker as if it were the session. Its hooks post status against the *spawner's* row; its `session-note.sh` overwrites the spawner's pid note and transcript note with its own, and stamps a turn start the spawner never took; and its `turn-end-status.sh` classifies the *worker's* process tree and paints that answer onto the spawner's row. The pid note repairs itself on the next sweep, because discovery prefers a pane-attached process, but the turn stamps and the transcript note do not — they stay wrong until the spawner's own next turn rewrites them, and in between the sweeper can read the spawner's turn as live when it is not. If you dispatch headless agents from inside a session, this is the cost. The underlying inheritance is a defect in the stock hook package, under discussion in [agterm discussion #456](https://github.com/umputun/agterm/discussions/456), not something this recipe fixes. +- **Which process the sweeper takes for a session's agent is decided by pid order.** A session is matched to an agent by scanning process environments for `AGTERM_SESSION_ID`, and a worker spawned inside the session inherits it. Workers are normally skipped because they have no controlling terminal, but one started under `script`, `expect` or `unbuffer` has one, and then two processes claim the same session. The lower pid wins, which is the pane's own agent in every ordinary case since it existed before it spawned anything; a pty-wrapped worker that somehow predates it would win instead, and the row would then be classified from the worker's process tree. +- **Stuck detection only fires when nothing is running under the agent.** A session wedged while it still holds a live tool subtree is never flagged, and a session whose dispatched workers are making progress while it makes none is deliberately not flagged either. It also depends on the transcript path from the hook payload, so a session that started before the hooks were installed is never flagged. +- **A tool subtree that neither burns CPU nor looks like a wait can read as nothing during a sweep** — an I/O-bound download or an idle-but-alive server. The turn-end classifier paints it as machinery; the sweeper, which needs CPU evidence, may leave the row dark until the next turn boundary. +- **The sweeper is only as good as the stamps in its state directory.** Sessions that were already running when you installed the recipe, or a state directory you cleared, have no turn stamps and no heartbeat, so the sweeper cannot tell "this session's own turn is producing" from "this session is sitting on a background wait" and will prefer the work glyph over the pulse until the session's next turn writes its stamps. Nothing is wrong afterwards; it is the first pass over an old session that reads oddly. +- **Non-Claude agents are recognized as workers but not analyzed.** Another agent binary found under a wrapper counts as a running worker, but a Codex or Kimi session's own tool commands do not run under this wrapper, so their sessions get no classification of their own. +- **A long-running foreground tool call and a hung one look alike** while the call is in flight: both leave a live subtree and a quiet transcript. Only the empty-subtree case is called stuck, so a wedged command is your call to make, not the sidebar's. diff --git a/cookbook/truthful-agent-lights/lights-common.sh b/cookbook/truthful-agent-lights/lights-common.sh new file mode 100644 index 00000000..ed8dcb76 --- /dev/null +++ b/cookbook/truthful-agent-lights/lights-common.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# lights-common.sh — settings and helpers shared by the truthful-agent-lights +# scripts. Sourced, never executed: every value below is a variable you can +# override in the environment of the hooks and of the sweeper, and both sides +# must agree on AGT_LIGHTS_STATE or the sweeper reads no stamps at all. +# +# Colors and shapes are per-call overrides. A per-call override BEATS whatever +# you picked in Settings ▸ Agent Status, so if a shape here collides with your +# own vocabulary, change it here rather than in Settings. + +# the CLI that talks to the control socket +AGTERMCTL=${AGTERMCTL:-agtermctl} + +# Where the pid notes, turn stamps and heartbeats live. The hooks and the +# sweeper must resolve this to the SAME directory or the sweeper reads no +# stamps at all and reports every session as idle. +# +# XDG_STATE_HOME is ignored deliberately, and this is the reason: the hooks run +# inside your shell, where an XDG_STATE_HOME exported from a shell rc is set, +# while the sweeper runs from launchd, which starts with no such environment. +# Honoring it would put the two halves in two different directories on exactly +# the machines that set it — the hooks writing stamps nobody reads, the sweeper +# concluding that live sessions are dead. A path off $HOME is the same path in +# both. Override AGT_LIGHTS_STATE if you must, and then set it in both places. +AGT_LIGHTS_STATE=${AGT_LIGHTS_STATE:-$HOME/.local/state/agterm-lights} + +# the stock status script the hooks package installs. When it is present the +# recipe posts through it, so socket, pane and pane-id handling stay upstream's; +# when it is not, the fallback below calls agtermctl directly. +AGT_STATUS_SCRIPT=${AGT_STATUS_SCRIPT:-$HOME/.config/agterm/agent-status/agterm-agent-status.sh} + +# extended regex of agent binaries, matched against the WHOLE command name: +# every alternative is an exact basename, not a prefix. Add yours as its own +# alternative (`claude|codex|my-agent-wrapper`) rather than relying on a prefix +# to cover it — a prefix would also swallow ordinary commands that merely start +# the same way, and a process wrongly read as an agent makes a row claim a +# worker that does not exist. +AGT_AGENT_PATTERN=${AGT_AGENT_PATTERN:-claude|codex|kimi|opencode|pi} + +# The work tint, and the silhouette per sub-state. These six take the `${VAR-…}` +# form on purpose, not `${VAR:-…}`: setting one to the empty string is how you +# say "post this state with no override of my own", and a `:-` default would +# quietly hand the default back instead of honoring that. +AGT_WORK_COLOR=${AGT_WORK_COLOR-#4A9EFF} +AGT_STUCK_COLOR=${AGT_STUCK_COLOR-#FF3B30} +AGT_SHAPE_RUNNING=${AGT_SHAPE_RUNNING-square} # machinery is executing +AGT_SHAPE_MIXED=${AGT_SHAPE_MIXED-diamond} # executing AND queued +AGT_SHAPE_QUEUED=${AGT_SHAPE_QUEUED-triangle} # only waiting for a slot +AGT_SHAPE_STUCK=${AGT_SHAPE_STUCK-star} # claims to run, makes no progress + +# timings, all seconds +AGT_HB_FRESH_SECS=${AGT_HB_FRESH_SECS:-300} # a hook fired this recently: hands off +AGT_HB_STALE_SECS=${AGT_HB_STALE_SECS:-1500} # no hook this long: the glyph is unbacked +AGT_OWN_LIVE_SECS=${AGT_OWN_LIVE_SECS:-900} # cap on "my own turn is live" without writes +AGT_STALL_SECS=${AGT_STALL_SECS:-1500} # running claim, no transcript progress: stuck +AGT_SSH_WORK_SECS=${AGT_SSH_WORK_SECS:-120} # ssh older than this is a run, not a probe + +agt_active_args() { # color shape [extra flags…] -> fills AGT_STATUS_ARGS + # Blank a color or shape variable to mean "post this state without that + # override", so the glyph falls back to your Settings ▸ Agent Status choice. + # The empty value must never reach the CLI: agtermctl rejects `--shape ""`, + # and the status call swallows its own errors, so it would fail invisibly. + local color=$1 shape=$2 + shift 2 + AGT_STATUS_ARGS=(active "$@") + [ -n "$color" ] && AGT_STATUS_ARGS+=(--color "$color") + [ -n "$shape" ] && AGT_STATUS_ARGS+=(--shape "$shape") + return 0 +} + +agt_pid_start() { # pid -> its start time as one normalized line, empty when gone + ps -o lstart= -p "$1" 2>/dev/null | tr -s ' ' | sed 's/^ *//; s/ *$//' +} + +agt_write_pid_note() { # note-path pid — records the pid AND when it started + # The start time is what makes the note safe to believe later: pids are + # recycled, and a recycled one can land on another agent process, which no + # name check can tell apart from the original. + printf '%s %s\n' "$2" "$(agt_pid_start "$2")" > "$1" 2>/dev/null || true +} + +agt_note_pid() { # note-path -> the pid it records, or nothing + local line + line=$(cat "$1" 2>/dev/null) || return 1 + [ -n "$line" ] || return 1 + printf '%s' "${line%% *}" +} + +agt_note_is_live() { # note-path -> 0 when that exact process is still running + # pid alive, still an agent binary, and started when the note says it did + local line pid start + line=$(cat "$1" 2>/dev/null) || return 1 + pid=${line%% *} + start=${line#* } + [ -n "$pid" ] || return 1 + case "$pid" in *[!0-9]*) return 1 ;; esac + agt_is_agent_name "$(agt_base_name "$(ps -o command= -p "$pid" 2>/dev/null)")" || return 1 + [ "$start" = "$line" ] && return 0 # note predates start-time recording + [ "$start" = "$(agt_pid_start "$pid")" ] +} + +agt_state_dir() { # ensure and echo a state subdirectory + mkdir -p "$AGT_LIGHTS_STATE/$1" 2>/dev/null || true + printf '%s\n' "$AGT_LIGHTS_STATE/$1" +} + +agt_is_agent_name() { # command name -> 0 when it is an agent binary + # anchored at both ends: `pi` must not match `ping`, `pip` or `pipx` + printf '%s' "$1" | grep -qE "^($AGT_AGENT_PATTERN)$" +} + +agt_base_name() { # argv string -> bare command name, unwrapping ps's (parens) + local b=${1%% *} + b=${b##*/}; b=${b#\(}; b=${b%\)} + printf '%s' "$b" +} + +agt_find_agent_pid() { # walk up from $1 (default $PPID) to the agent process + local p=${1:-$PPID} cmd + local _ + for _ in 1 2 3 4 5 6; do + [ -n "$p" ] && [ "$p" -gt 1 ] 2>/dev/null || break + cmd=$(ps -o command= -p "$p" 2>/dev/null) || break + if agt_is_agent_name "$(agt_base_name "$cmd")"; then + printf '%s\n' "$p" + return 0 + fi + p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ') + done + return 1 +} diff --git a/cookbook/truthful-agent-lights/machinery-paint.sh b/cookbook/truthful-agent-lights/machinery-paint.sh new file mode 100755 index 00000000..d55349c2 --- /dev/null +++ b/cookbook/truthful-agent-lights/machinery-paint.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +# machinery-paint.sh — PreToolUse[Bash] hook: paint the machinery glyph when +# the command about to run is test- or build-shaped, so half an hour of CI +# reads differently from the model thinking. The PostToolUse hook restores the +# pulse when the command returns. +# +# Optional: the recipe works without it, you just do not see machinery until +# the turn ends. +# +# Override AGT_MACHINERY_PATTERN with your own extended regex to match the +# commands you actually wait on. +# +# MUST always exit 0: a non-zero PreToolUse exit blocks the tool call. +set -u +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lights-common.sh +. "$DIR/lights-common.sh" + +[ -n "${AGTERM_SESSION_ID:-}" ] || exit 0 +command -v jq >/dev/null 2>&1 || exit 0 +# the payload arrives on stdin; run by hand from a terminal there is none, and +# the read below would block forever — which for a PreToolUse hook means a +# wedged tool call, not a missed glyph +[ -t 0 ] && exit 0 + +AGT_MACHINERY_PATTERN=${AGT_MACHINERY_PATTERN:-'(^|[ /;&|(])(pytest|vitest|jest|playwright|tox |go test|cargo (test|nextest|build)|npm (run )?(test|check|build)|pnpm (run )?(test|check|build)|yarn test|make (test|check|build)|ctest|swift test|xcodebuild|mix test|rspec|bun test|deno test|dotnet test|mvn (test|verify)|gradlew (test|check|build)|just test)'} + +cmd=$(jq -r '.tool_input.command // empty' 2>/dev/null) || exit 0 +[ -n "$cmd" ] || exit 0 + +if printf '%s' "$cmd" | grep -qiE "$AGT_MACHINERY_PATTERN"; then + # through the shared builder like every other caller, so blanking a shape or + # color drops the flag instead of posting an empty value the CLI rejects + agt_active_args "$AGT_WORK_COLOR" "$AGT_SHAPE_RUNNING" + "$DIR/set-status.sh" "${AGT_STATUS_ARGS[@]}" +fi +exit 0 diff --git a/cookbook/truthful-agent-lights/session-note.sh b/cookbook/truthful-agent-lights/session-note.sh new file mode 100755 index 00000000..04f04634 --- /dev/null +++ b/cookbook/truthful-agent-lights/session-note.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# session-note.sh — SessionStart / UserPromptSubmit hook: note what the rest of +# the recipe needs to know about this session. +# +# Writes, under the state directory: +# pid/ the agent process id and its start time, so the +# classifier and the sweeper know which process tree +# to scan and can tell a recycled pid from the +# original +# transcript/ the transcript path from the hook payload, so the +# sweeper can measure progress +# turnstart/ touched on UserPromptSubmit only: a turn began +# +# $PPID is not reliably the agent — a hook can be invoked through an extra +# `zsh -c` layer — so this walks up until it finds an agent binary. +# +# Always exits 0, and prints nothing: Claude Code injects a hook's stdout into +# the prompt context. +set -u +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lights-common.sh +. "$DIR/lights-common.sh" + +[ -n "${AGTERM_SESSION_ID:-}" ] || exit 0 + +# the hook payload arrives as JSON on stdin: transcript_path binds this session +# to its transcript file (the agent does not hold it open, so nothing else can +# find it), hook_event_name tells a prompt from a session start +# Read the payload WHOLE. A cap here is not a safety measure: a payload longer +# than the cap parses as truncated JSON, jq returns nothing, and the turn-start +# stamp is never written — which makes the sweeper believe no turn is running +# and hand the row the false `completed` this recipe exists to prevent. A large +# prompt or a long transcript path is enough to reach that. +tp=""; ev="" +if [ ! -t 0 ] && command -v jq >/dev/null 2>&1; then + hj=$(cat) + tp=$(printf '%s' "$hj" | jq -r '.transcript_path // empty' 2>/dev/null) + ev=$(printf '%s' "$hj" | jq -r '.hook_event_name // empty' 2>/dev/null) +fi + +agent_pid=$(agt_find_agent_pid "$PPID") || exit 0 +[ -n "$agent_pid" ] || exit 0 + +agt_write_pid_note "$(agt_state_dir pid)/$AGTERM_SESSION_ID" "$agent_pid" +if [ -n "$tp" ]; then + printf '%s\n' "$tp" > "$(agt_state_dir transcript)/$AGTERM_SESSION_ID" 2>/dev/null || true +fi +if [ "$ev" = "UserPromptSubmit" ]; then + touch "$(agt_state_dir turnstart)/$AGTERM_SESSION_ID" 2>/dev/null || true +fi +exit 0 diff --git a/cookbook/truthful-agent-lights/set-status.sh b/cookbook/truthful-agent-lights/set-status.sh new file mode 100755 index 00000000..3ab19ac9 --- /dev/null +++ b/cookbook/truthful-agent-lights/set-status.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# set-status.sh — post one agent status for the current session, and leave a +# heartbeat behind so the sweeper can tell a live session from an abandoned +# glyph. +# +# set-status.sh active --blink +# set-status.sh active --color '#4A9EFF' --shape square +# set-status.sh completed --auto-reset +# +# Every argument is forwarded verbatim to `agtermctl session status`. Outside +# agterm this is a silent no-op, and it always exits 0: a hook that fails must +# never block the agent's turn. +set -u +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lights-common.sh +. "$DIR/lights-common.sh" + +[ -n "${AGTERM_SESSION_ID:-}" ] || exit 0 +[ "$#" -gt 0 ] || exit 0 + +# heartbeat: every status post marks this session as alive +hb=$(agt_state_dir hb) +touch "$hb/$AGTERM_SESSION_ID" 2>/dev/null || true + +# prefer the stock script the hooks package installs — socket, pane and +# pane-id handling then stay upstream's, and extra flags pass through +if [ -x "$AGT_STATUS_SCRIPT" ]; then + "$AGT_STATUS_SCRIPT" "$@" >/dev/null 2>&1 || true + exit 0 +fi + +state=$1 +shift +args=() +[ -n "${AGTERM_PANE:-}" ] && args+=(--pane "$AGTERM_PANE") +[ -n "${AGTERM_PANE_ID:-}" ] && args+=(--pane-id "$AGTERM_PANE_ID") +[ -n "${AGTERM_SOCKET:-}" ] && args+=(--socket "$AGTERM_SOCKET") + +"$AGTERMCTL" session status "$state" --target "$AGTERM_SESSION_ID" \ + "${args[@]+"${args[@]}"}" "$@" >/dev/null 2>&1 || true +exit 0 diff --git a/cookbook/truthful-agent-lights/status-sweep.sh b/cookbook/truthful-agent-lights/status-sweep.sh new file mode 100755 index 00000000..bc38397a --- /dev/null +++ b/cookbook/truthful-agent-lights/status-sweep.sh @@ -0,0 +1,349 @@ +#!/usr/bin/env bash +# status-sweep.sh — make the sidebar tell the truth in both directions, on a +# timer. A status is pushed and then trusted forever, so a killed agent leaves +# its last glyph pulsing and a session that quietly started working after its +# turn ended shows nothing at all. +# +# Run it from a LaunchAgent every couple of minutes (see the recipe's Setup). +# The invariant it maintains: no glyph means idle with nothing running, and a +# glyph that claims work is backed by a live process. +# +# claims active, agent dead, hooks long dark -> idle +# claims active, agent alive, nothing running -> completed, or idle when the +# hooks have gone dark +# claims active while a worker subtree lives -> active --blink +# claims active while machinery or a queue runs-> active, work color + shape +# claims active, no subtree, no progress -> active, stuck color + shape +# idle or completed while work is running -> re-lit to match the work +# blocked -> left alone while anything +# backs it; a dead agent with +# dark hooks clears it +# +# Reads only; the only thing it changes is the glyph. +set -u +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lights-common.sh +. "$DIR/lights-common.sh" + +AGT_LIGHTS_LOG=${AGT_LIGHTS_LOG:-$AGT_LIGHTS_STATE/sweep.log} +mkdir -p "$AGT_LIGHTS_STATE" 2>/dev/null || true +LOCK="$AGT_LIGHTS_STATE/sweep.lock" + +log() { printf '%s %s\n' "$(date '+%m-%d %H:%M:%S')" "$*" >> "$AGT_LIGHTS_LOG"; } + +# A scheduled job runs under launchd's PATH, not yours, so a Homebrew jq or an +# agtermctl that was never symlinked is simply absent here. Say so in the log: +# a sweeper that exits silently every two minutes looks exactly like a sweeper +# that has nothing to do. +if ! command -v "$AGTERMCTL" >/dev/null 2>&1 && [ ! -x "$AGTERMCTL" ]; then + log "no agtermctl on PATH as '$AGTERMCTL' (PATH=$PATH) — nothing done" + exit 0 +fi +if ! command -v jq >/dev/null 2>&1; then + log "no jq on PATH (PATH=$PATH) — nothing done" + exit 0 +fi + +# single-flight; break a lock older than ten minutes +if ! mkdir "$LOCK" 2>/dev/null; then + if [ -n "$(find "$LOCK" -maxdepth 0 -mmin +10 2>/dev/null)" ]; then + rmdir "$LOCK" 2>/dev/null || true + mkdir "$LOCK" 2>/dev/null || exit 0 + else + exit 0 + fi +fi +MAP=$(mktemp "$AGT_LIGHTS_STATE/agent-map.XXXXXX") || { rmdir "$LOCK" 2>/dev/null; exit 0; } +trap 'rm -f "$MAP"; rmdir "$LOCK" 2>/dev/null' EXIT + +if [ -f "$AGT_LIGHTS_LOG" ] && [ "$(wc -l < "$AGT_LIGHTS_LOG")" -gt 200 ]; then + tail -n 100 "$AGT_LIGHTS_LOG" > "$AGT_LIGHTS_LOG.tmp" && mv "$AGT_LIGHTS_LOG.tmp" "$AGT_LIGHTS_LOG" +fi + +# Keep the state directory from growing without end: one file per session per +# kind, and sessions come and go. Stale state already fails closed — a dead +# pid note, a week-old heartbeat and an ancient turn stamp all read as "no +# information" — so this prunes for tidiness rather than for correctness, and +# it also collects the agent-map temporaries a SIGKILLed sweep leaves behind +# before its trap can fire. +for sub in pid hb turnstart turnend transcript; do + [ -d "$AGT_LIGHTS_STATE/$sub" ] && + find "$AGT_LIGHTS_STATE/$sub" -type f -mtime +7 -delete 2>/dev/null +done +find "$AGT_LIGHTS_STATE" -maxdepth 1 -type f -name 'agent-map.*' -mtime +1 -delete 2>/dev/null + +now=$(date +%s) + +# ---- discovery: session id -> agent pid, for every pane-attached agent ---- +# Every agent started inside a session carries AGTERM_SESSION_ID in its +# environment, so this covers sessions that predate the hooks and repairs a pid +# note that was written by something else. +# the pattern travels through the environment, not through -v: awk interprets +# escapes in a -v value, so a reader's escaped metacharacter would compile +# differently here than it does in the grep that reads the same variable +ps -axo pid=,tty=,command= | + AGT_AGENT_PATTERN="$AGT_AGENT_PATTERN" awk ' + BEGIN { re = "^(" ENVIRON["AGT_AGENT_PATTERN"] ")$" } + { b = $3; sub(/.*\//, "", b); sub(/^\(/, "", b); sub(/\)$/, "", b) + if (b ~ re && $2 != "??") print $1 }' | + sort -n | + while read -r cand; do + # pgrep cannot read another process's environment; `ps eww` is the only + # way to ask which session a running agent belongs to + # shellcheck disable=SC2009 + sid=$(ps eww "$cand" 2>/dev/null | grep -o 'AGTERM_SESSION_ID=[A-Za-z0-9-]*' | head -1) + [ -n "$sid" ] && printf '%s %s\n' "${sid#AGTERM_SESSION_ID=}" "$cand" + # candidates arrive lowest pid first, so when two processes claim one + # session the older one — the pane's own agent, which existed before it + # spawned anything — wins the dedupe below + done | awk '!seen[$1]++' > "$MAP" + +pid_dir=$(agt_state_dir pid) +while read -r msid mpid; do + [ -n "$msid" ] && agt_write_pid_note "$pid_dir/$msid" "$mpid" +done < "$MAP" + +set_status() { # session-id pane args… + local sid=$1 pane=$2 + shift 2 + if [ -n "$pane" ]; then + "$AGTERMCTL" session status "$@" --target "$sid" --pane "$pane" >/dev/null 2>&1 || true + else + "$AGTERMCTL" session status "$@" --target "$sid" >/dev/null 2>&1 || true + fi +} + +file_age() { # path -> seconds since mtime, or a huge number when unreadable + local m + m=$(stat -f %m "$1" 2>/dev/null) || { echo 999999; return; } + echo $((now - m)) +} + +hb_age() { file_age "$AGT_LIGHTS_STATE/hb/$1"; } + +pid_is_agent() { # pid -> 0 when alive and still an agent binary (pids get reused) + local c + c=$(ps -o command= -p "$1" 2>/dev/null) || return 1 + agt_is_agent_name "$(agt_base_name "$c")" +} + +own_turn_live() { # session-id -> 0 when a turn started after the last turn end + # Transcript mtime alone cannot decide this: the harness keeps appending + # after a turn ends, which held a false "still working" glyph for minutes. + # The stamps decide, and the transcript only caps the claim, so an + # interrupted turn that never fired Stop cannot hold it once writes stop. + local ts="$AGT_LIGHTS_STATE/turnstart/$1" te="$AGT_LIGHTS_STATE/turnend/$1" + local tf="$AGT_LIGHTS_STATE/transcript/$1" + local sm em tp tm + [ -f "$ts" ] || return 1 + sm=$(stat -f %m "$ts" 2>/dev/null) || return 1 + em=0 + [ -f "$te" ] && { em=$(stat -f %m "$te" 2>/dev/null) || em=0; } + [ "$sm" -gt "$em" ] || return 1 + tp="" + [ -f "$tf" ] && tp=$(cat "$tf" 2>/dev/null) + if [ -n "$tp" ] && [ -f "$tp" ]; then + tm=$(stat -f %m "$tp" 2>/dev/null) || return 1 + [ $((now - tm)) -lt "$AGT_OWN_LIVE_SECS" ] + else + [ $((now - sm)) -lt "$AGT_OWN_LIVE_SECS" ] + fi +} + +transcript_stalled() { # session-id -> 0 when the transcript is known and cold + local tf="$AGT_LIGHTS_STATE/transcript/$1" tp + [ -f "$tf" ] || return 1 + tp=$(cat "$tf" 2>/dev/null) + [ -n "$tp" ] && [ -f "$tp" ] || return 1 + [ "$(file_age "$tp")" -ge "$AGT_STALL_SECS" ] +} + +scan_counts() { # agent pid -> fills SC_*, returns 1 when the scan is unusable + local out counts line + out=$(AGT_SCAN_PIDS=1 "$DIR/work-scan.sh" "$1" "$$" 2>/dev/null) + counts=${out%%$'\n'*} + case "$counts" in agents=*machinery=*waiting=*remote=*watch=*) ;; *) return 1 ;; esac + SC_AGENTS=${counts#agents=}; SC_AGENTS=${SC_AGENTS%% *} + SC_MACHINERY=${counts#*machinery=}; SC_MACHINERY=${SC_MACHINERY%% *} + SC_WAITING=${counts#*waiting=}; SC_WAITING=${SC_WAITING%% *} + SC_REMOTE=${counts#*remote=}; SC_REMOTE=${SC_REMOTE%% *} + SC_WATCH=${counts#*watch=}; SC_WATCH=${SC_WATCH%% *} + case "$SC_AGENTS$SC_MACHINERY$SC_WAITING$SC_REMOTE$SC_WATCH" in *[!0-9]*) return 1 ;; esac + SC_PIDS="" + while IFS= read -r line; do + case "$line" in pids=*) SC_PIDS=${line#pids=} ;; esac + done <<<"$out" + return 0 +} + +cpu_centis() { # pid list -> summed cpu time in centiseconds + local list + list=$(printf '%s' "$1" | tr ' ' ',' | sed 's/^,*//; s/,*$//') + [ -n "$list" ] || { echo 0; return; } + ps -o cputime= -p "$list" 2>/dev/null | awk ' + { n = split($1, t, ":"); s = 0; for (i = 1; i <= n; i++) s = s * 60 + t[i]; total += s } + END { printf "%d", total * 100 }' +} + +subtree_busy() { # -> 0 when the machinery subtrees are actually computing + [ -n "$SC_PIDS" ] || return 1 + local t1 t2 + t1=$(cpu_centis "$SC_PIDS") + sleep 1.5 + t2=$(cpu_centis "$SC_PIDS") + [ $((t2 - t1)) -ge 15 ] # 0.15s of cpu over 1.5s is work, not a poll loop + # SC_PIDS holds only the machinery subtrees, which is exactly what this + # gates: a watcher burning cpu elsewhere must not vouch for idle machinery +} + +work_state() { # -> running | mixed | queued for the scanned counts, or nothing + # A remote run counts as running on sight; local machinery has to prove it + # burns cpu, because a subtree can look busy while it only waits. + # The state, not the shape: a reader who blanks a shape variable to fall back + # on their Settings silhouette must still get the state recognized. + local run=$SC_REMOTE + if [ "$SC_MACHINERY" -gt 0 ] && subtree_busy; then run=$((run + SC_MACHINERY)); fi + if [ "$run" -gt 0 ] && [ "$SC_WAITING" -gt 0 ]; then printf mixed + elif [ "$run" -gt 0 ]; then printf running + elif [ $((SC_WAITING + SC_WATCH)) -gt 0 ]; then printf queued + fi +} + +shape_for() { # work state -> the shape configured for it, empty when blanked + case "$1" in + running) printf '%s' "$AGT_SHAPE_RUNNING" ;; + mixed) printf '%s' "$AGT_SHAPE_MIXED" ;; + queued) printf '%s' "$AGT_SHAPE_QUEUED" ;; + esac +} + +windows=$("$AGTERMCTL" window list --json 2>/dev/null | jq -r '.result.windows[]? | select(.open) | .id') || exit 0 + +for w in $windows; do + "$AGTERMCTL" tree --window "$w" --json 2>/dev/null | jq -c ' + .result.tree.workspaces[]?.sessions[]? | + {id, status: (.status // "idle"), + pane: (.statusPane // ""), + blink: (.statusBlink // false), + shape: (.statusShape // ""), + color: (.statusColor // ""), + fg: ((.foreground // [])[0] // ""), + sfg: ((.splitForeground // [])[0] // "")}' | + while IFS= read -r row; do + sid=$(jq -r .id <<<"$row") + status=$(jq -r .status <<<"$row") + pane=$(jq -r .pane <<<"$row") + blink=$(jq -r .blink <<<"$row") + shape_now=$(jq -r .shape <<<"$row") + color_now=$(jq -r .color <<<"$row") + fg=$(jq -r .fg <<<"$row") + sfg=$(jq -r .sfg <<<"$row") + [ -n "$sid" ] || continue + + # a pid straight from discovery is live by construction; one read back from + # a note has to prove it is still the same process, not a recycled pid that + # happens to be another agent + pid=$(awk -v s="$sid" '$1 == s { print $2; exit }' "$MAP") + alive=0; knew_pid=0 + if [ -n "$pid" ]; then + knew_pid=1 + pid_is_agent "$pid" && alive=1 + elif [ -f "$AGT_LIGHTS_STATE/pid/$sid" ]; then + # this session had an agent once; whether it still does is the question + knew_pid=1 + if agt_note_is_live "$AGT_LIGHTS_STATE/pid/$sid"; then + pid=$(agt_note_pid "$AGT_LIGHTS_STATE/pid/$sid") + alive=1 + fi + fi + + case "$status" in + blocked) + # blocked means a human was asked something: leave it alone while + # anything backs it, and clear it only once the agent is gone and the + # hooks have been silent for a long time + if [ "$knew_pid" -eq 1 ] && [ "$alive" -eq 0 ] && [ "$(hb_age "$sid")" -gt "$AGT_HB_STALE_SECS" ]; then + set_status "$sid" "$pane" idle + log "dead blocked $sid -> idle" + fi + ;; + active) + if [ "$alive" -eq 1 ] && scan_counts "$pid"; then + state=""; shape="" + if [ "$SC_AGENTS" -eq 0 ]; then state=$(work_state); shape=$(shape_for "$state"); fi + total=$((SC_AGENTS + SC_MACHINERY + SC_WAITING + SC_REMOTE + SC_WATCH)) + if [ "$SC_AGENTS" -gt 0 ]; then + if [ "$shape_now" = "$AGT_SHAPE_STUCK" ]; then + set_status "$sid" "$pane" active --blink + log "worker progress $sid -> pulse" + fi + elif own_turn_live "$sid"; then + if [ "$blink" != "true" ] || [ "$shape_now" = "$AGT_SHAPE_STUCK" ]; then + set_status "$sid" "$pane" active --blink + log "own turn live $sid -> pulse" + fi + elif [ -n "$state" ]; then + # only post when the row is not already showing exactly this. A + # repaint is not free: it would also clear a pulse the row is + # carrying, so a blinking row is repainted even when the shape and + # tint already match. + if [ "$shape_now" = "$shape" ] && [ "$color_now" = "$AGT_WORK_COLOR" ] && + [ "$blink" != "true" ]; then + : # already honest; leave it alone + else + agt_active_args "$AGT_WORK_COLOR" "$shape" + set_status "$sid" "$pane" "${AGT_STATUS_ARGS[@]}" + log "work $sid (m=$SC_MACHINERY r=$SC_REMOTE q=$SC_WAITING w=$SC_WATCH) -> $state" + fi + elif [ "$blink" = "true" ] && [ "$total" -eq 0 ] && transcript_stalled "$sid"; then + # it claims to be working, nothing is running under it, and the + # transcript has not moved: wedged, and worth summoning you + if [ "$shape_now" != "$AGT_SHAPE_STUCK" ] || [ "$color_now" != "$AGT_STUCK_COLOR" ]; then + agt_active_args "$AGT_STUCK_COLOR" "$AGT_SHAPE_STUCK" --blink + set_status "$sid" "$pane" "${AGT_STATUS_ARGS[@]}" + log "stuck $sid (running claim, no progress) -> stuck glyph" + fi + elif [ -f "$AGT_LIGHTS_STATE/turnstart/$sid" ] && [ -f "$AGT_LIGHTS_STATE/turnend/$sid" ] && + [ "$(stat -f %m "$AGT_LIGHTS_STATE/turnend/$sid" 2>/dev/null || echo 0)" -gt \ + "$(stat -f %m "$AGT_LIGHTS_STATE/turnstart/$sid" 2>/dev/null || echo 1)" ]; then + # the stamps prove no turn is running and the scan found nothing: + # the background work this glyph stood for has drained + set_status "$sid" "$pane" completed --auto-reset + log "work drained $sid -> completed" + elif [ "$(hb_age "$sid")" -gt "$AGT_HB_STALE_SECS" ]; then + set_status "$sid" "$pane" idle + log "idle at prompt $sid -> idle" + fi + elif [ "$alive" -eq 0 ]; then + age=$(hb_age "$sid") + if [ "$age" -lt "$AGT_HB_FRESH_SECS" ]; then + : # a hook fired moments ago: an agent we cannot see yet is alive + elif agt_is_agent_name "$(agt_base_name "$fg")" || + agt_is_agent_name "$(agt_base_name "$sfg")"; then + : # an agent is visibly in the pane; its own hooks will take over + elif [ "$age" -gt "$AGT_HB_STALE_SECS" ]; then + set_status "$sid" "$pane" idle + log "unbacked active $sid (dark ${age}s) -> idle" + fi + fi + ;; + idle|completed) + # the other direction: work is running but the row shows done, or nothing + if [ "$alive" -eq 1 ] && scan_counts "$pid"; then + state=""; shape="" + if [ "$SC_AGENTS" -eq 0 ]; then state=$(work_state); shape=$(shape_for "$state"); fi + if [ "$SC_AGENTS" -gt 0 ]; then + set_status "$sid" "$pane" active --blink + log "silent workers $sid (agents=$SC_AGENTS) -> pulse" + elif [ -n "$state" ]; then + agt_active_args "$AGT_WORK_COLOR" "$shape" + set_status "$sid" "$pane" "${AGT_STATUS_ARGS[@]}" + log "silent work $sid (m=$SC_MACHINERY r=$SC_REMOTE q=$SC_WAITING w=$SC_WATCH) -> $state" + fi + fi + ;; + esac + done +done +exit 0 diff --git a/cookbook/truthful-agent-lights/turn-end-status.sh b/cookbook/truthful-agent-lights/turn-end-status.sh new file mode 100755 index 00000000..c659755c --- /dev/null +++ b/cookbook/truthful-agent-lights/turn-end-status.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# turn-end-status.sh — Stop hook: end the turn on the glyph the session +# deserves, instead of an unconditional completed. +# +# turn-end-status.sh completed --auto-reset +# +# The arguments are the fallback: they are used only when the scan proves +# nothing is left running. Otherwise: +# +# a dispatched worker or subagent still alive -> active --blink +# machinery running AND something queued -> active, work color, mixed shape +# machinery running -> active, work color, running shape +# only wait-shaped subtrees (locks, monitors) -> active, work color, queued shape +# the scan failed while an agent pid is known -> active --blink +# +# The last line is deliberate: an unknown state must never read as done. A +# stale "something is running" costs you a glance; a false green costs you the +# work you thought had finished. +# +# Always exits 0 and prints nothing. +set -u +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lights-common.sh +. "$DIR/lights-common.sh" + +[ -n "${AGTERM_SESSION_ID:-}" ] || exit 0 + +# stamp the turn end: the sweeper compares this against the turn-start stamp to +# tell a session that is producing from one that is merely holding a wait +touch "$(agt_state_dir turnend)/$AGTERM_SESSION_ID" 2>/dev/null || true + +# find the agent process: walk up from the hook shell, fall back to the note +agent_pid=$(agt_find_agent_pid "$PPID" || true) +note="$AGT_LIGHTS_STATE/pid/$AGTERM_SESSION_ID" +if [ -z "$agent_pid" ] && [ -f "$note" ] && agt_note_is_live "$note"; then + # the note is believed only when that exact process — same pid, same start + # time, still an agent — is running; a recycled pid would classify a stranger + agent_pid=$(agt_note_pid "$note") +fi + +agents=""; machinery=""; waiting=""; remote=""; watch="" +if [ -n "$agent_pid" ]; then + scan=$("$DIR/work-scan.sh" "$agent_pid" "$$" 2>/dev/null | head -1) + case "$scan" in + agents=*machinery=*waiting=*remote=*watch=*) + agents=${scan#agents=}; agents=${agents%% *} + machinery=${scan#*machinery=}; machinery=${machinery%% *} + waiting=${scan#*waiting=}; waiting=${waiting%% *} + remote=${scan#*remote=}; remote=${remote%% *} + watch=${scan#*watch=}; watch=${watch%% *} + ;; + esac + case "$agents$machinery$waiting$remote$watch" in + ''|*[!0-9]*) + # the scan said nothing usable while an agent pid is known: keep the + # pulse and let the sweeper sort it out on its next pass + exec "$DIR/set-status.sh" active --blink + ;; + esac +else + agents=0; machinery=0; waiting=0; remote=0; watch=0 +fi + +running=$((machinery + remote)) + +if [ "$agents" -gt 0 ]; then + exec "$DIR/set-status.sh" active --blink +fi +if [ "$running" -gt 0 ] && [ "$waiting" -gt 0 ]; then + agt_active_args "$AGT_WORK_COLOR" "$AGT_SHAPE_MIXED" + exec "$DIR/set-status.sh" "${AGT_STATUS_ARGS[@]}" +fi +if [ "$running" -gt 0 ]; then + agt_active_args "$AGT_WORK_COLOR" "$AGT_SHAPE_RUNNING" + exec "$DIR/set-status.sh" "${AGT_STATUS_ARGS[@]}" +fi +if [ $((waiting + watch)) -gt 0 ]; then + agt_active_args "$AGT_WORK_COLOR" "$AGT_SHAPE_QUEUED" + exec "$DIR/set-status.sh" "${AGT_STATUS_ARGS[@]}" +fi +exec "$DIR/set-status.sh" "$@" diff --git a/cookbook/truthful-agent-lights/work-scan.sh b/cookbook/truthful-agent-lights/work-scan.sh new file mode 100755 index 00000000..b27b2ee3 --- /dev/null +++ b/cookbook/truthful-agent-lights/work-scan.sh @@ -0,0 +1,161 @@ +#!/usr/bin/env bash +# work-scan.sh — classify the live work under an agent process. +# +# work-scan.sh [caller-pid] +# +# Prints one line: "agents=N machinery=M waiting=K remote=R watch=W". +# +# agents — a tool subtree containing another agent binary: a dispatched +# worker or subagent that is still running +# machinery — a tool subtree doing real work (tests, builds, a long-lived ssh +# that IS a remote run) +# waiting — a tool subtree whose every leaf is wait-shaped (sleep, flock, +# a poll loop, a short ssh probe), or that has no leaves at all +# remote — long-lived ssh/scp subtrees, counted separately so the caller +# can treat a remote run as running work +# watch — pure log-watcher pipelines (tail/grep), which accompany a real +# run and must not read as "queued for more" +# +# What makes this possible: Claude Code runs every tool command — foreground +# and background alike — under a recognizable wrapper shell, `zsh -c source +# …/shell-snapshots/snapshot-….sh && …`, as a child of the agent process, while +# MCP servers and harness helpers are direct children WITHOUT that wrapper. So +# "is a tool still running" is a question about wrapper subtrees, and the +# helpers never pollute the count. That wrapper shape is undocumented; see the +# recipe's Limits. +# +# ssh is disambiguated by age rather than by name: a reachability probe lives +# seconds, a remote build holds its pipe for minutes, so an ssh older than +# AGT_SSH_WORK_SECS counts as a run and a younger one as a wait. +# +# The caller's own process chain is excluded, so a hook never counts itself. +# Every ancestry walk is hop-capped: a cyclic ps row must spoil a count, never +# hang the hook. +# +# Set AGT_SCAN_PIDS=1 to get a second line, "pids=…", listing the members of +# the MACHINERY subtrees only — the caller can then measure whether the work it +# is about to report is really computing. Waiters and log watchers are left out +# on purpose: an `rg` burning cpu inside a watcher subtree would otherwise vouch +# for machinery that is doing nothing. +# Set AGT_SCAN_PS_FILE to a file of "pid ppid etime command" lines to classify +# canned input instead of live ps, which is how this is tested. +set -u +DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +# shellcheck source-path=SCRIPTDIR +# shellcheck source=lights-common.sh +. "$DIR/lights-common.sh" + +root=${1:?agent pid required} +self=${2:-$$} + +if [ -n "${AGT_SCAN_PS_FILE:-}" ]; then + ps_out=$(cat "$AGT_SCAN_PS_FILE") +else + ps_out=$(ps -axo pid=,ppid=,etime=,command=) +fi + +# AGT_AGENT_PATTERN travels through the environment rather than -v, because awk +# interprets escape sequences in a -v value: a reader who escapes a regex +# metacharacter would get a different pattern here than in the grep that reads +# the same variable. The numeric -v values have nothing to escape. +printf '%s\n' "$ps_out" | + AGT_AGENT_PATTERN="$AGT_AGENT_PATTERN" awk \ + -v root="$root" -v self="$self" -v sshwork="$AGT_SSH_WORK_SECS" ' +BEGIN { agentre = "^(" ENVIRON["AGT_AGENT_PATTERN"] ")$" } +function base(pid, b) { + b = command[pid]; sub(/ .*/, "", b); sub(/.*\//, "", b) + sub(/^\(/, "", b); sub(/\)$/, "", b) # ps prints (name) when argv is unreadable + return b +} +function etime_secs(e, d, parts, hms, n, s, i) { + d = 0 + if (e ~ /-/) { split(e, parts, "-"); d = parts[1]; e = parts[2] } + n = split(e, hms, ":"); s = 0 + for (i = 1; i <= n; i++) s = s * 60 + hms[i] + return d * 86400 + s +} +{ + pid = $1; ppid = $2; et = $3; cmd = "" + for (i = 4; i <= NF; i++) cmd = cmd (i > 4 ? " " : "") $i + parent[pid] = ppid; command[pid] = cmd; age[pid] = etime_secs(et) +} +END { + CAP = 256 # hop cap on every upward walk + + # mark the caller chain so the hook never counts itself as work + p = self; hops = 0 + while (p in parent && p != root && hops++ < CAP) { excluded[p] = 1; p = parent[p] } + + # the tool-wrapper shells that sit under the agent process + for (pid in parent) { + if (command[pid] !~ /shell-snapshots\/snapshot-/) continue + if (pid in excluded) continue + q = pid; under = 0; hops = 0 + while (q in parent && hops++ < CAP) { + if (q in excluded) break + if (parent[q] == root) { under = 1; break } + q = parent[q] + } + if (under) wrappers[pid] = 1 + } + + # attribute every other process to its nearest wrapper ancestor + for (pid in parent) { + if (pid in wrappers) continue + q = parent[pid]; hops = 0; w = "" + while (q in parent && hops++ < CAP) { + if (q in wrappers) { w = q; break } + q = parent[q] + } + if (w == "") continue + b = base(pid) + # exact basename, both ends anchored: a prefix match would read `ping` and + # `pip` as agents and pulse the row for a worker that is not there + if (b ~ agentre) { agentwrap[w] = 1; continue } + if (b ~ /^(zsh|bash|sh|dash|-zsh|-bash)$/) continue # structure + if (b ~ /^(ssh|scp)$/) { + if (age[pid] >= sshwork) remotework[w] = 1; else waitq[w] = 1 + continue + } + if (b ~ /^(tail|tee|cat|grep|egrep|fgrep|zgrep|ugrep|ggrep|rg)$/) { + watch[w] = 1; continue # log watcher + } + if (b ~ /^(sleep|flock|inotifywait|fswatch|wait4path|caffeinate|pgrep|git|gh|jq|curl|date|stat|cut|tr|sort|head|wc|sed|awk|uname|hostname|ping|timeout|gtimeout)$/) { + waitq[w] = 1; continue # queue or probe + } + realwork[w] = 1 # actual work + } + + agents = 0; machinery = 0; waiting = 0; remote = 0; watching = 0 + for (w in wrappers) { + if (w in agentwrap) agents++ + else if (w in realwork) machinery++ + else if (w in remotework) remote++ + else if (w in waitq) waiting++ + else if (w in watch) watching++ + else waiting++ # no leaves at all: an idling loop shell + } + printf "agents=%d machinery=%d waiting=%d remote=%d watch=%d\n", \ + agents, machinery, waiting, remote, watching + + if (ENVIRON["AGT_SCAN_PIDS"] == "1") { + # machinery subtrees only: the same subtrees the machinery count is built + # from, so a cpu measurement over them answers exactly the question asked + out = "" + for (pid in parent) { + if (pid in wrappers) { + if ((pid in realwork) && !(pid in agentwrap)) out = out " " pid + continue + } + q = parent[pid]; hops = 0 + while (q in parent && hops++ < CAP) { + if (q in wrappers) { + if ((q in realwork) && !(q in agentwrap)) out = out " " pid + break + } + q = parent[q] + } + } + printf "pids=%s\n", out + } +}' From 3716c60194ed569c3048869b88ca3b78c2270065 Mon Sep 17 00:00:00 2001 From: x9x9x9x9x9x91 <131364915+x9x9x9x9x9x91@users.noreply.github.com> Date: Wed, 19 Aug 2026 10:53:11 +0200 Subject: [PATCH 2/2] docs(cookbook): unroll two A && B || C guards for CI's shellcheck The ubuntu runner's shellcheck flags SC2015 on these two lines; the local 0.11.0 and px 0.9.0 do not flag the unrolled form on any version tried. Behavior identical: each condition already meant break/return on failure. --- cookbook/truthful-agent-lights/lights-common.sh | 3 ++- cookbook/truthful-agent-lights/status-sweep.sh | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/cookbook/truthful-agent-lights/lights-common.sh b/cookbook/truthful-agent-lights/lights-common.sh index ed8dcb76..30783584 100644 --- a/cookbook/truthful-agent-lights/lights-common.sh +++ b/cookbook/truthful-agent-lights/lights-common.sh @@ -119,7 +119,8 @@ agt_find_agent_pid() { # walk up from $1 (default $PPID) to the agent process local p=${1:-$PPID} cmd local _ for _ in 1 2 3 4 5 6; do - [ -n "$p" ] && [ "$p" -gt 1 ] 2>/dev/null || break + [ -n "$p" ] || break + [ "$p" -gt 1 ] 2>/dev/null || break cmd=$(ps -o command= -p "$p" 2>/dev/null) || break if agt_is_agent_name "$(agt_base_name "$cmd")"; then printf '%s\n' "$p" diff --git a/cookbook/truthful-agent-lights/status-sweep.sh b/cookbook/truthful-agent-lights/status-sweep.sh index bc38397a..119440aa 100755 --- a/cookbook/truthful-agent-lights/status-sweep.sh +++ b/cookbook/truthful-agent-lights/status-sweep.sh @@ -155,7 +155,8 @@ transcript_stalled() { # session-id -> 0 when the transcript is known and cold local tf="$AGT_LIGHTS_STATE/transcript/$1" tp [ -f "$tf" ] || return 1 tp=$(cat "$tf" 2>/dev/null) - [ -n "$tp" ] && [ -f "$tp" ] || return 1 + [ -n "$tp" ] || return 1 + [ -f "$tp" ] || return 1 [ "$(file_age "$tp")" -ge "$AGT_STALL_SECS" ] }