feat: v0.10.0 quieter Matrix, worktrees, usage pace, repo diffs, copy reply, turn timer, compact
- Matrix "needs you": ignore Claude's idle reminder (no red, no post); post only when no tab is in view, after 60 s, once per session per 10 min, and say what is asked. Closes #50 - devdesk new NAME PROJECT --worktree: own checkout on devdesk/NAME, cleaned up on end when clean/merged. Closes #44 - Usage meters: reset time, pace warning; Matrix ping past 90% when away. Closes #45 - Repo panel lists changed files; click opens the diff. Closes #46 - Copy Claude's last reply (--last-reply). Closes #47 - Turn timer in chip, title and cards. Closes #48 - Compact button at 80% context when idle. Closes #49 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
This commit is contained in:
@@ -111,3 +111,24 @@ cmd_reply() {
|
||||
rm -f "$tmp"
|
||||
trap - HUP INT TERM
|
||||
}
|
||||
|
||||
# --last-reply SESSION (devdesk#47): Claude's whole answer to your last message,
|
||||
# every text part of it (between tool calls), as the Markdown Claude wrote.
|
||||
cmd_last_reply() {
|
||||
[ $# -eq 1 ] || dd_die "usage: devdesk attach --last-reply SESSION"
|
||||
local session=$1 transcript
|
||||
dd_valid_name "$session" || dd_die "invalid session name: $session"
|
||||
transcript=$(dd_transcript "$session") || transcript=""
|
||||
{ [ -n "$transcript" ] && [ -r "$transcript" ]; } || dd_die "no conversation recorded for '$session' yet"
|
||||
tail -n 3000 -- "$transcript" | jq -cMs --arg s "$session" '
|
||||
def text: if type == "string" then . else ([.[]? | select(.type == "text") | .text] | join("\n")) end;
|
||||
[.[] | select(.isSidechain != true and .isMeta != true)] as $all
|
||||
# Your last message: a user entry with text of its own, not a tool result.
|
||||
| ([range(0; $all | length) | select($all[.] as $e | $e.type == "user"
|
||||
and ($e.message.content | type == "string" or any(.[]?; .type == "text"))
|
||||
and ($e.message.content | text | startswith("<") | not))] | last // -1) as $u
|
||||
| [$all[($u + 1):][] | select(.type == "assistant") | .message.content | text | select(length > 0)] as $parts
|
||||
| if ($parts | length) == 0 then {session: $s, text: null}
|
||||
else {session: $s, text: ($parts | join("\n\n")), at: ([$all[] | select(.type == "assistant")] | last | .timestamp)} end' 2>/dev/null ||
|
||||
dd_die "could not read the conversation of '$session'"
|
||||
}
|
||||
|
||||
@@ -58,7 +58,9 @@ dd_load_env
|
||||
: "${DEVDESK_UPLOAD_MAX:=26214400}"
|
||||
: "${DEVDESK_PUBLIC_URL:=}"
|
||||
: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}"
|
||||
: "${DEVDESK_MATRIX_DELAY:=30}"
|
||||
: "${DEVDESK_MATRIX_DELAY:=60}"
|
||||
: "${DEVDESK_MATRIX_QUIET:=600}"
|
||||
: "${DEVDESK_USAGE_WARN:=90}"
|
||||
: "${DEVDESK_PRESENCE_DIR:=/run/devdesk/presence}"
|
||||
|
||||
DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
|
||||
|
||||
@@ -1,15 +1,48 @@
|
||||
#!/bin/bash
|
||||
# devdesk-notify-matrix SESSION SINCE
|
||||
# devdesk-notify-matrix --usage PCT RESETS_AT
|
||||
#
|
||||
# Started (detached) by devdesk-state-hook when a session starts waiting on you.
|
||||
# After DEVDESK_MATRIX_DELAY seconds, if that same wait is still on and nobody is
|
||||
# attached to the session (no browser tab, no SSH client), post one "Claude needs
|
||||
# you" message to the hookshot webhook in DEVDESK_MATRIX_WEBHOOK_FILE (devdesk#28).
|
||||
# After DEVDESK_MATRIX_DELAY seconds, if that same wait is still on, no DevDesk
|
||||
# tab is in view, and this session hasn't posted in DEVDESK_MATRIX_QUIET seconds,
|
||||
# post what Claude is asking to the hookshot webhook in DEVDESK_MATRIX_WEBHOOK_FILE
|
||||
# (devdesk#28, #50).
|
||||
set -euo pipefail
|
||||
|
||||
# shellcheck source=libexec/devdesk-lib.sh
|
||||
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
|
||||
|
||||
# Post BODY (JSON with a .text) to the hookshot webhook. The URL is a secret
|
||||
# (anyone holding it can post to the room): it goes to curl on stdin, never on
|
||||
# the command line.
|
||||
post() {
|
||||
local webhook
|
||||
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the URL
|
||||
webhook=$(tr -d '[:space:]"\\' <"$DEVDESK_MATRIX_WEBHOOK_FILE")
|
||||
printf 'url = "%s"\n' "$webhook" |
|
||||
curl -fsS --max-time 10 --retry 2 -K - -H 'Content-Type: application/json' --data-binary "$1" >/dev/null
|
||||
}
|
||||
|
||||
# devdesk-notify-matrix --usage PCT RESETS_AT (devdesk#45): started by the status
|
||||
# line when the 5-hour plan window passes DEVDESK_USAGE_WARN percent. Once per
|
||||
# window, and only when no client is attached to any session (you're away; a
|
||||
# browser tab shows its own warning).
|
||||
if [ "${1:-}" = --usage ]; then
|
||||
pct=${2:-} resets=${3:-}
|
||||
[[ $pct =~ ^[0-9]+$ && $resets =~ ^[0-9]+$ ]] || exit 0
|
||||
marker="$DEVDESK_STATE_DIR/usage-warned"
|
||||
exec 9>>"$marker.lock"
|
||||
flock -n 9 || exit 0
|
||||
[ "$(cat "$marker" 2>/dev/null)" != "$resets" ] || exit 0
|
||||
printf '%s\n' "$resets" >"$marker"
|
||||
[ -r "$DEVDESK_MATRIX_WEBHOOK_FILE" ] || exit 0
|
||||
[ -z "$(dd_tmux list-clients -F x 2>/dev/null)" ] || exit 0
|
||||
at=$(date -d "@$resets" '+%H:%M %Z')
|
||||
post "$(jq -cMn --arg p "$pct" --arg at "$at" --arg h "$(hostname)" \
|
||||
'{kind: "usage", host: $h, text: "Claude plan usage is at \($p)% of the 5-hour window; it resets at \($at)."}')"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
session=${1:-}
|
||||
since=${2:-}
|
||||
dd_valid_name "$session" || exit 0
|
||||
@@ -21,25 +54,29 @@ sleep "$DEVDESK_MATRIX_DELAY"
|
||||
state_file="$DEVDESK_STATUS_DIR/$session.json"
|
||||
# Still the same wait? (Answering it moves the state on; a new wait has its own notifier.)
|
||||
[ "$(jq -rM '"\(.state) \(.since)"' "$state_file" 2>/dev/null)" = "waiting $since" ] || exit 0
|
||||
# Anyone looking at it already?
|
||||
attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null |
|
||||
awk -v s="$session" '$1 == s { print $2 }')
|
||||
[ "${attached:-0}" = 0 ] || exit 0
|
||||
# You're at DevDesk if any tab has shown a session in the last ~2 minutes
|
||||
# (presence markers, devdesk#26): the browser tells you itself (devdesk#50).
|
||||
[ -z "$(find "$DEVDESK_PRESENCE_DIR" -type f 2>/dev/null | head -n 1)" ] || exit 0
|
||||
# The idle reminder isn't a request (the state hook drops it; old states may have it).
|
||||
message=$(jq -rM '.message // ""' "$state_file" 2>/dev/null)
|
||||
request=$(jq -cM '.request // null' "$state_file" 2>/dev/null) || request=null
|
||||
if [ "$request" = null ] && { [ -z "$message" ] || [[ $message == *"waiting for your input"* ]]; }; then exit 0; fi
|
||||
# At most one message per session per DEVDESK_MATRIX_QUIET seconds.
|
||||
sent="$DEVDESK_STATE_DIR/matrix-sent/$session"
|
||||
mkdir -p "${sent%/*}"
|
||||
if [ -n "$(find "$sent" -newermt "-$DEVDESK_MATRIX_QUIET seconds" 2>/dev/null)" ]; then exit 0; fi
|
||||
touch "$sent"
|
||||
|
||||
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
|
||||
detail=$(jq -rM '.request.detail // empty' "$state_file" 2>/dev/null)
|
||||
[ -z "$detail" ] || message="$message: ${detail:0:300}"
|
||||
url=""
|
||||
# existing=1: if the session was ended by the time you tap, say so instead of
|
||||
# starting a new Claude there (devdesk#38).
|
||||
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session&existing=1"
|
||||
body=$(jq -cMn --arg s "$session" --arg m "$message" --arg u "$url" --arg h "$(hostname)" \
|
||||
'{session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h,
|
||||
text: ("Claude needs you in " + $s + ": " + $m + (if $u == "" then "" else " — " + $u end))}')
|
||||
# What exactly it wants: "alpha: Claude wants to run Bash (Remove x)" and the command.
|
||||
body=$(jq -cMn --arg s "$session" --arg m "$message" --argjson r "$request" --arg u "$url" --arg h "$(hostname)" '
|
||||
(if $r then "\($s): Claude wants to run \($r.tool)\(if $r.description then " (\($r.description))" else "" end)"
|
||||
else "\($s): \($m)" end) as $head
|
||||
| ($r.detail // null | if . then .[0:300] else null end) as $d
|
||||
| {session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h,
|
||||
text: ($head + (if $d then "\n" + $d else "" end) + (if $u == "" then "" else "\nAnswer: " + $u end))}')
|
||||
|
||||
# The webhook URL is a secret (anyone holding it can post to the room): pass it
|
||||
# to curl on stdin, never on the command line.
|
||||
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the URL
|
||||
webhook=$(tr -d '[:space:]"\\' <"$DEVDESK_MATRIX_WEBHOOK_FILE")
|
||||
printf 'url = "%s"\n' "$webhook" |
|
||||
curl -fsS --max-time 10 --retry 2 -K - -H 'Content-Type: application/json' --data-binary "$body" >/dev/null
|
||||
post "$body"
|
||||
|
||||
@@ -21,8 +21,17 @@ session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
|
||||
|
||||
# Unit separator, not tab: read collapses runs of whitespace delimiters, so an
|
||||
# empty field would shift the ones after it.
|
||||
IFS=$'\x1f' read -r event message tool file < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\u001f\n]"; " ")),
|
||||
.tool_name // "", (.tool_input.file_path // .tool_input.notebook_path // "" | gsub("[\u001f\n]"; " "))] | join("\u001f")' <<<"$input" 2>/dev/null)
|
||||
IFS=$'\x1f' read -r event message tool file ntype < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\u001f\n]"; " ")),
|
||||
.tool_name // "", (.tool_input.file_path // .tool_input.notebook_path // "" | gsub("[\u001f\n]"; " ")),
|
||||
.notification_type // ""] | join("\u001f")' <<<"$input" 2>/dev/null)
|
||||
|
||||
# Claude's idle reminder ("Claude is waiting for your input", about a minute
|
||||
# after a turn ends) is not a request: the turn is simply done. It must not
|
||||
# turn the session red or reach Matrix (devdesk#50).
|
||||
if [ "$event" = Notification ] &&
|
||||
{ [ "$ntype" = idle_prompt ] || { [ -z "$ntype" ] && [[ $message == *"waiting for your input"* ]]; }; }; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Files Claude edits, for the file viewer's "Claude" badge (devdesk#19).
|
||||
case $event:$tool in
|
||||
|
||||
@@ -43,6 +43,17 @@ if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Plan usage past DEVDESK_USAGE_WARN: ping Matrix if you're away (devdesk#45).
|
||||
# The marker holds the window already warned about, so this stays one read.
|
||||
if [[ $session != "" ]] && read -r pct resets < <(jq -rM '.rate_limits.five_hour
|
||||
| select(.used_percentage != null and .resets_at != null)
|
||||
| "\(.used_percentage | floor) \(.resets_at)"' <<<"$input" 2>/dev/null) &&
|
||||
[ "$pct" -ge "${DEVDESK_USAGE_WARN:-90}" ] &&
|
||||
[ "$(cat "${DEVDESK_STATE_DIR:-/var/lib/devdesk}/usage-warned" 2>/dev/null)" != "$resets" ]; then
|
||||
setsid -f "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-notify-matrix" --usage "$pct" "$resets" \
|
||||
</dev/null >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# Footer line: session · model · context % and tokens · plan use. ANSI colour numbers, not
|
||||
# RGB, so the browser's dark or light palette applies (devdesk#31). Claude sends
|
||||
# rate_limits only to Pro/Max subscribers; there .cost is an API-price estimate,
|
||||
|
||||
Reference in New Issue
Block a user