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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ These scripts run pull request reviews through Claude Code or Codex. The `review
| Script | Purpose |
| ------ | ------- |
| [`claude-session`](bin/claude-session) | Manage tmux sessions for Claude Code (new, attach, list, kill, status). |
| [`claude-sessions`](bin/claude-sessions) | List live Claude sessions and background agents with status, waiting prompt, branch, PR status, the PRs each one waits on, and signing state. `--json` adds each transcript's tail. `--plan` and `--render` group the sessions and draw the table for `/sessions`. |
| [`claude-sessions`](bin/claude-sessions) | List live Claude sessions and background agents with status, waiting prompt, branch, PR status, the PRs each one waits on, and signing state. `--json` adds each transcript's tail. `--plan` and `--render` group the sessions and print the markdown for `/sessions`. `--plan` also tints the Supacode worktree of each session whose PR merged gray. |
| [`claude-session-tokens`](bin/claude-session-tokens) | Read token usage from the current Claude Code session JSONL. |
| [`token-count`](bin/token-count) | Count tokens in a text file using `tiktoken` (cl100k_base) via uv's inline script deps. |

Expand Down
6 changes: 3 additions & 3 deletions ai/skills/sessions/SKILL.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
name: sessions
description: Show every live local Claude Code session and background agent in one table, grouped by what each one needs next (needs you, Claude can continue, waiting on something outside, working, done, stale) with a one-line next step, and each session nested under the session whose PR it waits on. Use when the user asks which sessions need them, what their sessions are doing, or runs /sessions. With --act, offers to tell the sessions that can continue on their own to continue.
description: Show every live local Claude Code session and background agent, grouped by what each one needs next (needs you, Claude can continue, waiting on something outside, held, working, done, stale) with a one-line next step, and under each session the session whose PR it waits on. Use when the user asks which sessions need them, what their sessions are doing, or runs /sessions. With --act, offers to tell the sessions that can continue on their own to continue.
argument-hint: "[--act]"
compatibility: Designed for Claude Code (or similar products)
model: haiku
Expand All @@ -12,7 +12,7 @@ metadata:

Answers one question: which of my Claude sessions need me, and what is the next step for each?

`~/.dotfiles/bin/claude-sessions` gathers the facts, puts each session in its group, and draws the table without any model calls. This skill answers the checks that need judgment, reading what a session last said, and writes the next steps that the script can't. `~/.dotfiles/bin/lib/claude-sessions.jq` holds the rules.
`~/.dotfiles/bin/claude-sessions` gathers the facts, puts each session in its group, and prints the markdown without any model calls. This skill answers the checks that need judgment, reading what a session last said, and writes the next steps that the script can't. `~/.dotfiles/bin/lib/claude-sessions.jq` holds the rules.

Never message a session to ask about its state. A busy session reads the message only after its current work ends, and the answer spends its context.

Expand Down Expand Up @@ -89,7 +89,7 @@ Add `--act` after `<state>` when the user passed `--act`. Leave a session out of

If the script exits with 1, it prints what is wrong with the answers. Fix those answers and run it again with the same state file.

Print the script's output as it is. Print the table itself, not a code block, because a code block shows a link's markdown instead of the link. The script has already chosen each session's group, nesting, and order, so don't move or edit rows.
Print the script's output as it is. Do not put it in a code block, because a code block shows a link's markdown instead of the link. The script has already chosen each session's group, parent, and order, so don't move or edit lines.

### 4. Act (`--act` only)

Expand Down
41 changes: 38 additions & 3 deletions bin/claude-sessions
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,13 @@
# assistant text. running is {tool, input, minutes}: the tool's name, the first
# 500 characters of its Bash command or its description, and the minutes since
# the call. self is true for the session whose ID is $CLAUDE_CODE_SESSION_ID.
# supacodeWorktree is the Supacode worktree ID of the session's tab, or null
# when the session's git worktree is not the tab's worktree.
# --plan saves the --json array to a new temp file. It prints the file's path
# as state, and the checks that the /sessions skill must answer for each session.
# --render reads the skill's answers on stdin and prints the /sessions table for
# It also tints the Supacode sidebar entry of each session whose PR merged gray,
# and clears that gray from the entry of each session whose PR is open.
# --render reads the skill's answers on stdin and prints the /sessions markdown for
# the state file. With --act, it also lists the sessions that Claude can
# continue. bin/lib/claude-sessions.jq holds the rules for both.

Expand All @@ -37,6 +41,7 @@ config_dir="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
sign_fail='failed to sign|failed to write commit object|signing failed|agent refused operation|Couldn.t sign'
commit_ok='\[[A-Za-z0-9/._-]+ [0-9a-f]{7,}\] '
lib="$script_dir/lib"
merged_tint='#8E8E93'
inc='include "claude-sessions"; '

usage() {
Expand Down Expand Up @@ -168,7 +173,7 @@ lookup_pr() { # lookup_pr <key> view <url> | lookup_pr <key> list <repo> <branch
# Prints one session's facts as a JSON object, or nothing when the session has
# exited or is a `claude -p` child.
session_facts() { # agent JSON from `claude agents --json`
local agent=$1 sid pid cwd status updated_at='' entrypoint='' tab='' transcript mtime=''
local agent=$1 sid pid cwd status updated_at='' entrypoint='' proc_env tab='' supacode_worktree='' supacode_path='' transcript mtime=''
local facts='' top='' branch='' dirty='' repo='' default='' pr='' pr_file pr_pid signing='' depends=''

{
Expand All @@ -185,7 +190,10 @@ session_facts() { # agent JSON from `claude agents --json`
read -r entrypoint
} < <(jq -r '(.statusUpdatedAt // .updatedAt // ""), (.entrypoint // "")' "$config_dir/sessions/$pid.json" 2>/dev/null) || true
[[ "$entrypoint" == sdk* ]] && return 0
tab=$(ps eww -o command= -p "$pid" 2>/dev/null | tr ' ' '\n' | sed -n 's/^SUPACODE_TAB_ID=//p') || return 0
proc_env=$(ps eww -o command= -p "$pid" 2>/dev/null | tr ' ' '\n') || return 0
tab=$(sed -n 's/^SUPACODE_TAB_ID=//p' <<<"$proc_env")
supacode_worktree=$(sed -n 's/^SUPACODE_WORKTREE_ID=//p' <<<"$proc_env")
supacode_path=$(sed -n 's/^SUPACODE_WORKTREE_PATH=//p' <<<"$proc_env")
fi

transcript=$(find "$config_dir/projects" -maxdepth 2 -name "$sid.jsonl" -print -quit 2>/dev/null) || transcript=
Expand Down Expand Up @@ -215,6 +223,12 @@ session_facts() { # agent JSON from `claude agents --json`
pr=$(<"$pr_file")
[[ -z "$pr" ]] || depends=$(dependencies "$pr" "$default") || depends=
fi
# A session can start in another worktree than its tab's, such as after a cd.
# The Supacode worktree then does not hold the session's PR.
if [[ -z "$top" || -z "$supacode_path" ]] ||
[[ "$(cd "$supacode_path" 2>/dev/null && pwd -P)" != "$(cd "$top" && pwd -P)" ]]; then
supacode_worktree=
fi

jq -L "$lib" -n \
--argjson agent "$agent" \
Expand All @@ -223,6 +237,7 @@ session_facts() { # agent JSON from `claude agents --json`
--arg updatedAt "$updated_at" \
--arg mtime "$mtime" \
--arg tab "$tab" \
--arg supacodeWorktree "$supacode_worktree" \
--arg transcript "$transcript" \
--arg top "$top" \
--arg branch "$branch" \
Expand All @@ -248,6 +263,7 @@ session_facts() { # agent JSON from `claude agents --json`
depends: ($depends | fromjson? // null),
signing: ($signing | blank),
tab: ($tab | blank),
supacodeWorktree: ($supacodeWorktree | blank),
worktree: ($top | blank),
cwd: $agent.cwd,
pid: $agent.pid,
Expand Down Expand Up @@ -350,6 +366,25 @@ case "$mode" in
exit 0
;;
plan)
# A tint that fails to apply does not fail the plan. An open PR's entry
# loses only the gray that this script set, so a tint the user chose stays.
if command -v supacode >/dev/null; then
while IFS=$'\t' read -r how id; do
if [[ "$how" == merged ]]; then
supacode worktree appearance -w "$id" --color "$merged_tint" --timeout 5 >/dev/null 2>&1 &
else
{
supacode worktree appearance -w "$id" --timeout 5 2>/dev/null | grep -qxF "color=$merged_tint" &&
supacode worktree appearance -w "$id" --color none --timeout 5 >/dev/null 2>&1
} &
fi
done < <(jq -r '
([.[] | select(.pr.state == "MERGED") | .supacodeWorktree | values] | unique) as $merged
| ($merged[] | "merged\t\(.)"),
([.[] | select(.pr.state == "OPEN") | .supacodeWorktree | values] | unique - $merged | .[] | "open\t\(.)")' \
<<<"$sessions")
wait
fi
state=$(mktemp "${TMPDIR:-/tmp}/claude-sessions.XXXXXX")
echo "$sessions" >"$state"
jq -L "$lib" --arg state "$state" "$inc"'{state: $state} + plan' <<<"$sessions"
Expand Down
160 changes: 96 additions & 64 deletions bin/lib/claude-sessions.jq
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
# plan decides each session's bucket and next step where the facts alone can,
# and returns the checks that only a model can answer.
# render($answers; $act) applies the model's answers to the same rules and
# returns the markdown table, or the problems it found in the answers.
# returns the /sessions markdown, or the problems it found in the answers.

def minutes_since: (now * 1000 - .) / 60000 | floor | if . < 0 then 0 else . end;
def epoch_ms: sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601 * 1000;
Expand Down Expand Up @@ -110,7 +110,7 @@ def busy_rules($by; $ans):
{bucket: "needs-you", fit: exited, step: "close its tab"},
{bucket: "held", fit: ($bb != null), step: "rebase onto \($bb | edge_label) once it settles"},
{bucket: "needs-you", fit: (.pr.mergeable == "CONFLICTING" and ($r | resolving | not)), step: "rebase; the PR conflicts with its base"},
if $r == null then {bucket: "working", fit: true, step: "working"}
if $r == null then {bucket: "working", fit: true, step: ""}
elif ($r | known_poll) then
if ($r.minutes // 0) > 60 then {bucket: "needs-you", fit: true, step: poll_step(true)}
else {bucket: "outside", fit: true, step: poll_step(false)} end
Expand Down Expand Up @@ -221,39 +221,23 @@ def nest:
| (first($r.owners[] | select(any(.session | up($parents); . == $r.name) | not)) // null) as $o
| if $o then .[$r.name] = $o else . end);

# Each child's name cell, depth first, drawn as `tree` draws it.
def tree($kids; $parents; $indent):
($kids[.] // []) as $ks
| range($ks | length) as $i
| $ks[$i] as $c
| ($i == ($ks | length) - 1) as $last
| {name: $c, cell: ($indent + (if $last then "└" else "├" end) + (if $parents[$c].inferred then "? " else " " end) + $c)},
($c | tree($kids; $parents; $indent + (if $last then " " else "│ " end)));

def pr_cell:
if .pr == null then "-"
else
.pr as $pr
| ($pr.status // "") as $words
| "[#\($pr.number)](\($pr.url)) "
+ if ($words | startswith("Approved")) | not then $words
else
(if ($pr.approvers // []) != [] then "Approved by \($pr.approvers | join(" and "))\($words[8:])" else $words end)
+ if ($pr.commenters // []) != [] then "; comments from \($pr.commenters | join(" and "))"
elif $pr.commenters == null and $pr.state == "OPEN" then "; comments did not load"
else "" end
end
end;

def names($label): if $label | startswith("#") then test($label + "(?![0-9])") else contains($label) end;
def oneline: gsub("\\s*[\\r\\n]\\s*"; " ");
def cell: oneline | gsub("\\|"; "\\|");
def names($label): if $label | startswith("#") then test($label + "(?![0-9])") else contains($label) end;

def groups:
[["needs-you", "Needs you"], ["continue", "Claude can continue"], ["outside", "Waiting on something outside"],
["working", "Working"], ["done", "Done"], ["stale", "Stale"]];
def buckets:
[{key: "needs-you", title: "Needs you", one: "needs you", many: "need you", list: true, bold: true},
{key: "continue", title: "Claude can continue", one: "can continue", list: true},
{key: "outside", title: "Waiting on something outside", one: "waiting", list: true},
{key: "held", title: "Held", one: "held"},
{key: "working", title: "Working", one: "working"},
{key: "done", title: "Done", one: "done"},
{key: "stale", title: "Stale", one: "stale"}];

def render($raw; $act):
# Applies the answers to the rules. Returns the problems it found in the
# answers as {errors}, or else {rows}: one {name, bucket, step, on, parent,
# inferred, order, facts} per session in script order. on holds "; on #N" for
# each dependency other than the parent that the step does not name.
def judge($raw):
. as $all
| INDEX(.name) as $by
| ($raw | object_or_empty) as $answers
Expand Down Expand Up @@ -283,38 +267,86 @@ def render($raw; $act):
| if $errors != [] then {errors: $errors}
else
($decided | nest) as $parents
| (reduce $decided[] as $r ({}; if $parents[$r.name] then .[$parents[$r.name].session] += [$r.name] else . end)) as $kids
| ($decided | INDEX(.name)) as $dec
| (groups | map(.[0])) as $rank
| [$decided | to_entries[] | select($parents[.value.name] == null)
| .key as $order | .value.name as $root
| [{name: $root, cell: $root}, ($root | tree($kids; $parents; ""))] as $block
| {order: $order, rows: $block,
group: ([$block[] | $dec[.name].bucket as $b
| if $b != "held" then $rank | index($b) elif .name == $root then $rank | index("outside") else empty end] | min)}]
| sort_by(.group, -(.rows | length), .order) as $blocks
| (if $rows == [] then ["No sessions are live."]
else
["| Session | PR | Next step |", "| --- | --- | --- |"]
+ [groups | to_entries[] | .key as $g
| [$blocks[] | select(.group == $g)] as $in
| select($in != [])
| "| **\(.value[1])** | | |",
($in[].rows[]
| $dec[.name] as $d
| ($parents[.name].label // null) as $shown
| ($d.step + ([$d.deps[] | edge_label] | uniq_by(.)
| map(select(. != $shown) | . as $l | select($d.step | names($l) | not) | "; on \(.)") | join(""))) as $step
| "| \(.cell | sub("^(?<s> +)"; "\(.s | gsub(" "; "⠀"))") | cell) | \($d.s | pr_cell | cell) | \($step | cell) |")]
| {rows: [$decided | to_entries[] | .key as $order | .value
| ($parents[.name] // null) as $p
| .step as $step
| {name, bucket, step, order: $order, facts: .s,
parent: ($p.session // null), inferred: ($p.inferred // false),
on: ([.deps[] | edge_label] | uniq_by(.)
| map(select(. != $p.label) | . as $l | select($step | names($l) | not) | "; on \(.)") | join(""))}]}
end;

# A code span's fence is one backtick longer than the longest run of backticks
# in the text.
def code:
oneline
| ([scan("`+") | length] | max // 0) as $n
| if $n == 0 then "`\(.)`" else ("`" * ($n + 1)) as $f | "\($f) \(.) \($f)" end;
def prefix($s): if . == "" then "" else $s + . end;

def pr_link:
"[\(.url | repo | split("/")[1])#\(.number)](\(.url))"
+ ([(.approvers // [] | map("✓" + .) | join(" ") | select(. != "")),
(if .commenters == null then
select(.state == "OPEN" and (.status // "" | startswith("Approved"))) | "comments did not load"
elif .commenters != [] then "comments from " + (.commenters | join(" and "))
else empty end)]
| join(", ") | prefix(" "));

# Prints the /sessions markdown: a count line, then one section per bucket in
# the order of buckets. Needs you, Claude can continue, and Waiting on
# something outside print one list item per session, with a sub-item that
# names its parent. Sessions there that share a step and that no edge touches
# print as one item. The other buckets print their sessions on one line.
# Within a bucket, the sessions that more sessions wait on come first.
def render($raw; $act):
judge($raw)
| if .errors then .
else
.rows as $rows
| (buckets | INDEX(.key)) as $bucket
| ($rows | INDEX(.name)) as $by
| ($rows | map(select(.parent) | {key: .name, value: {session: .parent}}) | from_entries) as $parents
| ([$rows[] | [.name | up($parents)][1:][]] | group_by(.) | map({key: .[0], value: length}) | from_entries) as $waiters
| [$rows[] | . + {waiters: ($waiters[.name] // 0)}] | sort_by(-.waiters, .order) as $sorted
| [buckets[] as $b | {b: $b, rows: [$sorted[] | select(.bucket == $b.key)]} | select(.rows != [])] as $sections
| def count($b):
"\(length) \(if length == 1 then $b.one else $b.many // $b.one end)"
| if $b.bold then "**\(.)**" else . end;
def waiters: if . == 0 then empty elif . == 1 then "1 waits on it" else "\(.) wait on it" end;
def doing: (if .step == "" then .on | ltrimstr("; ") else .step + .on end) | oneline | select(. != "");
def lone: .parent == null and .waiters == 0;
def waits_on: select(.parent) | "waits on \(.parent | code)\(if .inferred then " (inferred)" else "" end)";
def row:
[doing, (.name | code), (.facts.pr | values | pr_link), (.facts.idle | age), (.waiters | waiters)]
| map(values) | "- " + join(" · ");
def parent_item:
select(.parent) | $by[.parent] as $p
| " - " + waits_on + " · " + $bucket[$p.bucket].one + (($p | doing | ": " + .) // "");
def item: (.name | code) + ((.facts.pr | values | " " + pr_link) // "");
def compact: (.name | code) + ([doing, waits_on, (.waiters | waiters)] | join(", ") | prefix(": "));
def section:
.b as $b
| if $b.list then
([.rows[] | select(lone)] | group_by(.step + .on) | map(select(length > 1)) | sort_by(.[0].order)) as $groups
| ($groups | map(.[0] | .step + .on)) as $shared
| "**\($b.title) (\(.rows | length))**\n\n"
+ ([(.rows[] | select(lone and (.step + .on | IN($shared[])) | not) | row, parent_item),
($groups[] | "- \(.[0] | doing) (\(length)): \(map(item) | join(", "))")]
| join("\n"))
else
"**\($b.title) (\(.rows | length)):** \(.rows | map(compact) | join(" · "))"
end;
(if $sections == [] then ["No sessions are live."]
else [([$sections[] | .b as $b | .rows | count($b)] | join(" · ")), ($sections[] | "", section)] end)
+ (($raw | object_or_empty | .offline // 0) as $n
| if $n == 1 then ["", "1 Remote Control session is offline."]
elif $n > 1 then ["", "\($n) Remote Control sessions are offline."]
else [] end)
+ (if $act | not then []
else [$sections[] | select(.b.key == "continue") | .rows[] | "- \(.name | oneline): \(.step | oneline)"] as $act_rows
| if $act_rows == [] then ["", "No session can continue on its own."]
else ["", "Sessions that Claude can continue:"] + $act_rows end
end)
+ (($answers.offline // 0) as $n
| if $n == 1 then ["", "1 Remote Control session is offline."]
elif $n > 1 then ["", "\($n) Remote Control sessions are offline."]
else [] end)
+ (if $act | not then []
else [$blocks[].rows[] | $dec[.name] | select(.bucket == "continue") | "- \(.name | oneline): \(.step | oneline)"] as $act_rows
| if $act_rows == [] then ["", "No session can continue on its own."]
else ["", "Sessions that Claude can continue:"] + $act_rows end
end)
| {text: join("\n")}
end;
Loading
Loading