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
+68 -12
View File
@@ -2,7 +2,9 @@
# devdesk — persistent Claude Code sessions in tmux, shared by the browser UI and SSH.
#
# devdesk attach [NAME] attach to session NAME, creating it if needed; no NAME opens the picker
# devdesk new NAME [PROJECT] create session NAME (detached) in PROJECT's folder, or the default folder
# devdesk new NAME [PROJECT] [--worktree]
# create session NAME (detached) in PROJECT's folder, or the default
# folder; --worktree gives it its own git worktree on branch devdesk/NAME
# devdesk rename OLD NEW rename a session (running or dormant)
# devdesk end NAME stop session NAME and forget it
# devdesk repo NAME git + Gitea CI status of the session's folder (JSON)
@@ -12,7 +14,8 @@
# devdesk pick interactive picker
#
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --investigate SESSION UID |
# --upload SESSION FILE BYTES | --new NAME [PROJECT] | --rename OLD NEW | --end NAME | --existing NAME`
# --upload SESSION FILE BYTES | --new NAME [PROJECT] [--worktree] | --rename OLD NEW | --end NAME |
# --last-reply NAME | --existing NAME`
# through ttyd; those print JSON.
set -euo pipefail
@@ -49,7 +52,7 @@ projects_tsv() {
# Only offer directories that attach would accept (no symlinks out of the root).
real=$(realpath -e -- "$d" 2>/dev/null) || continue
case "$real/" in "$root"/*) ;; *) continue ;; esac
printf '%s\t%s\n' "$(dd_slugify "${d##*/}")" "$d"
printf '%s\t%s\t%s\n' "$(dd_slugify "${d##*/}")" "$([ -e "$d/.git" ] && echo 1 || echo 0)" "$d"
done
}
@@ -85,15 +88,17 @@ cmd_list() {
| ($live | map(.name)) as $liveNames
| {default: $dflt, root: $root,
sessions: (
($live | map(. + {claude_session_id: ($known[.name].claude_session_id // null)}))
($live | map(. + {claude_session_id: ($known[.name].claude_session_id // null),
worktree: ($known[.name].worktree // null)}))
+ ($known | to_entries
| map(select(.key as $k | $liveNames | index($k) | not))
| map({name: .key, dir: .value.dir, running: false, attached: 0,
activity: null, windows: 0,
claude_session_id: (.value.claude_session_id // null)}))
claude_session_id: (.value.claude_session_id // null),
worktree: (.value.worktree // null)}))
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // [])}),
projects: ([{name: $dflt, dir: $root}]
+ [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))}
+ [rows($proj)[] | {name: .[0], git: (.[1] == "1"), dir: (.[2:] | join("\t"))}
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
}
@@ -121,7 +126,12 @@ create_detached() {
}
cmd_new() {
{ [ $# -ge 1 ] && [ $# -le 2 ]; } || dd_die "usage: devdesk new NAME [PROJECT]"
local worktree=0 a args=()
for a in "$@"; do
if [ "$a" = --worktree ]; then worktree=1; else args+=("$a"); fi
done
set -- "${args[@]}"
{ [ $# -ge 1 ] && [ $# -le 2 ]; } || dd_die "usage: devdesk new NAME [PROJECT] [--worktree]"
dd_valid_name "$1" || dd_die "invalid session name: $1"
local dir
if [ $# -eq 2 ]; then
@@ -129,12 +139,49 @@ cmd_new() {
dir=$(dd_project_dir "$2") || dd_die "no project '$2' under $DEVDESK_PROJECTS_ROOT"
! session_exists "$1" || dd_die "a session named '$1' already exists"
else
[ "$worktree" = 0 ] || dd_die "--worktree needs a project"
dir=$(dd_resolve_dir "$1")
fi
[ "$worktree" = 0 ] || dir=$(dd_worktree_add "$1" "$2" "$dir")
dd_ensure_server
create_detached "$1" "$dir"
}
# A git worktree of PROJECT for session NAME (devdesk#44), so two Claudes on one
# repository never edit the same checkout. It lives in $ROOT/.worktrees (hidden
# from the project list) on branch devdesk/NAME, made from the project's HEAD or
# reused if that branch exists. Prints the folder the session starts in.
dd_worktree_add() {
local name=$1 project=$2 dir=$3 top prefix wt branch="devdesk/$1" g
top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || dd_die "'$project' is not a git repository"
prefix=$(git -C "$dir" rev-parse --show-prefix)
wt="$DEVDESK_PROJECTS_ROOT/.worktrees/$project--$name"
[ ! -e "$wt" ] || dd_die "$wt already exists"
mkdir -p "$DEVDESK_PROJECTS_ROOT/.worktrees"
g=(git -C "$top")
if "${g[@]}" show-ref -q --verify "refs/heads/$branch"; then
"${g[@]}" worktree add -q "$wt" "$branch" >&2 || dd_die "git worktree add failed"
else
"${g[@]}" worktree add -q -b "$branch" "$wt" HEAD >&2 || dd_die "git worktree add failed"
fi
wt=$(realpath -e -- "$wt")
# shellcheck disable=SC2016 # jq variables
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {worktree: {path: $w, repo: $r, branch: $b}})' \
--arg n "$name" --arg w "$wt" --arg r "$top" --arg b "$branch"
printf '%s\n' "$wt${prefix:+/${prefix%/}}"
}
# Ending a worktree session removes the worktree when nothing in it is uncommitted,
# and its branch only when that is merged (git branch -d). Otherwise both stay.
dd_worktree_end() {
local wt=$1 repo=$2 branch=$3
[ -d "$wt" ] || return 0
if [ -z "$(git -C "$wt" status --porcelain 2>/dev/null)" ]; then
git -C "$repo" worktree remove "$wt" 2>/dev/null || return 0
git -C "$repo" branch -d "$branch" >/dev/null 2>&1 || true
fi
}
cmd_rename() {
[ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW"
local old=$1 new=$2 running=0
@@ -160,8 +207,14 @@ rename_locked() {
cmd_end() {
[ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
local wt
wt=$(jq -rM --arg n "$1" '.sessions[$n].worktree // empty | [.path, .repo, .branch] | @tsv' "$DEVDESK_REGISTRY" 2>/dev/null) || wt=""
# Forget first: killing the session SIGHUPs us when run from inside it.
dd_registry_remove "$1"
if [ -n "$wt" ]; then
IFS=$'\t' read -r -a wt <<<"$wt"
dd_worktree_end "${wt[@]}"
fi
rm -rf "${DEVDESK_STATE_DIR:?}/uploads/$1"
if dd_server_up; then
dd_tmux kill-session -t "=$1" 2>/dev/null || true
@@ -196,7 +249,7 @@ gitea_slug() {
cmd_repo() {
[ $# -eq 1 ] || dd_die "usage: devdesk repo NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
local dir g branch sha="" dirty ahead=null behind=null upstream=false remote slug="" ci=null prs=null
local dir g branch sha="" dirty changes ahead=null behind=null upstream=false remote slug="" ci=null prs=null
dir=$(dd_resolve_dir "$1")
# --no-optional-locks: never take .git/index.lock (Claude's own git would collide).
# No safe.directory override: repos owned by other users stay untrusted.
@@ -208,6 +261,9 @@ cmd_repo() {
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
sha=$("${g[@]}" rev-parse -q --verify HEAD 2>/dev/null || true)
dirty=$("${g[@]}" status --porcelain 2>/dev/null | wc -l)
# The first changed files, for the panel to open as diffs (devdesk#46).
changes='[]'
[ "$dirty" = 0 ] || changes=$(cmd_git_status "$1" 2>/dev/null | jq -cM '.files[:8] // []' 2>/dev/null) || changes='[]'
if "${g[@]}" rev-parse -q --verify '@{u}' >/dev/null 2>&1; then
upstream=true
read -r behind ahead < <("${g[@]}" rev-list --left-right --count '@{u}...HEAD')
@@ -222,10 +278,10 @@ cmd_repo() {
prs=$(gitea_get "/repos/$slug/pulls?state=open&limit=50" | jq -M 'length' 2>/dev/null) || prs=null
fi
jq -cMn --arg s "$1" --arg dir "$dir" --arg branch "$branch" --arg sha "${sha:0:7}" \
--argjson dirty "$dirty" --argjson ahead "$ahead" --argjson behind "$behind" \
--argjson dirty "$dirty" --argjson changes "${changes:-[]}" --argjson ahead "$ahead" --argjson behind "$behind" \
--argjson upstream "$upstream" --arg remote "$remote" --arg slug "$slug" \
--arg web "$DEVDESK_GITEA_URL" --argjson ci "${ci:-null}" --argjson prs "${prs:-null}" '
{session: $s, dir: $dir, git: true, branch: $branch, sha: $sha, dirty: $dirty,
{session: $s, dir: $dir, git: true, branch: $branch, sha: $sha, dirty: $dirty, changes: $changes,
ahead: $ahead, behind: $behind, upstream: $upstream, remote: $remote,
repo: (if $slug == "" then null else $slug end),
url: (if $slug == "" then null else "\($web)/\($slug)" end),
@@ -310,10 +366,10 @@ cmd_attach() {
dd_linger
return
;;
--peek | --search)
--peek | --search | --last-reply)
local action=${1#--}
shift
"cmd_$action" "$@"
"cmd_${action//-/_}" "$@"
dd_linger
return
;;