diff --git a/Makefile b/Makefile index 804cd46..9417b82 100644 --- a/Makefile +++ b/Makefile @@ -40,7 +40,7 @@ install: all install -m 0644 libexec/devdesk-lib.sh $(DESTDIR)$(LIBDIR)/devdesk-lib.sh install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \ libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \ - libexec/devdesk-deploy $(DESTDIR)$(LIBDIR)/ + libexec/devdesk-deploy libexec/devdesk-state-hook $(DESTDIR)$(LIBDIR)/ install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/ install -m 0644 etc/env $(DESTDIR)$(SYSCONFDIR)/devdesk/env diff --git a/bin/devdesk b/bin/devdesk index 3ed40fc..872cefa 100755 --- a/bin/devdesk +++ b/bin/devdesk @@ -6,10 +6,12 @@ # 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) +# devdesk alerts firing/pending Grafana alerts (JSON) # devdesk ls list sessions # devdesk pick interactive picker # -# The web UI calls `devdesk attach --list | --repo NAME | --new NAME [PROJECT] | --rename OLD NEW | --end NAME` +# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --upload SESSION FILE BYTES | +# --new NAME [PROJECT] | --rename OLD NEW | --end NAME` # through ttyd; those print JSON. set -euo pipefail @@ -17,7 +19,7 @@ set -euo pipefail . "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" usage() { - sed -n '2,11s/^# \{0,1\}//p' "$0" + sed -n '2,12s/^# \{0,1\}//p' "$0" } # Tab-separated running sessions: name, dir, attached clients, last activity, windows. @@ -41,11 +43,12 @@ projects_tsv() { done } -# {"": {model, used_pct, ...}} from the status-line snapshots. -context_json() { - local f - if [ -d "$DEVDESK_CONTEXT_DIR" ]; then - for f in "$DEVDESK_CONTEXT_DIR"/*.json; do +# {"": {...}} merged from the per-session JSON files in DIR +# (status-line snapshots, hook states). +per_session_json() { + local dir=$1 f + if [ -d "$dir" ]; then + for f in "$dir"/*.json; do [ -f "$f" ] || continue jq -cM --arg n "$(basename "$f" .json)" '{($n): .}' "$f" 2>/dev/null || true done @@ -59,7 +62,8 @@ cmd_list() { --argjson reg "$reg" \ --arg run "$(running_tsv)" \ --arg proj "$(projects_tsv)" \ - --argjson ctx "$(context_json)" \ + --argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \ + --argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \ --arg dflt "$DEVDESK_DEFAULT_SESSION" \ --arg root "$DEVDESK_PROJECTS_ROOT" ' def rows($s): $s | split("\n") | map(select(length > 0) | split("\t")); @@ -76,7 +80,7 @@ cmd_list() { | map({name: .key, dir: .value.dir, running: false, attached: 0, activity: null, windows: 0, claude_session_id: (.value.claude_session_id // null)})) - ) | map(. + {context: ($ctx[.name] // null)}), + ) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null)}), projects: ([{name: $dflt, dir: $root}] + [rows($proj)[] | {name: .[0], dir: .[1]} | select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}' @@ -206,6 +210,66 @@ cmd_repo() { prs: $prs}' } +# Firing and pending Grafana alert rules, most severe first. +cmd_alerts() { + if [ -z "$DEVDESK_GRAFANA_URL" ]; then + jq -cMn '{configured: false, alerts: []}' + return + fi + local auth=() raw + if [ -r "$DEVDESK_GRAFANA_TOKEN_FILE" ]; then + auth=(-H "Authorization: Bearer $(tr -d '[:space:]' <"$DEVDESK_GRAFANA_TOKEN_FILE")") + fi + if ! raw=$(curl -fsS --max-time 6 "${auth[@]}" "$DEVDESK_GRAFANA_URL/api/prometheus/grafana/api/v1/rules" 2>&1); then + jq -cMn --arg e "${raw:-no response}" '{configured: true, error: $e, alerts: []}' + return + fi + jq -cM --arg pub "$DEVDESK_GRAFANA_PUBLIC_URL" ' + def num: (tonumber? // .) | tostring; + {configured: true, checked: (now | floor), alerts: [ + .data.groups[] | .name as $group | .file as $folder | .rules[] + | select(.state == "firing" or .state == "pending") + | . as $r + | ([.alerts[]? | select(.state == "Alerting" or .state == "Pending")]) as $active + | {name, state, severity: (.labels.severity // null), group: $group, folder: $folder, + since: (.activeAt // $active[0].activeAt // null), + summary: ((.annotations.summary // .annotations.description // "") + | gsub("\\{\\{ *\\$value *\\}\\}"; ($active[0].value // "?" | num))), + instances: [$active[] | .labels + | del(.__alert_rule_uid__, .__grafana_origin__, .alertname, .grafana_folder, .__name__, .severity) + | to_entries | map("\(.key)=\(.value)") | join(" ") + | select(. != "")], + url: "\($pub)/alerting/grafana/\(.uid)/view"} + ] | sort_by(if .severity == "critical" then 0 elif .severity == "warning" then 1 else 2 end)}' <<<"$raw" +} + +# Receive a file for SESSION over the terminal: the browser waits for the ready +# line, then sends exactly ceil(BYTES/3)*4 bytes of base64 as input. +cmd_upload() { + [ $# -eq 3 ] || dd_die "usage: devdesk attach --upload SESSION FILENAME BYTES" + local session=$1 name size=$3 dir dest b64len actual + dd_valid_name "$session" || dd_die "invalid session name: $session" + { [[ $size =~ ^[0-9]+$ ]] && [ "$size" -gt 0 ] && [ "$size" -le "$DEVDESK_UPLOAD_MAX" ]; } || + dd_die "file is empty or larger than $((DEVDESK_UPLOAD_MAX / 1048576)) MB" + name=$(dd_safe_filename "$2") + dir="$DEVDESK_STATE_DIR/uploads/$session" + mkdir -p "$dir" + find "$DEVDESK_STATE_DIR/uploads" -type f -mtime +7 -delete 2>/dev/null || true + dest="$dir/$(date +%Y%m%d-%H%M%S)-$name" + # shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose + b64len=$(((size + 2) / 3 * 4)) + stty raw -echo 2>/dev/null || true + printf 'DEVDESK-UPLOAD-READY\r\n' + head -c "$b64len" | base64 -d >"$dest" 2>/dev/null || true + stty sane 2>/dev/null || true + actual=$(stat -c %s "$dest" 2>/dev/null || echo 0) + if [ "$actual" != "$size" ]; then + rm -f "$dest" + dd_die "upload incomplete ($actual of $size bytes)" + fi + jq -cMn --arg p "$dest" '{ok: true, path: $p}' +} + ok_json() { printf '{"ok":true}\n' } @@ -225,6 +289,15 @@ cmd_attach() { cmd_repo "$@" return ;; + --alerts) + cmd_alerts + return + ;; + --upload) + shift + cmd_upload "$@" + return + ;; --new | --rename | --end) local action=${1#--} shift @@ -263,6 +336,7 @@ attach | a) cmd_attach "$@" ;; new) cmd_new "$@" ;; rename | mv) cmd_rename "$@" ;; repo) cmd_repo "$@" ;; +alerts) cmd_alerts ;; ls | list) cmd_ls ;; --list) cmd_list ;; end) cmd_end "$@" ;; diff --git a/debian/changelog b/debian/changelog index a84cad3..45a9e37 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,19 @@ +devdesk (0.4.0) bookworm; urgency=medium + + * Session status at a glance: Claude hooks record whether each session is + working, waiting on you (with Claude's message) or idle. The sidebar shows + it with context %, the header shows the current session's state, and a + pulsing "needs you" chip jumps to sessions waiting for input. + * File & image drop: drag files onto the terminal or paste a screenshot. + They're uploaded through ttyd to /var/lib/devdesk/uploads// (25 MB + max, pruned after 7 days) and the path is pasted into Claude, which + attaches images itself. + * Ops Alerts panel is live: firing/pending Grafana alert rules with + severity, instances, summary and a link, plus an "Ask Claude" button that + types an investigation prompt into the current session. + + -- Jared Vititoe Wed, 07 Oct 2026 19:24:15 -0400 + devdesk (0.3.1) bookworm; urgency=medium * Auto-deploy: tagged releases trigger CT 126's webhook listener (house HMAC diff --git a/etc/env b/etc/env index 5fe38a7..32e3718 100644 --- a/etc/env +++ b/etc/env @@ -23,6 +23,12 @@ DEVDESK_DEFAULT_DIR= DEVDESK_GITEA_URL=https://code.lotusguild.org DEVDESK_GITEA_ALIASES=10.10.10.63 +# Grafana for the Ops Alerts panel (firing/pending alert rules). A Viewer +# service-account token goes in DEVDESK_GRAFANA_TOKEN_FILE (mode 600). +DEVDESK_GRAFANA_URL=http://10.10.10.49:3000 +DEVDESK_GRAFANA_PUBLIC_URL=https://dashboard.lotusguild.org +DEVDESK_GRAFANA_TOKEN_FILE=/etc/devdesk/grafana-token + # Claude Code binary and extra arguments for every session. CLAUDE_BIN=/root/.local/bin/claude CLAUDE_ARGS= diff --git a/libexec/devdesk-lib.sh b/libexec/devdesk-lib.sh index 5d9b952..43e0371 100644 --- a/libexec/devdesk-lib.sh +++ b/libexec/devdesk-lib.sh @@ -30,9 +30,14 @@ dd_load_env : "${DEVDESK_GITEA_URL:=https://code.lotusguild.org}" : "${DEVDESK_GITEA_ALIASES:=10.10.10.63}" : "${DEVDESK_GITEA_TOKEN_FILE:=/etc/devdesk/gitea-token}" +: "${DEVDESK_GRAFANA_URL:=}" +: "${DEVDESK_GRAFANA_PUBLIC_URL:=$DEVDESK_GRAFANA_URL}" +: "${DEVDESK_GRAFANA_TOKEN_FILE:=/etc/devdesk/grafana-token}" +: "${DEVDESK_UPLOAD_MAX:=26214400}" DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json" DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context" +DEVDESK_STATUS_DIR="$DEVDESK_STATE_DIR/state" DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$' dd_die() { @@ -113,15 +118,18 @@ dd_registry_set_claude() { dd_registry_remove() { dd_registry_update 'del(.sessions[$n])' --arg n "$1" - rm -f "$DEVDESK_CONTEXT_DIR/$1.json" + rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" } dd_registry_rename() { dd_registry_update 'if .sessions[$old] then .sessions[$new] = .sessions[$old] | del(.sessions[$old]) else . end' \ --arg old "$1" --arg new "$2" - if [ -f "$DEVDESK_CONTEXT_DIR/$1.json" ]; then - mv -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_CONTEXT_DIR/$2.json" - fi + local d + for d in "$DEVDESK_CONTEXT_DIR" "$DEVDESK_STATUS_DIR"; do + if [ -f "$d/$1.json" ]; then + mv -f "$d/$1.json" "$d/$2.json" + fi + done } # Project name (directory or its slug) -> absolute directory inside the projects root. @@ -147,6 +155,15 @@ dd_project_dir() { esac } +# Upload filename -> safe basename ([A-Za-z0-9._-], no leading dots, max 100 chars). +dd_safe_filename() { + local n=${1##*/} + n=${n//[^A-Za-z0-9._-]/_} + while [[ $n == .* ]]; do n=${n:1}; done + n=${n:0:100} + printf '%s' "${n:-upload}" +} + # Session name -> working directory. Sessions don't need a project: the # registry wins, then a project of the same name, then DEVDESK_DEFAULT_DIR. dd_resolve_dir() { diff --git a/libexec/devdesk-state-hook b/libexec/devdesk-state-hook new file mode 100755 index 0000000..9e87d93 --- /dev/null +++ b/libexec/devdesk-state-hook @@ -0,0 +1,40 @@ +#!/bin/bash +# Claude Code hook (wired in via claude-settings.json) that tracks what Claude is +# doing in each DevDesk session, for the sidebar's at-a-glance status: +# UserPromptSubmit, PostToolUse -> working +# Notification -> waiting (Claude needs input or a permission) +# Stop, SessionStart -> idle +# SessionEnd -> exited +# Fires on every tool call, so it stays tiny and only writes when the state changes. + +input=$(cat) + +outer=${TMUX:-} +[ "${outer%%,*}" = "${DEVDESK_SOCKET:-/run/devdesk/tmux.sock}" ] && [ -n "${TMUX_PANE:-}" ] || exit 0 +session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null) +[[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]] || exit 0 + +IFS=$'\t' read -r event message < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\t\n]"; " "))] | @tsv' <<<"$input" 2>/dev/null) +case $event in +UserPromptSubmit | PostToolUse) state=working ;; +Notification) state=waiting ;; +Stop | SessionStart) state=idle ;; +SessionEnd) state=exited ;; +*) exit 0 ;; +esac + +dir="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/state" +file="$dir/$session.json" +# Unchanged state (the common case during tool runs): nothing to write. +if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then + exit 0 +fi +mkdir -p "$dir" 2>/dev/null || exit 0 +tmp=$(mktemp "$dir/.$session.XXXXXX" 2>/dev/null) || exit 0 +if jq -cMn --arg s "$state" --arg m "$message" --arg e "$event" \ + '{state: $s, since: (now | floor), event: $e, message: (if $m == "" then null else $m end)}' >"$tmp"; then + mv -f "$tmp" "$file" +else + rm -f "$tmp" +fi +exit 0 diff --git a/share/claude-settings.json b/share/claude-settings.json index 00c475c..95e2d4b 100644 --- a/share/claude-settings.json +++ b/share/claude-settings.json @@ -8,6 +8,67 @@ "type": "command", "command": "/usr/lib/devdesk/devdesk-session-hook", "timeout": 10 + }, + { + "type": "command", + "command": "/usr/lib/devdesk/devdesk-state-hook", + "timeout": 5 + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "/usr/lib/devdesk/devdesk-state-hook", + "timeout": 5 + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "command": "/usr/lib/devdesk/devdesk-state-hook", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "/usr/lib/devdesk/devdesk-state-hook", + "timeout": 5 + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "/usr/lib/devdesk/devdesk-state-hook", + "timeout": 5 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "/usr/lib/devdesk/devdesk-state-hook", + "timeout": 5 } ] } diff --git a/tests/attach.bats b/tests/attach.bats index c8fab22..78d7b0b 100644 --- a/tests/attach.bats +++ b/tests/attach.bats @@ -246,6 +246,55 @@ claude_args() { [ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha/ops" ] } +@test "state hook tracks working, waiting and idle per session" { + start_server + devdesk new alpha + pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}') + hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; } + state() { jq -r '.sessions[] | select(.name=="alpha") | .status.state // "none"' <<<"$(devdesk attach --list)"; } + echo '{"hook_event_name":"UserPromptSubmit","prompt":"hi"}' | hook + [ "$(state)" = working ] + echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | hook + [ "$(state)" = waiting ] + [ "$(jq -r '.message' "$DEVDESK_STATE_DIR/state/alpha.json")" = "Claude needs your permission to use Bash" ] + echo '{"hook_event_name":"PostToolUse","tool_name":"Bash"}' | hook + [ "$(state)" = working ] + echo '{"hook_event_name":"Stop"}' | hook + [ "$(state)" = idle ] + echo '{"hook_event_name":"PreCompact"}' | hook + [ "$(state)" = idle ] + devdesk rename alpha beta + [ -f "$DEVDESK_STATE_DIR/state/beta.json" ] && [ ! -e "$DEVDESK_STATE_DIR/state/alpha.json" ] + devdesk end beta + [ ! -e "$DEVDESK_STATE_DIR/state/beta.json" ] +} + +@test "alerts reports unconfigured and unreachable Grafana cleanly" { + run env DEVDESK_GRAFANA_URL= devdesk alerts + [ "$status" -eq 0 ] + [ "$(jq -r '.configured' <<<"$output")" = false ] + run env DEVDESK_GRAFANA_URL=http://127.0.0.1:9 devdesk attach --alerts + [ "$status" -eq 0 ] + [ "$(jq -r '.configured' <<<"$output")" = true ] + [ "$(jq -r '.error | length > 0' <<<"$output")" = true ] +} + +@test "upload stores the exact bytes under a sanitised name" { + head -c 50001 /dev/urandom >"$TMP/in.bin" + run bash -c "base64 -w0 '$TMP/in.bin' | devdesk attach --upload alpha '../../etc/My Shot (1).png' 50001" + [ "$status" -eq 0 ] + path=$(tail -n1 <<<"$output" | jq -r .path) + [[ $path == "$DEVDESK_STATE_DIR/uploads/alpha/"*-My_Shot__1_.png ]] + cmp "$TMP/in.bin" "$path" + run bash -c "printf short | devdesk attach --upload alpha x.bin 100" + [ "$status" -ne 0 ] + [[ $output == *"upload incomplete"* ]] + run devdesk attach --upload alpha x.bin 999999999999 + [ "$status" -ne 0 ] + run devdesk attach --upload ../x x.bin 10 + [ "$status" -ne 0 ] +} + @test "web actions run through attach and answer with JSON" { start_server run devdesk attach --new web1 alpha diff --git a/web/index.template.html b/web/index.template.html index 5aeb36b..056212a 100644 --- a/web/index.template.html +++ b/web/index.template.html @@ -20,10 +20,12 @@
— +
+ connecting @@ -41,6 +43,8 @@
+ +
-
-
Ops Alerts v2
+
+
Ops Alerts
-
    -
  • - - No alert source connected - -
  • -
-

Firing Grafana alerts, each with an “Ask Claude” prompt.

+
  • Loading…
+

-
diff --git a/web/src/alerts.js b/web/src/alerts.js new file mode 100644 index 0000000..4b95192 --- /dev/null +++ b/web/src/alerts.js @@ -0,0 +1,64 @@ +// Ops Alerts panel: firing/pending Grafana alert rules (`devdesk attach --alerts`), +// each with an "Ask Claude" button that types an investigation prompt into the +// current session (without pressing Enter). + +import { el } from './sessions.js'; + +const SEVERITY = { + critical: 'lt-chip-critical', + warning: 'lt-chip-warn', +}; + +function age(iso) { + const t = Date.parse(iso || ''); + if (Number.isNaN(t)) return ''; + const s = Math.max(0, (Date.now() - t) / 1000); + if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + return `${Math.floor(s / 86400)}d`; +} + +export function investigatePrompt(alert) { + const where = alert.instances?.length ? ` on ${alert.instances.join('; ')}` : ''; + return `Investigate the ${alert.state} Grafana alert "${alert.name}"` + + ` (${alert.severity || 'no severity'}, ${alert.folder} / ${alert.group}${where}, since ${alert.since}).` + + ` Summary: ${alert.summary || 'n/a'}. Use the grafana MCP server to look at the rule and the related` + + ' metrics, find the cause, and tell me what you would change before changing anything.'; +} + +export function renderAlerts(root, data) { + const list = root.querySelector('.dd-alert-list'); + const note = root.querySelector('.dd-alerts-note'); + const count = root.querySelector('.dd-alert-count'); + const alerts = data?.alerts ?? []; + count.textContent = alerts.length ? String(alerts.length) : ''; + count.className = `lt-badge lt-badge-sm dd-alert-count${alerts.some((a) => a.severity === 'critical') ? ' lt-badge-p1' : alerts.length ? ' lt-badge-p2' : ''}`; + + if (!data) { + root.dataset.state = 'empty'; + list.replaceChildren(el('li', { class: 'dd-empty' }, 'Loading…')); + note.textContent = ''; + return; + } + if (!data.configured) { + root.dataset.state = 'empty'; + list.replaceChildren(el('li', { class: 'dd-empty' }, 'Grafana is not configured.')); + note.textContent = 'Set DEVDESK_GRAFANA_URL and a token file in /etc/devdesk/env.local.'; + return; + } + root.dataset.state = 'live'; + note.textContent = data.error ? `Grafana unreachable: ${data.error}` : ''; + if (!alerts.length) { + list.replaceChildren(el('li', { class: 'dd-all-clear' }, + el('span', { class: 'lt-dot lt-dot-up' }), data.error ? 'No data' : 'All clear: nothing firing')); + return; + } + list.replaceChildren(...alerts.map((a) => el('li', { class: `dd-alert is-${a.state}` }, + el('div', { class: 'dd-alert-head' }, + el('span', { class: `lt-chip ${SEVERITY[a.severity] || 'lt-chip-info'}` }, a.state === 'pending' ? 'pending' : (a.severity || 'alert')), + el('a', { class: 'dd-alert-name', href: a.url, target: '_blank', rel: 'noopener', title: `${a.folder} / ${a.group}` }, a.name), + el('span', { class: 'dd-muted dd-alert-age' }, age(a.since))), + a.instances?.length ? el('div', { class: 'dd-muted dd-alert-instances' }, a.instances.join(' · ')) : null, + a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null, + el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a) }, 'Ask Claude')))); +} diff --git a/web/src/connection.js b/web/src/connection.js index b0933ec..f9a1c7a 100644 --- a/web/src/connection.js +++ b/web/src/connection.js @@ -124,6 +124,73 @@ export async function fetchSessionList() { return parseListOutput(await runCommand([LIST_ARG])); } +// Firing/pending Grafana alerts (`devdesk attach --alerts`). +export async function fetchAlerts() { + return parseListOutput(await runCommand(['--alerts'])); +} + +function toBase64(bytes) { + let binary = ''; + for (let i = 0; i < bytes.length; i += 0x8000) { + binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000)); + } + return btoa(binary); +} + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Upload a file into the session's upload folder through ttyd: wait for the +// server's ready line, stream the file as base64 input, read back {ok, path}. +export async function uploadFile(session, file, onProgress) { + const token = await fetchToken(); + const bytes = new Uint8Array(await file.arrayBuffer()); + const b64 = toBase64(bytes); + const name = (file.name || 'pasted-image.png').slice(-100); + return new Promise((resolve, reject) => { + const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(bytes.length)]), ['tty']); + socket.binaryType = 'arraybuffer'; + const decoder = new TextDecoder(); + let output = ''; + let sending = false; + const timer = setTimeout(() => socket.close(), 30000 + bytes.length / 50); + const send = async () => { + const chunk = 48 * 1024; + for (let i = 0; i < b64.length; i += chunk) { + while (socket.bufferedAmount > 1 << 20) await sleep(20); + if (socket.readyState !== WebSocket.OPEN) return; + socket.send(encodeInput(b64.slice(i, i + chunk))); + onProgress?.(Math.min(1, (i + chunk) / b64.length)); + } + }; + socket.onopen = () => socket.send(encodeInit(token, 240, 24)); + socket.onmessage = (event) => { + const { type, payload } = decodeServer(event.data); + if (type !== Server.OUTPUT) return; + output += decoder.decode(payload, { stream: true }); + if (!sending && output.includes('DEVDESK-UPLOAD-READY')) { + sending = true; + send(); + } + }; + socket.onerror = () => {}; + socket.onclose = () => { + clearTimeout(timer); + const text = stripAnsi(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, '')); + try { + const result = parseListOutput(text); + if (result.ok && result.path) { + resolve(result.path); + return; + } + } catch { + // fall through to the error message + } + const message = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop(); + reject(new Error((message || 'upload failed').replace(/^devdesk:\s*/, ''))); + }; + }); +} + // Git + Gitea CI status for a session's folder (`devdesk attach --repo NAME`). export async function fetchRepo(session) { return parseListOutput(await runCommand(['--repo', session])); diff --git a/web/src/main.js b/web/src/main.js index 11834aa..55acfd6 100644 --- a/web/src/main.js +++ b/web/src/main.js @@ -1,8 +1,11 @@ /* global __DEVDESK_VERSION__ */ import { createTerminal, loadFonts } from './term.js'; -import { TtydConnection, fetchRepo, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js'; +import { + TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError, +} from './connection.js'; import { parseTitle, sessionFromSearch } from './protocol.js'; -import { SessionsPanel, rememberSession } from './sessions.js'; +import { SessionsPanel, rememberSession, sessionStatus } from './sessions.js'; +import { renderAlerts } from './alerts.js'; import { renderContext } from './context.js'; import { renderRepo } from './repo.js'; import { bindAskClaude } from './widgets.js'; @@ -34,6 +37,8 @@ const ui = { wsLabel: $('ws-label'), sso: $('sso-badge'), sessionChip: $('session-chip'), + stateChip: $('state-chip'), + attention: $('attention-chip'), paneTitle: $('pane-title'), overlay: $('overlay'), overlayTitle: $('overlay-title'), @@ -95,6 +100,107 @@ function showContext() { const data = sessionsPanel?.data; const current = data?.sessions?.find((s) => s.name === state.session); renderContext($('widget-context'), state.session, current?.context ?? null); + showSessionState(data, current); +} + +// Header: what Claude is doing here, and which other sessions are waiting on you. +function showSessionState(data, current) { + const status = current ? sessionStatus(current) : null; + const key = status && ['working', 'waiting', 'idle'].includes(status.key) ? status.key : ''; + ui.stateChip.dataset.state = key; + ui.stateChip.textContent = key ? status.label : ''; + ui.stateChip.title = current?.status?.message || ''; + const waiting = (data?.sessions ?? []).filter((s) => s.name !== state.session && sessionStatus(s).key === 'waiting'); + ui.attention.classList.toggle('is-active', waiting.length > 0); + ui.attention.textContent = waiting.length === 1 ? `${waiting[0].name} needs you` : `${waiting.length} need you`; + ui.attention.title = waiting.map((s) => `${s.name}: ${s.status?.message || 'waiting'}`).join('\n'); + ui.attention.dataset.target = waiting[0]?.name || ''; +} + +let alertsBusy = false; +async function refreshAlerts() { + if (alertsBusy) return; + alertsBusy = true; + try { + renderAlerts($('widget-alerts'), await fetchAlerts()); + } catch { + // Keep the last rendering; the next refresh retries. + } finally { + alertsBusy = false; + } +} + +// ---------------------------------------------------------------- file drop / paste + +const uploadUi = $('upload-status'); +let uploadHideTimer = 0; +function uploadMessage(text, isError = false, sticky = false) { + clearTimeout(uploadHideTimer); + uploadUi.hidden = false; + uploadUi.textContent = text; + uploadUi.classList.toggle('is-error', isError); + if (!sticky) uploadHideTimer = setTimeout(() => { uploadUi.hidden = true; }, isError ? 8000 : 4000); +} + +async function attachFiles(files) { + if (!files.length) return; + if (!state.session || !state.conn?.isOpen) { + uploadMessage('Open a session first: files are attached to the current session.', true); + return; + } + const session = state.session; + const paths = []; + for (const file of files) { + try { + const path = await uploadFile(session, file, (p) => + uploadMessage(`Uploading ${file.name || 'image'}… ${Math.round(p * 100)}%`, false, true)); + paths.push(path); + } catch (error) { + uploadMessage(`${file.name || 'file'}: ${error.message}`, true); + return; + } + } + if (session !== state.session) return; + // A pasted image path makes Claude attach the image itself. + term.paste(`${paths.join(' ')} `); + term.focus(); + uploadMessage(paths.length === 1 ? `Attached ${paths[0]}` : `Attached ${paths.length} files`); +} + +function wireUploads() { + const frame = document.querySelector('.dd-terminal-frame'); + const overlay = $('drop-overlay'); + let depth = 0; + const hasFiles = (event) => [...(event.dataTransfer?.types ?? [])].includes('Files'); + frame.addEventListener('dragenter', (event) => { + if (!hasFiles(event)) return; + event.preventDefault(); + depth += 1; + $('drop-session').textContent = state.session || 'a session'; + overlay.hidden = false; + }); + frame.addEventListener('dragover', (event) => { + if (hasFiles(event)) event.preventDefault(); + }); + frame.addEventListener('dragleave', () => { + depth = Math.max(0, depth - 1); + if (!depth) overlay.hidden = true; + }); + frame.addEventListener('drop', (event) => { + if (!hasFiles(event)) return; + event.preventDefault(); + depth = 0; + overlay.hidden = true; + attachFiles([...event.dataTransfer.files]); + }); + // Pasting a screenshot / copied file: upload it instead of pasting text. + document.addEventListener('paste', (event) => { + const files = [...(event.clipboardData?.files ?? [])]; + if (!files.length) return; + event.preventDefault(); + event.stopPropagation(); + attachFiles(files); + }, true); } // Repo / CI panel for the current session; notifies when HEAD's CI finishes. @@ -361,9 +467,16 @@ function wireChrome() { document.addEventListener('visibilitychange', () => { if (!document.hidden) retrySoon(); }); + // Session states feed the header chips too, so refresh even with the sidebar closed. setInterval(() => { - if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) sessionsPanel.refresh(); + if (!document.hidden) sessionsPanel.refresh(); }, 15 * 1000); + setInterval(() => { + if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshAlerts(); + }, 30 * 1000); + ui.attention.addEventListener('click', () => { + if (ui.attention.dataset.target) connect(ui.attention.dataset.target); + }); } // ---------------------------------------------------------------- boot @@ -435,6 +548,9 @@ async function boot() { term.focus(); }); wireChrome(); + wireUploads(); + sessionsPanel.refresh(); + refreshAlerts(); connect(state.session); } diff --git a/web/src/sessions.js b/web/src/sessions.js index 399f063..6aee11f 100644 --- a/web/src/sessions.js +++ b/web/src/sessions.js @@ -57,6 +57,20 @@ export function el(tag, attrs = {}, ...children) { return node; } +// What Claude is doing in a session, from the devdesk-state-hook snapshot. +// Returns { key, label, dot } for display; key is one of +// waiting | working | idle | exited | running | dormant. +export function sessionStatus(s) { + if (!s?.running) return { key: 'dormant', label: 'dormant', dot: 'lt-dot-idle' }; + switch (s.status?.state) { + case 'waiting': return { key: 'waiting', label: 'needs you', dot: 'lt-dot-down' }; + case 'working': return { key: 'working', label: 'working', dot: 'lt-dot-warn' }; + case 'idle': return { key: 'idle', label: 'idle', dot: 'lt-dot-up' }; + case 'exited': return { key: 'exited', label: 'Claude exited', dot: 'lt-dot-idle' }; + default: return { key: 'running', label: 'running', dot: 'lt-dot-up' }; + } +} + export class SessionsPanel { // hooks: onOpen(name), onPicker(), onEnded(name), onRenamed(from, to), onData(data) constructor({ list, form, nameInput, select, refreshButton, pickerButton, status, hooks }) { @@ -156,15 +170,19 @@ export class SessionsPanel { renderSession(s) { const isCurrent = s.name === this.current; - const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : ''; + const status = sessionStatus(s); + const ctx = s.context?.used_pct != null ? `ctx ${Math.floor(s.context.used_pct)}%` : ''; + const since = s.status?.since ? ago(s.status.since) : ago(s.activity); const meta = s.running - ? `${s.attached} attached · ${ago(s.activity)}${ctx}` + ? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, s.attached ? `${s.attached} open` : ''] + .filter(Boolean).join(' · ') : 'dormant · resumes Claude'; + const title = [s.dir, status.key === 'waiting' && s.status?.message].filter(Boolean).join('\n'); const iconButton = (label, glyph, onclick, extra = '') => el('button', { class: `dd-icon-btn ${extra}`, type: 'button', title: label, 'aria-label': `${label} ${s.name}`, onclick }, glyph); - return el('li', { class: `dd-session-item${isCurrent ? ' is-current' : ''}${s.running ? '' : ' is-dormant'}` }, - el('button', { class: 'dd-session-open', type: 'button', title: s.dir || '', onclick: () => this.hooks.onOpen(s.name) }, - el('span', { class: `lt-dot ${s.running ? 'lt-dot-up' : 'lt-dot-idle'}` }), + return el('li', { class: `dd-session-item is-${status.key}${isCurrent ? ' is-current' : ''}` }, + el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) }, + el('span', { class: `lt-dot ${status.dot}` }), el('span', { class: 'dd-session-name' }, s.name), el('span', { class: 'dd-session-meta' }, meta)), el('span', { class: 'dd-session-actions' }, diff --git a/web/styles.css b/web/styles.css index 72a36c2..29e3a9d 100644 --- a/web/styles.css +++ b/web/styles.css @@ -211,6 +211,24 @@ body.dd-sidebar-open .dd-sidebar { .dd-session-item.is-recent .dd-session-name { color: var(--text-secondary); font-weight: 400; } .dd-session-item.is-dormant .dd-session-name { color: var(--text-secondary); } +.dd-session-item.is-waiting { border-color: rgba(255, 45, 85, 0.45); background: var(--accent-red-dim); } +.dd-session-item.is-waiting .dd-session-meta { color: var(--accent-red); } +.dd-session-item.is-working .dd-session-meta { color: var(--accent-amber); } + +/* Header chips: current session's Claude state, and sessions needing you. */ +.dd-state-chip { display: none; } +.dd-state-chip[data-state="working"] { display: inline-flex; color: var(--accent-amber); background: var(--accent-amber-dim); } +.dd-state-chip[data-state="waiting"] { display: inline-flex; color: var(--accent-red); background: var(--accent-red-dim); text-shadow: var(--glow-red); } +.dd-state-chip[data-state="idle"] { display: inline-flex; color: var(--accent-green); background: var(--accent-green-dim); } +.dd-attention { + display: none; + cursor: pointer; + font-family: var(--font-mono); + color: var(--accent-red); + background: var(--accent-red-dim); + animation: lt-pulse 1.6s ease-in-out infinite; +} +.dd-attention.is-active { display: inline-flex; } .dd-session-actions { display: flex; @@ -275,24 +293,63 @@ body.dd-sidebar-open .dd-sidebar { gap: var(--space-sm); } -/* ---- stub widgets ------------------------------------------------------------ */ +/* ---- ops alerts --------------------------------------------------------------- */ +.dd-alert-count { margin-left: auto; } +.dd-alert-count:empty { display: none; } +.dd-alert-list { display: flex; flex-direction: column; gap: var(--space-sm); } .dd-alert { display: flex; - align-items: center; - gap: var(--space-sm); + flex-direction: column; + gap: 3px; + padding: 6px 8px; + border: 1px solid var(--border-dim); + border-left: 2px solid var(--accent-amber); font-size: 0.72rem; - color: var(--text-secondary); } -.dd-alert-text { flex: 1 1 auto; } +.dd-alert:has(.lt-chip-critical) { border-left-color: var(--accent-red); } +.dd-alert-head { display: flex; align-items: center; gap: var(--space-sm); min-width: 0; } +.dd-alert-name { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.dd-alert-age { margin-left: auto; white-space: nowrap; } +.dd-alert-instances { overflow-wrap: anywhere; } +.dd-alert-summary { color: var(--text-secondary); } +.dd-alert .dd-ask { align-self: flex-start; margin-top: 2px; } +.dd-all-clear { display: flex; align-items: center; gap: var(--space-sm); color: var(--accent-green); font-size: 0.74rem; } .dd-context-grid { margin-top: var(--space-sm); } -[data-state="stub"] .lt-section-body, -#widget-context[data-state="empty"] .lt-kv-grid, -#widget-repo[data-state="empty"] .lt-kv-grid { opacity: 0.6; } +[data-state="stub"] .lt-section-body { opacity: 0.75; } -.lt-status a { color: inherit; text-shadow: none; } -.dd-widget .lt-kv-val { overflow-wrap: anywhere; } +/* ---- file drop / upload ------------------------------------------------------ */ +.dd-drop-overlay { + position: absolute; + inset: 0; + z-index: 6; + display: flex; + align-items: center; + justify-content: center; + border: 2px dashed var(--accent-cyan); + background: rgba(0, 212, 255, 0.08); + color: var(--accent-cyan); + font-size: 0.9rem; + pointer-events: none; +} +.dd-drop-overlay[hidden] { display: none; } +.dd-upload-status { + position: absolute; + right: 14px; + bottom: 16px; + z-index: 6; + max-width: 70%; + padding: 6px 10px; + border: 1px solid var(--accent-cyan-border); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 0.72rem; + box-shadow: var(--box-glow-cyan); + overflow-wrap: anywhere; +} +.dd-upload-status[hidden] { display: none; } +.dd-upload-status.is-error { border-color: rgba(255, 45, 85, 0.5); color: var(--accent-red); } /* ---- overlay ----------------------------------------------------------------- */ .dd-overlay {