Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cookbook/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
184 changes: 184 additions & 0 deletions cookbook/truthful-agent-lights/README.md

Large diffs are not rendered by default.

132 changes: 132 additions & 0 deletions cookbook/truthful-agent-lights/lights-common.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
#!/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" ] || 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"
return 0
fi
p=$(ps -o ppid= -p "$p" 2>/dev/null | tr -d ' ')
done
return 1
}
38 changes: 38 additions & 0 deletions cookbook/truthful-agent-lights/machinery-paint.sh
Original file line number Diff line number Diff line change
@@ -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
52 changes: 52 additions & 0 deletions cookbook/truthful-agent-lights/session-note.sh
Original file line number Diff line number Diff line change
@@ -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/<session-id> 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/<session-id> the transcript path from the hook payload, so the
# sweeper can measure progress
# turnstart/<session-id> 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
42 changes: 42 additions & 0 deletions cookbook/truthful-agent-lights/set-status.sh
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading