2026-10-10 21:45:26 -04:00
|
|
|
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
|
|
|
|
|
# Past conversations (devdesk#24) and client presence (devdesk#26), sourced by
|
|
|
|
|
# bin/devdesk and reached through `devdesk attach --search|--resume-convo|--presence`.
|
|
|
|
|
|
|
|
|
|
# --search QUERY: full-text search over every Claude conversation on this host
|
|
|
|
|
# (DevDesk and code-server alike). Case-insensitive, plain text. Newest first;
|
|
|
|
|
# per conversation: title, folder, dates and up to 3 snippets (what you asked,
|
|
|
|
|
# Claude's answers, commands it ran, their output).
|
|
|
|
|
cmd_search() {
|
|
|
|
|
[ $# -eq 1 ] || dd_die "usage: devdesk attach --search QUERY"
|
|
|
|
|
local query=$1 dir files f
|
|
|
|
|
if [ ${#query} -lt 2 ] || [ ${#query} -gt 200 ] || [[ $query == *$'\n'* ]]; then dd_die "search for 2 to 200 characters"; fi
|
|
|
|
|
dir=$(dd_projects_dir)
|
|
|
|
|
# Files that mention it at all (grep is fast even over hundreds of MB), newest first.
|
|
|
|
|
mapfile -t files < <({ LC_ALL=C grep -rlFi --include='*.jsonl' -- "$query" "$dir" 2>/dev/null || true; } |
|
|
|
|
|
while IFS= read -r f; do printf '%s\t%s\n' "$(stat -c %Y "$f")" "$f"; done | sort -rn | head -n 30 | cut -f2-)
|
|
|
|
|
# Six conversations at a time (each is a grep and a jq over large lines).
|
|
|
|
|
local tmp i=0
|
|
|
|
|
tmp=$(mktemp -d)
|
|
|
|
|
for f in "${files[@]}"; do
|
|
|
|
|
dd_search_one "$query" "$f" >"$tmp/$(printf '%03d' "$i")" &
|
|
|
|
|
i=$((i + 1))
|
|
|
|
|
if [ $((i % 6)) -eq 0 ]; then wait; fi
|
|
|
|
|
done
|
|
|
|
|
wait
|
|
|
|
|
{ cat "$tmp"/* 2>/dev/null || true; } | jq -cMs --arg q "$query" '{query: $q, results: [.[] | select(. != null)][:25]}'
|
|
|
|
|
rm -rf "$tmp"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# One conversation's search result (or nothing), for cmd_search.
|
|
|
|
|
dd_search_one() {
|
|
|
|
|
local query=$1 f=$2
|
|
|
|
|
{ LC_ALL=C grep -Fi -m 30 -- "$query" "$f" || true; } | jq -cMs --arg q "$query" --arg id "$(basename "$f" .jsonl)" \
|
|
|
|
|
--arg mtime "$(stat -c %Y "$f")" --slurpfile head <(head -n 80 "$f" | jq -cM 'select(.type == "custom-title" or .type == "user" or .cwd)' 2>/dev/null | jq -cMs .) '
|
|
|
|
|
def texts:
|
|
|
|
|
if .type == "user" then
|
|
|
|
|
(.message.content | if type == "string" then [{role: "you", text: .}]
|
|
|
|
|
else [.[]? | if .type == "text" then {role: "you", text: .text}
|
|
|
|
|
elif .type == "tool_result" then {role: "output", text: (.content | if type == "string" then . else ([.[]? | .text? // empty] | join("\n")) end)}
|
|
|
|
|
else empty end] end)
|
|
|
|
|
elif .type == "assistant" then
|
|
|
|
|
[.message.content[]? | if .type == "text" then {role: "claude", text: .text}
|
|
|
|
|
elif .type == "tool_use" then {role: "command", text: (.input.command // .input.file_path // .input.url // .input.pattern // empty)}
|
|
|
|
|
else empty end]
|
|
|
|
|
else [] end;
|
|
|
|
|
($q | ascii_downcase) as $lq
|
|
|
|
|
| [.[] | select(.isSidechain != true) | . as $e | texts[] | select(.text | type == "string")
|
|
|
|
|
| (.text | ascii_downcase | index($lq)) as $i | select($i != null)
|
|
|
|
|
| {role, at: $e.timestamp,
|
|
|
|
|
snippet: ((if $i > 90 then "…" else "" end) + (.text[([$i - 90, 0] | max):($i + ($q | length) + 140)] | gsub("\\s+"; " "))
|
|
|
|
|
+ (if (.text | length) > $i + ($q | length) + 140 then "…" else "" end))}] as $m
|
|
|
|
|
| select($m | length > 0)
|
|
|
|
|
| ($head[0] // []) as $h
|
|
|
|
|
| {id: $id,
|
|
|
|
|
title: (([$h[] | select(.type == "custom-title") | .customTitle] | last)
|
|
|
|
|
// ([$h[] | select(.type == "user" and (.message.content | type == "string") and (.message.content | startswith("<") | not)) | .message.content] | first | .[0:120])
|
|
|
|
|
// "untitled"),
|
|
|
|
|
prompt: ([$h[] | select(.type == "user" and (.message.content | type == "string") and (.message.content | startswith("<") | not)) | .message.content] | first | .[0:160]),
|
|
|
|
|
cwd: ([$h[] | .cwd // empty] | first),
|
|
|
|
|
started: ([$h[] | .timestamp // empty] | first),
|
|
|
|
|
updated: ($mtime | tonumber),
|
|
|
|
|
hits: ($m | length), matches: ($m[:3])}' 2>/dev/null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# --resume-convo NAME ID [fork]: open a past conversation in a new session NAME,
|
|
|
|
|
# in the folder it ran in. "fork" continues it as a new conversation and leaves
|
|
|
|
|
# the original untouched (claude --fork-session).
|
|
|
|
|
cmd_resume_convo() {
|
|
|
|
|
if [ $# -lt 2 ] || [ $# -gt 3 ]; then dd_die "usage: devdesk attach --resume-convo NAME ID [fork]"; fi
|
|
|
|
|
local name=$1 id=$2 fork=${3:-} file dir
|
|
|
|
|
dd_valid_name "$name" || dd_die "invalid session name: $name"
|
|
|
|
|
if [ -n "$fork" ] && [ "$fork" != fork ]; then dd_die "the third argument can only be: fork"; fi
|
|
|
|
|
file=$(dd_convo_file "$id") || dd_die "no conversation $id"
|
|
|
|
|
[ -n "$file" ] || dd_die "no conversation $id"
|
|
|
|
|
if dd_server_up && dd_tmux has-session -t "=$name" 2>/dev/null; then dd_die "a session called $name is already running"; fi
|
|
|
|
|
# Two Claudes on one transcript would interleave it: resuming one that is
|
|
|
|
|
# open elsewhere is refused (forking is fine).
|
|
|
|
|
if [ -z "$fork" ]; then
|
|
|
|
|
local other
|
|
|
|
|
for other in $(jq -rM --arg id "$id" '.sessions | to_entries[] | select(.value.claude_session_id == $id) | .key' "$DEVDESK_REGISTRY" 2>/dev/null); do
|
|
|
|
|
if [ "$other" != "$name" ] && dd_tmux has-session -t "=$other" 2>/dev/null; then
|
|
|
|
|
dd_die "that conversation is open in session $other: open it there, or fork it"
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
fi
|
|
|
|
|
dir=$(jq -rM 'select(.cwd) | .cwd' "$file" 2>/dev/null | head -n1)
|
|
|
|
|
[ -n "$dir" ] && [ -d "$dir" ] || dir=$(dd_resolve_dir "$name")
|
|
|
|
|
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, claude_session_id: $id, claude_source: "devdesk-history"}
|
|
|
|
|
+ (if $fork == "fork" then {fork: true} else {} end))' \
|
|
|
|
|
--arg n "$name" --arg d "$dir" --arg id "$id" --arg fork "$fork"
|
|
|
|
|
dd_ensure_server
|
|
|
|
|
create_detached "$name" "$dir"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# --presence SESSION on|off (devdesk#26): while a DevDesk tab shows a session,
|
|
|
|
|
# its Claude sees a client-presence marker (CLAUDE_CLIENT_PRESENCE_FILE, set by
|
|
|
|
|
# devdesk-claude-run per tmux session) and Remote Control holds back phone
|
|
|
|
|
# pushes. The browser refreshes it every ~45 s; it disappears ~2 minutes after
|
|
|
|
|
# the last refresh, so pushes resume once you walk away.
|
|
|
|
|
cmd_presence() {
|
|
|
|
|
[ $# -eq 2 ] || dd_die "usage: devdesk attach --presence SESSION on|off"
|
|
|
|
|
local session=$1 state=$2 sid marker
|
|
|
|
|
dd_valid_name "$session" || dd_die "invalid session name: $session"
|
|
|
|
|
dd_server_up || return 0
|
|
|
|
|
sid=$(dd_tmux display -p -t "=$session:" '#{session_id}' 2>/dev/null) || return 0
|
|
|
|
|
marker="$DEVDESK_PRESENCE_DIR/${sid#\$}"
|
|
|
|
|
if [ "$state" = off ]; then
|
|
|
|
|
rm -f "$marker"
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
[ "$state" = on ] || dd_die "presence is on or off"
|
|
|
|
|
mkdir -p "$DEVDESK_PRESENCE_DIR" && touch "$marker"
|
|
|
|
|
# Expire it unless refreshed meanwhile.
|
|
|
|
|
# (fd 3 closed too: a reaper must not hold a caller's extra descriptors open)
|
|
|
|
|
setsid -f bash -c 'sleep 120; [ -n "$(find "$1" -newermt "-110 seconds" 2>/dev/null)" ] || rm -f "$1"' _ "$marker" \
|
|
|
|
|
</dev/null >/dev/null 2>&1 3>&- || true
|
|
|
|
|
}
|
2026-11-03 21:23:29 -05:00
|
|
|
|
|
|
|
|
# --delete-convo ID (devdesk#61): permanently delete a conversation's local files
|
|
|
|
|
# (the transcript ~/.claude/projects/<folder>/<id>.jsonl and its <id>/ folder of
|
|
|
|
|
# subagent transcripts and tool results). Refused while a running session uses
|
|
|
|
|
# it. Dormant sessions that would resume it forget it (they start fresh).
|
|
|
|
|
# Copies kept by Anthropic (Remote Control, the API) are not touched: those are
|
|
|
|
|
# deleted at claude.ai.
|
|
|
|
|
cmd_delete_convo() {
|
|
|
|
|
[ $# -eq 1 ] || dd_die "usage: devdesk attach --delete-convo ID"
|
|
|
|
|
local id=$1 file dir other
|
|
|
|
|
[[ $id =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || dd_die "bad conversation id: $id"
|
|
|
|
|
file=$(dd_convo_file "$id") || true
|
|
|
|
|
{ [ -n "$file" ] && [ -f "$file" ]; } || dd_die "no conversation $id"
|
|
|
|
|
case "$file" in "$(dd_projects_dir)"/*/"$id.jsonl") ;; *) dd_die "unexpected path: $file" ;; esac
|
|
|
|
|
for other in $(jq -rM --arg id "$id" '.sessions | to_entries[] | select(.value.claude_session_id == $id) | .key' "$DEVDESK_REGISTRY" 2>/dev/null); do
|
|
|
|
|
if dd_server_up && dd_tmux has-session -t "=$other" 2>/dev/null; then
|
|
|
|
|
dd_die "session $other is using this conversation: end it first"
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
dir=${file%.jsonl}
|
|
|
|
|
rm -f -- "$file"
|
|
|
|
|
[ ! -d "$dir" ] || rm -rf -- "$dir"
|
|
|
|
|
# shellcheck disable=SC2016 # jq variables
|
|
|
|
|
dd_registry_update '.sessions |= with_entries(if .value.claude_session_id == $id then .value |= (del(.claude_session_id) | del(.claude_source)) else . end)' \
|
|
|
|
|
--arg id "$id"
|
|
|
|
|
jq -cMn --arg id "$id" '{ok: true, deleted: $id}'
|
|
|
|
|
}
|