Skip to content
Merged
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 @@ -50,6 +50,7 @@ its *needs* column, so searching this page for `claude` or `kiro` finds those di
| [remote-claude-session](remote-claude-session/) | one chord runs Claude Code in a tmux session on a remote host, reconnecting and reporting status onto its own tab | 0.22.0, jq, ssh, tmux, Claude Code |
| [session-context-nudge](session-context-nudge/) | Claude Code keeps the title bar saying what the session is working on | 0.26.0, jq, Claude Code |
| [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 |
| [two-agent-chat](two-agent-chat/) | let Claude Code and Codex talk to each other in one split | 0.24.0, python3, Claude Code, Codex |

### Panes, pickers and input
Expand Down
192 changes: 192 additions & 0 deletions cookbook/truthful-agent-lights/README.md

Large diffs are not rendered by default.

149 changes: 149 additions & 0 deletions cookbook/truthful-agent-lights/lights-common.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/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 one is present the
# recipe posts through it, so socket, pane and pane-id handling stay upstream's;
# when none is, the fallback in set-status.sh calls agtermctl directly.
#
# Since agterm 0.26.0 the package wires the Claude hooks to
# agterm-claude-status.sh: an adapter that stays silent when the hook was fired
# by a worker agent spawned inside the session (a headless `claude -p` from a
# tool call inherits the spawner's AGTERM_* environment and would otherwise
# repaint the spawner's row) and hands every other post to agterm-agent-status.sh.
# Posting through the adapter keeps that guard; posting through the generic
# script would undo it. So the adapter is preferred when it is installed, and an
# older package without it falls back to the generic script. Set
# AGT_STATUS_SCRIPT to post through something else.
if [ -z "${AGT_STATUS_SCRIPT:-}" ]; then
AGT_STATUS_SCRIPT=$HOME/.config/agterm/agent-status/agterm-claude-status.sh
[ -x "$AGT_STATUS_SCRIPT" ] ||
AGT_STATUS_SCRIPT=$HOME/.config/agterm/agent-status/agterm-agent-status.sh
fi

# 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
# 0700 like the root above: these hold session ids, pids and transcript
# paths. The umask is confined to a subshell so sourcing this file never
# changes the mode of anything else the caller writes.
(umask 077; 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
45 changes: 45 additions & 0 deletions cookbook/truthful-agent-lights/set-status.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
#!/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. On
# 0.26.0 and later that is the Claude adapter, which drops the post when a
# worker spawned inside this session fired the hook; the heartbeat above is
# written either way, so a worker's hooks still mark the spawner as alive
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