feat: v0.10.0 quieter Matrix, worktrees, usage pace, repo diffs, copy reply, turn timer, compact
build-deb / Notify on failure (push) Successful in 4s
build-deb / Lint, test, package (push) Failing after 2m22s
build-deb / Deploy to CT 126 (push) Skipped

- 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:
2026-10-11 23:28:11 -04:00
co-authored by Claude Opus 5.5
parent bf8821f3f9
commit a2edec966a
17 changed files with 556 additions and 78 deletions
+21
View File
@@ -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'"
}
+3 -1
View File
@@ -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"
+56 -19
View File
@@ -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"
+11 -2
View File
@@ -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
+11
View File
@@ -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,