Compare commits

...
6 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 bf8821f3f9 feat: v0.9.5 context in tokens as well as percent
build-deb / Lint, test, package (push) Successful in 2m17s
build-deb / Deploy to CT 126 (push) Successful in 8s
build-deb / Notify on failure (push) Skipped
Claude's footer reads "ctx 34% 346k/1M"; the Sessions panel and the
session switcher use the same label via ctxLabel(). Closes #43

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-11 23:07:04 -04:00
jaredandClaude Opus 5.5 fe4df10210 fix: v0.9.4 inbox badge clears when seen
build-deb / Lint, test, package (push) Successful in 2m23s
build-deb / Deploy to CT 126 (push) Failing after 3m15s
build-deb / Notify on failure (push) Successful in 3s
Opening the bell marks its events seen; viewing a session (or returning
to the tab) reads that session's events. The badge now drops to what is
live instead of climbing with every background "finished". Closes #42

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-11 22:45:46 -04:00
jaredandClaude Opus 5.5 963d2f5413 fix: v0.9.3 plan usage instead of API-price cost; larger footer
build-deb / Lint, test, package (push) Successful in 2m15s
build-deb / Deploy to CT 126 (push) Successful in 7s
build-deb / Notify on failure (push) Skipped
- Footer shows "5h N%" when Claude sends rate_limits (Pro/Max); the
  dollar figure is an API-price estimate, so it is kept only for API-key
  billing. Sessions tooltip follows suit. Closes #40
- Bottom row: 40px, larger hints, bold 1rem usage meters with wider bars;
  narrow windows drop hints instead of clipping the meters. Closes #41
- bats setup unsets inherited DevDesk env so tests pass inside DevDesk

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-11 22:07:39 -04:00
jaredandClaude Opus 5.5 c0f475bffe feat: v0.9.2 Ask Claude on an alert starts an investigation session
build-deb / Lint, test, package (push) Successful in 2m23s
build-deb / Deploy to CT 126 (push) Successful in 30s
build-deb / Notify on failure (push) Skipped
- Ops Alerts → Ask Claude now calls `devdesk attach --investigate SESSION UID`.
  The server looks the alert up, writes the prompt and starts Claude in a new
  alert-<name> session with it as the first prompt, already sent. Prompts are
  stored in prompts/<session>.txt and consumed once by devdesk-claude-run.
  A session that is already running is just opened.
- The prompt has the rule, query, summary, details and labels, and where it
  runs: the CT/VM id, the node it is on now, status and HA state, and
  `ssh <node> pct exec <id> --`. It reminds Claude that HA guests move
  (`devdesk guests <id>`), and gives aggregate rules a hint.
- `devdesk guests`: Proxmox inventory over SSH (DEVDESK_PVE_NODES). pvesh
  gives the cluster view; each node gives lxc-info and guest-agent IPs.
  Cached in guests.json, keeping last-seen IPs for stopped guests; --alerts
  refreshes it in the background every 5 minutes.
- Alerts carry uid, query, description and targets, matched by instance
  IP/host or by name; each card shows the target.
- cmd_alerts moves to libexec/devdesk-ops.sh.

Closes #39

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 11:24:49 -04:00
jaredandClaude Opus 5.5 afdbf6eaec docs: frame headers in the NPM Advanced template
build-deb / Lint, test, package (push) Successful in 2m44s
build-deb / Deploy to CT 126 (push) Skipped
build-deb / Notify on failure (push) Skipped
X-Frame-Options DENY and CSP frame-ancestors 'none', inside location /
(it has its own add_header, so server-level headers aren't inherited).
Live on proxy host 58 since 2026-10-11.

Refs #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:46:09 -04:00
jaredandClaude Opus 5.5 74927a7640 fix: v0.9.1 robustness cleanups
build-deb / Lint, test, package (push) Successful in 3m53s
build-deb / Deploy to CT 126 (push) Successful in 10s
build-deb / Notify on failure (push) Skipped
- run_resume falls back to a new conversation only when the transcript file
  is gone; other failures end at the menu with the recorded id intact.
- The SessionStart hook resolves the session name under the registry lock;
  rename holds it across tmux rename-session and the registry move
  (dd_registry_locked, nestable).
- dd_load_env parses KEY=value like systemd instead of sourcing the files.
- devdesk-deploy: DPkg::Lock::Timeout, explicit FAILED lines on stdout and in
  the log; the hook answers synchronously and CI checks the HTTP status.
  logrotate for the deploy log.
- postinst drops duplicate terminal-features entries; hooks sweep stale
  temp files.

Closes #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:01:51 -04:00
26 changed files with 960 additions and 183 deletions
+7 -2
View File
@@ -157,6 +157,8 @@ jobs:
steps:
# CT 126's webhook listener runs /usr/lib/devdesk/devdesk-deploy, which
# waits for this version in the apt registry and upgrades the package.
# The hook answers when the install is done (HTTP 500 if it failed), so
# this job's result is the deploy's result.
- name: Trigger devdesk-deploy webhook
env:
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
@@ -164,12 +166,15 @@ jobs:
run: |
PAYLOAD="{\"ref\":\"${GIT_REF}\"}"
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
curl -sf --connect-timeout 10 \
code=$(curl -sS --connect-timeout 10 --max-time 900 -o /tmp/deploy.out -w '%{http_code}' \
-X POST \
-H "Content-Type: application/json" \
-H "X-Gitea-Signature: ${SIG}" \
-d "$PAYLOAD" \
"http://10.10.10.58:9000/hooks/devdesk-deploy"
"http://10.10.10.58:9000/hooks/devdesk-deploy") || code=000
cat /tmp/deploy.out 2>/dev/null || true
echo "webhook: HTTP $code"
[ "$code" = 200 ]
notify-failure:
name: Notify on failure
+2 -1
View File
@@ -41,7 +41,8 @@ install: all
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 libexec/devdesk-state-hook libexec/devdesk-notify-matrix libexec/devdesk-guard $(DESTDIR)$(LIBDIR)/
install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh libexec/devdesk-history.sh $(DESTDIR)$(LIBDIR)/
install -m 0644 libexec/devdesk-files.sh libexec/devdesk-inbox.sh libexec/devdesk-history.sh libexec/devdesk-ops.sh \
$(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 -d $(DESTDIR)$(SHAREDIR)/assets
+6 -4
View File
@@ -43,9 +43,11 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- **File & image drop:** drag files onto the terminal or paste a screenshot.
- They're uploaded through ttyd to `/var/lib/devdesk/uploads/<session>/` (25 MB max, pruned after 7 days), and the path is pasted into Claude.
- For images, Claude attaches them itself.
- **Ops Alerts panel:** firing and pending Grafana alert rules, each with a link to the rule.
- **Ask Claude** types an investigation prompt into the current session without sending it.
- Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`.
- **Ops Alerts panel:** firing and pending Grafana alert rules, each with a link to the rule and the Proxmox guest or node it's about, e.g. `→ CT 110 draupnir · monitor-02 · stopped`.
- **Ask Claude** starts Claude in a new session (`alert-<name>`) with the investigation already sent. The prompt has the rule, its query, summary and labels, and where it runs: the CT/VM id, the node it's on right now, and the `ssh <node> pct exec <id> --` line. Clicking it again just opens that session.
- Guests are matched by the alert's `instance` IP (or host name), else by name in the job or alert ("by name"). Aggregate rules with no labels (`count(up == 0)`) get a hint to run the inner query instead.
- **`devdesk guests [ID|IP|NAME]`** lists Proxmox CTs/VMs with the node each runs on now, status, HA state and IPs. Claude is told to use it before acting, because HA guests move. Stopped guests keep the IP they had when last seen running.
- Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`. For the guest lookup, set `DEVDESK_PVE_NODES` to a few Proxmox nodes reachable by root SSH key. VM IPs need the QEMU guest agent.
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log.
- **Files** (Ctrl+Shift+E), laid out like VS Code: an explorer tree (git colours, gitignored entries dimmed) and an editor with tabs (a click opens a preview tab, a double-click keeps it), **Go to file** (Ctrl+P in the pane), markdown previews (sanitized, with relative images), images, and a Changes view of uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt. Tabs and open folders are remembered per session. Paths can't leave the session folder.
- **Editing:** text files are editable; ● marks unsaved tabs; Ctrl+S (or Save) writes atomically and keeps the file's mode and owner. If the file changed on disk since you opened it (Claude edited it), saving asks: Compare (a diff of disk vs yours), Overwrite or Cancel; a change on disk while you edit shows a banner instead of replacing your text. Unsaved edits are kept as drafts in the browser, so a reload or a session switch doesn't lose them. + creates a file (folders as needed). The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
@@ -54,7 +56,7 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- **Search past conversations** (⋯ menu, palette): full text over every Claude conversation on the host; **Resume** it in a new session or **Fork** it.
- **Plan usage:** the 5-hour and weekly windows as meters in the footer (warning at 90 %); a **PR** chip on sessions whose branch has a pull request.
- **Quiet phone at the desk:** while a tab shows a session, Remote Control holds back phone pushes for it (client-presence marker, expires ~2 min after you leave).
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar (cost in the tooltip), and Claude's own footer shows a one-line summary.
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use, plan usage and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar and Claude's own footer shows a one-line summary. On a Pro/Max plan that summary shows 5-hour plan usage; the dollar figure Claude reports there is an API-price estimate, not a bill, so it appears only for API-key billing.
- **Light mode:** the terminal switches palettes with the page. DevDesk starts Claude with its `dark-ansi` theme so Claude draws with the terminal's 16 colours, and the light palette maps bright white to ink; tmux and the status line use ANSI colours too. A session picks up Claude's theme the next time Claude starts in it (`/exit`, then ⏎ resumes the same conversation).
- **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer.
- **SSH:** `devdesk attach <name>`, `devdesk new <name> [project]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`, `devdesk repo <name>`, `devdesk alerts`.
+23 -42
View File
@@ -7,11 +7,12 @@
# 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 guests [ID|IP|NAME] Proxmox CTs/VMs: the node each runs on now, state, IPs
# devdesk ls list sessions
# devdesk pick interactive picker
#
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --upload SESSION FILE BYTES |
# --new NAME [PROJECT] | --rename OLD NEW | --end NAME | --existing NAME`
# 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`
# through ttyd; those print JSON.
set -euo pipefail
@@ -23,9 +24,11 @@ set -euo pipefail
. "$DEVDESK_LIBEXEC/devdesk-inbox.sh"
# shellcheck source=libexec/devdesk-history.sh
. "$DEVDESK_LIBEXEC/devdesk-history.sh"
# shellcheck source=libexec/devdesk-ops.sh
. "$DEVDESK_LIBEXEC/devdesk-ops.sh"
usage() {
sed -n '2,12s/^# \{0,1\}//p' "$0"
sed -n '2,13s/^# \{0,1\}//p' "$0"
}
# Tab-separated running sessions: name, dir, attached clients, last activity, windows.
@@ -141,11 +144,17 @@ cmd_rename() {
if dd_server_up && dd_tmux has-session -t "=$old" 2>/dev/null; then running=1; fi
[ "$running" = 1 ] || [ -n "$(dd_registry_get "$old" dir)" ] || dd_die "no session named '$old'"
! session_exists "$new" || dd_die "a session named '$new' already exists"
if [ "$running" = 1 ]; then
dd_tmux rename-session -t "=$old" "$new"
dd_tmux set-environment -t "=$new" DEVDESK_SESSION "$new"
dd_registry_locked rename_locked "$old" "$new" "$running" || dd_die "could not rename '$old'"
}
# tmux and the registry change together, under the registry lock (see
# devdesk-session-hook).
rename_locked() {
if [ "$3" = 1 ]; then
dd_tmux rename-session -t "=$1" "$2" || return 1
dd_tmux set-environment -t "=$2" DEVDESK_SESSION "$2"
fi
dd_registry_rename "$old" "$new"
dd_registry_rename "$1" "$2"
}
cmd_end() {
@@ -224,41 +233,6 @@ 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 raw
if ! raw=$({
if [ -r "$DEVDESK_GRAFANA_TOKEN_FILE" ]; then
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token
printf 'header = "Authorization: Bearer %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GRAFANA_TOKEN_FILE")"
fi
} | curl -fsS --max-time 6 -K - "$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() {
@@ -317,6 +291,12 @@ cmd_attach() {
dd_linger
return
;;
--investigate)
shift
cmd_investigate "$@"
dd_linger
return
;;
--upload)
shift
cmd_upload "$@"
@@ -402,6 +382,7 @@ new) cmd_new "$@" ;;
rename | mv) cmd_rename "$@" ;;
repo) cmd_repo "$@" ;;
alerts) cmd_alerts ;;
guests) cmd_guests "$@" ;;
ls | list) cmd_ls ;;
--list) cmd_list ;;
end) cmd_end "$@" ;;
+75
View File
@@ -1,3 +1,78 @@
devdesk (0.9.5) bookworm; urgency=medium
* Context use is shown in tokens as well as percent (#43): Claude's
footer shows "ctx 34% 346k/1M", and the Sessions panel and session
switcher show "ctx 34% · 346k/1M".
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 23:07:00 -0400
devdesk (0.9.4) bookworm; urgency=medium
* The inbox badge no longer only grows (#42). Opening the bell marks its
events seen, leaving just what is live (sessions waiting on you,
firing alerts). Viewing a session reads the events it reported while
you were away.
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 22:46:00 -0400
devdesk (0.9.3) bookworm; urgency=medium
* Claude's footer shows 5-hour plan use instead of a dollar figure on a
Pro/Max subscription (#40). Claude Code's cost is an API-price
estimate, not a bill; it is still shown for API-key billing. The
Sessions tooltip follows the same rule.
* The bottom row is larger and easier to read (#41): 40px tall, larger
hints, and 5h/week usage meters in bold monospace with wider bars.
Narrow windows drop the least-used hints instead of clipping the
meters.
* Tests clear inherited DevDesk variables, so they pass when run from
inside a DevDesk session.
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 22:08:00 -0400
devdesk (0.9.2) bookworm; urgency=medium
* Ops Alerts → Ask Claude starts an investigation (#39):
- It opens a new session (alert-<name>) and starts Claude with the
prompt already sent, instead of typing it into the current session.
Clicking again opens the running session.
- The server writes the prompt from Grafana: rule, query, summary,
details, labels, and where the alert runs. That is the Proxmox CT/VM
id, the node it is on right now, its state and HA state, and the
`ssh <node> pct exec <id> --` line.
- Each alert card shows that guest or node.
* `devdesk guests [ID|IP|NAME]`: Proxmox guests with their current node,
status, HA state and IPs, read over SSH from DEVDESK_PVE_NODES. CT IPs
come from lxc-info, VM IPs from the guest agent. Cached in
/var/lib/devdesk/guests.json; stopped guests keep their last-seen IPs.
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 11:23:53 -0400
devdesk (0.9.1) bookworm; urgency=medium
* Robustness (#12):
- A resume that fails (an early Ctrl+C, a network error) no longer
replaces the session's recorded conversation with a new one: Claude
starts fresh only when the conversation's file is really gone;
otherwise the menu offers to try again.
- The SessionStart hook reads the session name under the registry lock
that rename holds, so renaming during /clear or a restart can't leave
the conversation id under the old name.
- Env files are parsed the way systemd parses them instead of being
sourced: values are literal (no $ expansion or command substitution),
and bad lines are reported and skipped.
- Deploys wait for the dpkg lock (120 s), log an explicit "deploy
FAILED" line, and the webhook answers with the result, so CI's deploy
job fails (and alerts) when the install fails. The deploy log is
rotated monthly.
- postinst removes the duplicate tmux terminal-features entries left by
upgrades before 0.3.1.
- Temp files of hooks killed mid-write are swept.
- NPM now sends X-Frame-Options DENY and CSP frame-ancestors 'none' for
dev.lotusguild.org (proxy config, not part of the package).
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 01:01:33 -0400
devdesk (0.9.0) bookworm; urgency=medium
* Search past conversations (#24): every Claude conversation on this host
+8
View File
@@ -0,0 +1,8 @@
/var/log/devdesk-deploy.log {
monthly
rotate 6
compress
delaycompress
missingok
notifempty
}
+7
View File
@@ -13,6 +13,13 @@ configure)
# Apply tmux.conf changes to a running server without restarting it.
if [ -S /run/devdesk/tmux.sock ]; then
tmux -N -S /run/devdesk/tmux.sock source-file /usr/share/devdesk/tmux.conf >/dev/null 2>&1 || true
# Before 0.3.1 every upgrade appended another copy of DevDesk's
# terminal-features entry; tmux.conf now sets index 90. Drop the copies.
tmux -N -S /run/devdesk/tmux.sock show -s terminal-features 2>/dev/null |
awk '$2 == "xterm-256color:RGB" && $1 != "terminal-features[90]" { print $1 }' |
while read -r opt; do
tmux -N -S /run/devdesk/tmux.sock set -su "$opt" >/dev/null 2>&1 || true
done
fi
;;
esac
+5 -1
View File
@@ -37,7 +37,9 @@ Then run `authelia validate-config --config /etc/authelia/configuration.yml && s
- **Pi-hole:** add `"10.10.10.27 dev.lotusguild.org"` to `dns.hosts` in `pve-infra/containers/163-pihole/etc/pihole/pihole.toml`. A push auto-deploys it, and LAN clients then reach NPM directly instead of hairpinning.
## 5. Auto-deploy on release
A `v*` tag makes CI publish the package, then send a signed webhook (the house HMAC pattern) to CT 126's existing `webhook` listener on :9000. The listener runs `/usr/lib/devdesk/devdesk-deploy <ref>`, which waits for that version in the apt registry and upgrades only `devdesk` with `--force-confold`. Running Claude sessions are untouched. The log is `/var/log/devdesk-deploy.log`.
A `v*` tag makes CI publish the package, then send a signed webhook (the house HMAC pattern) to CT 126's existing `webhook` listener on :9000. The listener runs `/usr/lib/devdesk/devdesk-deploy <ref>`, which waits for that version in the apt registry and upgrades only `devdesk` with `--force-confold`. Running Claude sessions are untouched. The log is `/var/log/devdesk-deploy.log` (rotated monthly).
The hook answers when the deploy has finished, with the script's last line, and with HTTP 500 if it failed. CI's **Deploy to CT 126** job therefore fails when the install fails, and the failure alert goes to Matrix.
**Setup, done once:**
1. Generate a secret.
@@ -48,6 +50,8 @@ A `v*` tag makes CI publish the package, then send a signed webhook (the house H
"execute-command": "/usr/lib/devdesk/devdesk-deploy",
"command-working-directory": "/",
"pass-arguments-to-command": [{"source": "payload", "name": "ref"}],
"include-command-output-in-response": true,
"include-command-output-in-response-on-error": true,
"trigger-rule": {"and": [
{"match": {"type": "payload-hash-sha256", "secret": "<WEBHOOK_SECRET>",
"parameter": {"source": "header", "name": "X-Gitea-Signature"}}},
+5
View File
@@ -9,7 +9,12 @@
include /snippets/authelia-location.conf;
location / {
# Headers go here, not at server level: a location with its own add_header
# (HSTS) doesn't inherit the server's.
add_header Strict-Transport-Security $hsts_header always;
# No other site may frame the terminal (clickjacking).
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "frame-ancestors 'none'" always;
auth_request /authelia;
auth_request_set $user $upstream_http_remote_user;
+10 -2
View File
@@ -1,8 +1,9 @@
# DevDesk defaults, shipped by the package. Don't edit: put your changes in
# /etc/devdesk/env.local (same KEY=value format, read after this file), so
# package upgrades never prompt about this file.
# Read by systemd (EnvironmentFile=) and by the devdesk scripts. Plain KEY=value
# lines only: systemd does not support inline comments.
# Read by systemd (EnvironmentFile=) and by the devdesk scripts, the same way:
# plain KEY=value lines, values taken literally (no $VAR, no $(…)), optionally
# in quotes. No inline comments. Other lines are ignored with a warning.
# The ttyd credential lives in /etc/devdesk/secret.env (generated on install).
# Port ttyd listens on (NPM proxies dev.lotusguild.org here).
@@ -29,6 +30,13 @@ 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
# Proxmox nodes to ask where guests run (`devdesk guests`, and the CT/VM and node
# shown on each alert and given to Claude by "Ask Claude"). Space-separated SSH
# hosts; the first that answers gives the cluster view, then every online node is
# asked for its containers' IPs, by node name. Needs root SSH keys to the nodes.
# Empty = off.
DEVDESK_PVE_NODES=
# Claude Code binary and extra arguments for every session.
CLAUDE_BIN=/root/.local/bin/claude
CLAUDE_ARGS=
+19 -13
View File
@@ -36,18 +36,34 @@ common_args() {
}
run_new() {
local args
local args prompt=() file="$DEVDESK_STATE_DIR/prompts/$NAME.txt"
mapfile -d '' args < <(common_args)
"$CLAUDE_BIN" -n "$NAME" "${args[@]}"
# A first prompt left by `devdesk attach --investigate` (Ops Alerts → Ask
# Claude, devdesk#39): sent once, right after the session name so options
# in CLAUDE_ARGS can't swallow it.
if [ -s "$file" ]; then
prompt=("$(cat "$file")")
rm -f "$file"
fi
"$CLAUDE_BIN" -n "$NAME" "${prompt[@]}" "${args[@]}"
}
run_resume() {
local id args started rc fork=()
local id args fork=()
id=$(dd_registry_get "$NAME" claude_session_id)
if [ -z "$id" ]; then
run_new
return
fi
# Start fresh only when the conversation's file is really gone. Any other
# failure (an early Ctrl+C, a network error) leaves the recorded id alone
# and ends at the menu, where ⏎ tries again (devdesk#12).
if [ -z "$(dd_convo_file "$id")" ]; then
printf "%sConversation %s is no longer in Claude's history — starting a new one.%s\n" "$MUTED" "$id" "$RESET"
sleep 1
run_new
return
fi
mapfile -d '' args < <(common_args)
# "Fork" from conversation search (devdesk#24): continue as a new
# conversation, once; the SessionStart hook then records the new id.
@@ -56,17 +72,7 @@ run_resume() {
# shellcheck disable=SC2016 # $n is a jq variable
dd_registry_update 'del(.sessions[$n].fork)' --arg n "$NAME" 2>/dev/null || true
fi
started=$SECONDS
"$CLAUDE_BIN" --resume "$id" "${fork[@]}" "${args[@]}"
rc=$?
# A resume that fails straight away means the transcript is gone; start fresh.
if [ "$rc" -ne 0 ] && [ $((SECONDS - started)) -lt 10 ]; then
printf '%sCould not resume conversation %s — starting a new one.%s\n' "$MUTED" "$id" "$RESET"
sleep 1
run_new
return
fi
return "$rc"
}
menu() {
+32 -9
View File
@@ -3,6 +3,9 @@
# listener (hook "devdesk-deploy") when CI publishes a v* tag; the tag's ref is
# passed as $1. Safe for running sessions: package upgrades restart only
# devdesk.service (ttyd), never devdesk-tmux.service where Claude lives.
#
# The hook runs this synchronously and returns its output, with HTTP 500 when
# it fails, so CI's "Deploy to CT 126" job shows the real result (devdesk#12).
set -euo pipefail
LOG=${DEVDESK_DEPLOY_LOG:-/var/log/devdesk-deploy.log}
@@ -12,11 +15,28 @@ log() {
printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$LOG"
}
exec 9>/run/devdesk-deploy.lock
flock -w 900 9 || {
log "another deploy is still running; giving up"
# The outcome goes to the log and to stdout (the webhook response).
say() {
log "$*"
printf '%s\n' "$*"
}
fail() {
say "deploy FAILED: $*"
exit 1
}
on_error() {
local rc=$?
say "deploy FAILED: line $1 exited $rc (details in $LOG)"
exit "$rc"
}
trap 'on_error $LINENO' ERR
# Wait for dpkg/apt rather than fail when unattended-upgrades or a manual apt
# run holds the lock.
APT_OPTS=(-o DPkg::Lock::Timeout=120 -o Dpkg::Use-Pty=0)
exec 9>/run/devdesk-deploy.lock
flock -w 900 9 || fail "another deploy is still running"
want=${1:-}
want=${want#refs/tags/}
@@ -25,7 +45,7 @@ before=$(dpkg-query -W -f='${Version}' devdesk 2>/dev/null || echo none)
log "=== deploy: installed $before, release ${want:-latest}"
refresh() {
apt-get update -q -o Dir::Etc::sourcelist="$SOURCES" -o Dir::Etc::sourceparts=- \
apt-get update -q "${APT_OPTS[@]}" -o Dir::Etc::sourcelist="$SOURCES" -o Dir::Etc::sourceparts=- \
-o APT::Get::List-Cleanup=0 >>"$LOG" 2>&1
}
@@ -41,14 +61,17 @@ for _ in $(seq 1 18); do
done
if [ -n "$want" ] && [ "$candidate" != "$want" ]; then
log "registry still offers $candidate, not $want; giving up"
exit 1
fail "the registry still offers ${candidate:-nothing}, not $want"
fi
if [ "$candidate" = "$before" ]; then
log "already at $before; nothing to do"
say "already at $before; nothing to do"
exit 0
fi
DEBIAN_FRONTEND=noninteractive apt-get install -y -q --only-upgrade \
DEBIAN_FRONTEND=noninteractive apt-get install -y -q --only-upgrade "${APT_OPTS[@]}" \
-o Dpkg::Options::=--force-confold devdesk </dev/null >>"$LOG" 2>&1
log "devdesk $before -> $(dpkg-query -W -f='${Version}' devdesk)"
after=$(dpkg-query -W -f='${Version}' devdesk)
if [ -n "$want" ] && [ "$after" != "$want" ]; then
fail "installed $after, not $want"
fi
say "devdesk $before -> $after"
-9
View File
@@ -2,15 +2,6 @@
# Past conversations (devdesk#24) and client presence (devdesk#26), sourced by
# bin/devdesk and reached through `devdesk attach --search|--resume-convo|--presence`.
dd_projects_dir() {
printf '%s/projects\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
}
dd_convo_file() {
[[ $1 =~ ^[0-9a-f-]{36}$ ]] || return 1
compgen -G "$(dd_projects_dir)/*/$1.jsonl" | head -n1
}
# --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,
+1 -2
View File
@@ -50,8 +50,7 @@ cmd_answer() {
dd_transcript() {
local id
id=$(dd_registry_get "$1" claude_session_id)
[[ $id =~ ^[0-9a-f-]{36}$ ]] || return 1
compgen -G "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/projects/*/$id.jsonl" | head -n1
dd_convo_file "$id"
}
# --peek SESSION: what the session is doing and the last exchange, from the end
+77 -22
View File
@@ -8,13 +8,34 @@ DEVDESK_ENV=${DEVDESK_ENV:-/etc/devdesk/env}
# Local overrides go in env.local: it is never shipped, so upgrades don't prompt about it.
DEVDESK_ENV_LOCAL=${DEVDESK_ENV_LOCAL:-${DEVDESK_ENV}.local}
# The env files are read by systemd (EnvironmentFile=) as well, so they are
# parsed here the way systemd parses them, never sourced: KEY=value lines, the
# value taken literally (no $ expansion, no command substitution), optionally
# in matching quotes. Anything else is reported and skipped (devdesk#12).
dd_load_env() {
local f
for f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do
if [ -r "$f" ]; then
# shellcheck source=/dev/null
. "$f"
fi
local _dd_f _dd_line _dd_n _dd_key _dd_val
for _dd_f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do
[ -r "$_dd_f" ] || continue
_dd_n=0
while IFS= read -r _dd_line || [ -n "$_dd_line" ]; do
_dd_n=$((_dd_n + 1))
_dd_line=${_dd_line%$'\r'}
[[ $_dd_line =~ ^[[:space:]]*([#\;].*)?$ ]] && continue
if ! [[ $_dd_line =~ ^[[:space:]]*([A-Z_][A-Z0-9_]*)=(.*)$ ]]; then
printf 'devdesk: %s:%s: not a KEY=value line, ignored\n' "$_dd_f" "$_dd_n" >&2
continue
fi
_dd_key=${BASH_REMATCH[1]}
_dd_val=${BASH_REMATCH[2]}
_dd_val=${_dd_val%"${_dd_val##*[![:space:]]}"} # trailing blanks, like systemd
if [[ $_dd_val =~ ^\"(.*)\"$ || $_dd_val =~ ^\'(.*)\'$ ]]; then
_dd_val=${BASH_REMATCH[1]}
elif [[ $_dd_val == [\"\']* ]]; then
printf 'devdesk: %s:%s: unmatched quote, ignored\n' "$_dd_f" "$_dd_n" >&2
continue
fi
printf -v "$_dd_key" '%s' "$_dd_val"
done <"$_dd_f"
done
}
dd_load_env
@@ -33,6 +54,7 @@ dd_load_env
: "${DEVDESK_GRAFANA_URL:=}"
: "${DEVDESK_GRAFANA_PUBLIC_URL:=$DEVDESK_GRAFANA_URL}"
: "${DEVDESK_GRAFANA_TOKEN_FILE:=/etc/devdesk/grafana-token}"
: "${DEVDESK_PVE_NODES:=}"
: "${DEVDESK_UPLOAD_MAX:=26214400}"
: "${DEVDESK_PUBLIC_URL:=}"
: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}"
@@ -101,26 +123,59 @@ dd_registry_get() {
jq -Mr --arg n "$1" --arg f "$2" '.sessions[$n][$f] // empty' "$DEVDESK_REGISTRY" 2>/dev/null
}
# dd_registry_update JQ_FILTER [jq args...] — read-modify-write under flock.
dd_registry_update() {
local filter=$1
shift
# dd_registry_locked CMD [ARGS...] — run CMD holding the registry lock, so a
# step that reads tmux and then writes the registry (the SessionStart hook,
# rename) can't interleave with another. Nests: an update inside reuses it.
dd_registry_locked() {
if [ -n "${DD_REGISTRY_LOCKED:-}" ]; then
"$@"
return
fi
mkdir -p "$DEVDESK_STATE_DIR"
(
flock -w 5 9 || exit 1
[ -s "$DEVDESK_REGISTRY" ] || printf '{"sessions":{}}\n' >"$DEVDESK_REGISTRY"
local tmp
tmp=$(mktemp "$DEVDESK_REGISTRY.XXXXXX") || exit 1
if jq "$@" "$filter" "$DEVDESK_REGISTRY" >"$tmp"; then
chmod 600 "$tmp"
mv -f "$tmp" "$DEVDESK_REGISTRY"
else
rm -f "$tmp"
exit 1
fi
DD_REGISTRY_LOCKED=1
"$@"
) 9>"$DEVDESK_REGISTRY.lock"
}
# dd_registry_update JQ_FILTER [jq args...] — read-modify-write under flock.
dd_registry_update() {
dd_registry_locked _dd_registry_apply "$@"
}
_dd_registry_apply() {
local filter=$1 tmp
shift
[ -s "$DEVDESK_REGISTRY" ] || printf '{"sessions":{}}\n' >"$DEVDESK_REGISTRY"
tmp=$(mktemp "$DEVDESK_REGISTRY.XXXXXX") || return 1
if jq "$@" "$filter" "$DEVDESK_REGISTRY" >"$tmp"; then
chmod 600 "$tmp"
mv -f "$tmp" "$DEVDESK_REGISTRY"
else
rm -f "$tmp"
return 1
fi
}
# Claude's conversation files: ~/.claude/projects/<folder>/<id>.jsonl.
dd_projects_dir() {
printf '%s/projects\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
}
dd_convo_file() {
[[ $1 =~ ^[0-9a-f-]{36}$ ]] || return 1
compgen -G "$(dd_projects_dir)/*/$1.jsonl" | head -n1
}
# Temp files of writers that were killed mid-write (hooks run under Claude's
# timeouts): .<session>.XXXXXX and registry.json.XXXXXX, older than 10 minutes.
dd_sweep_tmp() {
find "$DEVDESK_CONTEXT_DIR" "$DEVDESK_STATUS_DIR" -maxdepth 1 -type f -name '.*.??????' -mmin +10 -delete 2>/dev/null
find "$DEVDESK_STATE_DIR" -maxdepth 1 -type f -name 'registry.json.??????' -mmin +10 -delete 2>/dev/null
return 0
}
dd_registry_set_dir() {
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, updated: $t})' \
--arg n "$1" --arg d "$2" --arg t "$(date -Is)"
@@ -134,7 +189,7 @@ dd_registry_set_claude() {
dd_registry_remove() {
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt" \
"$DEVDESK_STATE_DIR/commands/$1.jsonl" "$DEVDESK_STATE_DIR/tags/$1.json"
"$DEVDESK_STATE_DIR/commands/$1.jsonl" "$DEVDESK_STATE_DIR/tags/$1.json" "$DEVDESK_STATE_DIR/prompts/$1.txt"
}
dd_registry_rename() {
@@ -147,7 +202,7 @@ dd_registry_rename() {
fi
done
local f
for f in "edits/%s.txt" "commands/%s.jsonl" "tags/%s.json"; do
for f in "edits/%s.txt" "commands/%s.jsonl" "tags/%s.json" "prompts/%s.txt"; do
# shellcheck disable=SC2059 # the pattern is ours
if [ -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" ]; then
# shellcheck disable=SC2059
+300
View File
@@ -0,0 +1,300 @@
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
# Ops (devdesk#39): Grafana alerts, the Proxmox guests they are about, and
# "Ask Claude" investigations in a session of their own. Sourced by bin/devdesk.
DEVDESK_GUESTS="$DEVDESK_STATE_DIR/guests.json"
# ---------------------------------------------------------------- Proxmox guests
# Which CT/VM has which IP, and on which node it runs right now: guests are HA
# and move between nodes, so this is read live from the cluster, over SSH to the
# nodes in DEVDESK_PVE_NODES (empty = off). Cached in guests.json, where a
# stopped guest keeps the IPs it had when it was last seen running.
# Cluster view, from any one node: guests (id, name, node, status, HA) and nodes.
DD_PVE_INVENTORY='pvesh get /cluster/resources --type vm --output-format json && echo && pvesh get /cluster/status --output-format json'
# Per node: its own addresses, then those of its running guests ("<id>\t<IPs>").
IFS= read -r -d '' DD_PVE_NODE_SCAN <<'EOF' || true
printf '%s\t%s\n' "$(hostname)" "$(hostname -I)"
for id in $(lxc-ls --running 2>/dev/null); do
printf '%s\t%s\n' "$id" "$(lxc-info -n "$id" -iH 2>/dev/null | tr '\n' ' ')"
done
# VMs only through the QEMU guest agent, where one is configured; slow, so all at once.
for f in /var/run/qemu-server/*.pid; do
id=${f##*/}
id=${id%.pid}
case $id in '' | *[!0-9]*) continue ;; esac
grep -qE '^agent: *(1|enabled=1)' "/etc/pve/qemu-server/$id.conf" 2>/dev/null || continue
printf '%s\t%s\n' "$id" "$(timeout 3 qm guest cmd "$id" network-get-interfaces 2>/dev/null |
grep -oE '"ip-address" *: *"[0-9.]+"' | grep -oE '[0-9.]+' | tr '\n' ' ')" &
done
wait
EOF
dd_pve_ssh() {
local host=$1
shift
timeout 12 "${DEVDESK_PVE_SSH:-ssh}" -o BatchMode=yes -o ConnectTimeout=4 "$host" "$@"
}
# Refresh guests.json. Returns non-zero if no node answered (the cache stays).
dd_pve_scan() {
[ -n "${DEVDESK_PVE_NODES:-}" ] || return 1
local host inv="" tmp node ip pids=() rc=0
for host in $DEVDESK_PVE_NODES; do
inv=$(dd_pve_ssh "$host" "$DD_PVE_INVENTORY" 2>/dev/null) && jq -es 'length == 2' >/dev/null 2>&1 <<<"$inv" && break
inv=""
done
[ -n "$inv" ] || return 1
tmp=$(mktemp -d) || return 1
# Every online node at once: by name, or by its cluster address if the name doesn't answer.
while IFS=$'\t' read -r node ip; do
{ dd_pve_ssh "$node" "$DD_PVE_NODE_SCAN" || dd_pve_ssh "$ip" "$DD_PVE_NODE_SCAN"; } >"$tmp/$node" 2>/dev/null </dev/null &
pids+=($!)
done < <(jq -rs '.[1][] | select(.type == "node" and .online == 1) | [.name, .ip] | @tsv' <<<"$inv")
[ ${#pids[@]} -eq 0 ] || wait "${pids[@]}" 2>/dev/null || true
mkdir -p "$DEVDESK_STATE_DIR"
# Scan lines: the first is "<node>\t<its IPs>", then "<CT id>\t<its IPs>".
if { cat "$tmp"/* 2>/dev/null || true; } | jq -cMRs --slurpfile inv <(jq -s . <<<"$inv") \
--slurpfile old <(cat "$DEVDESK_GUESTS" 2>/dev/null || echo '{}') --arg src "$host" '
def v4: [splits("[[:space:]]+") | select(test("^[0-9]{1,3}(\\.[0-9]{1,3}){3}$") and (startswith("127.") | not))];
(split("\n") | map(select(length > 0) | split("\t"))) as $rows
| ([$rows[] | select(.[0] | test("^[0-9]+$")) | {key: .[0], value: (.[1] // "" | v4)}] | from_entries) as $live
| ([$rows[] | select(.[0] | test("^[0-9]+$") | not) | {key: .[0], value: (.[1] // "" | v4)}] | from_entries) as $nodeips
| ([$old[0].guests[]? | {key: (.id | tostring), value: .ips}] | from_entries) as $prev
| {updated: (now | floor), source: $src,
nodes: [$inv[0][1][] | select(.type == "node")
| {name, online: (.online == 1), ips: (([.ip] + ($nodeips[.name] // [])) | map(select(. != null)) | unique)}],
guests: [$inv[0][0][] | select(.vmid != null) | (.vmid | tostring) as $id
| ($live[$id] // []) as $now
| {id: .vmid, type, name, node, status, ha: (.hastate // null),
ips: (if ($now | length) > 0 then $now else ($prev[$id] // []) end),
ips_live: (($now | length) > 0)}]
| sort_by(.id)}' >"$tmp/out" && [ -s "$tmp/out" ]; then
chmod 644 "$tmp/out"
mv -f "$tmp/out" "$DEVDESK_GUESTS"
else
rc=1
fi
rm -rf "$tmp"
return $rc
}
# Refresh, one scan at a time: "wait" for a running one (then scan again), else
# skip when one is already running.
dd_pve_refresh() {
mkdir -p "$DEVDESK_STATE_DIR"
if [ "${1:-}" = wait ]; then
(flock -w 30 9 || exit 1; dd_pve_scan) 9>"$DEVDESK_GUESTS.lock"
else
(flock -n 9 || exit 0; dd_pve_scan) 9>"$DEVDESK_GUESTS.lock"
fi
}
# Seconds since guests.json was written (large when there is none).
dd_guests_age() {
local m
m=$(stat -c %Y "$DEVDESK_GUESTS" 2>/dev/null) || { echo 999999; return; }
echo $(($(date +%s) - m))
}
# jq: locate the guest or node a host/label is about, given the guests.json
# document as $g. A target: {kind: "ct"|"vm"|"node", id, name, node, status, ha,
# ip, by: "ip"|"host"|"name"}.
DD_LOCATE_JQ='
def kindof: if .type == "qemu" then "vm" else "ct" end;
def as_guest: {kind: kindof, id, name, node, status, ha};
def as_node: {kind: "node", name, node: .name, status: (if .online then "online" else "offline" end)};
def isv4: test("^[0-9]{1,3}(\\.[0-9]{1,3}){3}$");
# "http://10.0.0.1:8080/x", "10.0.0.1:9100", "host.example.org:9100" -> the host
def hostpart: sub("^[A-Za-z][A-Za-z0-9+.-]*://"; "") | sub("/.*$"; "") | sub("^[^@]*@"; "") | sub(":[0-9]+$"; "") | ascii_downcase;
def norm: ascii_downcase | gsub("[^a-z0-9]+"; "-");
def by_host($g; $h):
if ($h | isv4) then
([$g.nodes[]? | select(.ips | index([$h])) | as_node + {ip: $h, by: "ip"}]
+ [$g.guests[]? | select(.ips | index([$h])) | as_guest + {ip: $h, by: "ip", ip_seen: (.ips_live | not)}]) | first
else
($h | split(".")[0]) as $s
| ([$g.nodes[]? | select((.name | ascii_downcase) == $s) | as_node + {by: "host"}]
+ [$g.guests[]? | select((.name | ascii_downcase) == $s) | as_guest + {by: "host"}]) | first
end;
# A guest or node whose name appears as whole words in $v ("draupnir-node",
# "compute-storage-01-ceph", "Draupnir Down"); the longest name wins.
def by_name($g; $v):
("-" + ($v | norm) + "-") as $t
| [($g.nodes[]? | as_node), ($g.guests[]? | as_guest)]
| map(select((.name // "" | norm) as $n | ($n | length) >= 4 and ($t | contains("-" + $n + "-"))))
| sort_by(-(.name | length)) | first | if . then . + {by: "name"} else empty end;
# Targets of one alert (an element of the alerts list below): by each label
# set: its instance (IP or host name), else its job; with no labels at all, by the
# name of the alert itself ("Draupnir Down").
def targets($g):
if ($g | type) != "object" then [] else
.name as $alert
| [.labelsets[] | (.instance // "" | hostpart) as $h
| if $h != "" then (by_host($g; $h) // by_name($g; .job // "") // {kind: "unknown", ip: $h})
elif .job then by_name($g; .job)
else empty end]
| if length == 0 then [by_name($g; $alert)] else . end
| map(select(. != null)) | unique_by([.kind, .id, .name, .ip]) | .[:6]
end;
'
# ---------------------------------------------------------------- alerts
# Firing and pending Grafana alert rules, most severe first, each with the
# guests/nodes it is about (from the guest cache, refreshed in the background).
cmd_alerts() {
if [ -z "$DEVDESK_GRAFANA_URL" ]; then
jq -cMn '{configured: false, alerts: []}'
return
fi
local raw
if ! raw=$({
if [ -r "$DEVDESK_GRAFANA_TOKEN_FILE" ]; then
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token
printf 'header = "Authorization: Bearer %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GRAFANA_TOKEN_FILE")"
fi
} | curl -fsS --max-time 6 -K - "$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
if [ -n "${DEVDESK_PVE_NODES:-}" ] && [ "$(dd_guests_age)" -gt 300 ]; then
setsid -f "$0" guests --refresh </dev/null >/dev/null 2>&1 3>&- || true
fi
jq -cM --arg pub "$DEVDESK_GRAFANA_PUBLIC_URL" --slurpfile g <(cat "$DEVDESK_GUESTS" 2>/dev/null || echo null) "$DD_LOCATE_JQ"'
def num: (tonumber? // .) | tostring;
($g[0]) as $guests
| {configured: true, checked: (now | floor), pve: ($guests != null), alerts: [
.data.groups[] | .name as $group | .file as $folder | .rules[]
| select(.state == "firing" or .state == "pending")
| ([.alerts[]? | select(.state == "Alerting" or .state == "Pending")]) as $active
| {uid, name, state, severity: (.labels.severity // null), group: $group, folder: $folder,
query: (.query // null),
since: (.activeAt // $active[0].activeAt // null),
summary: ((.annotations.summary // .annotations.description // "")
| gsub("\\{\\{ *\\$value *\\}\\}"; ($active[0].value // "?" | num))),
description: (if .annotations.summary then (.annotations.description // null) else null end),
labelsets: [$active[] | .labels
| del(.__alert_rule_uid__, .__grafana_origin__, .alertname, .grafana_folder, .__name__, .severity)
| select(length > 0)],
url: "\($pub)/alerting/grafana/\(.uid)/view"}
| .instances = [.labelsets[] | to_entries | map("\(.key)=\(.value)") | join(" ")]
| .targets = targets($guests)
] | sort_by(if .severity == "critical" then 0 elif .severity == "warning" then 1 else 2 end)}' <<<"$raw"
}
# ---------------------------------------------------------------- guests command
# devdesk guests [--json] [--cached] [FILTER...]: Proxmox guests with their node,
# state and IPs, fresh from the cluster (the cache if no node answers).
# FILTER matches an id, an IP, or part of a name or node.
cmd_guests() {
local json="" cached="" filters=()
while [ $# -gt 0 ]; do
case $1 in
--json) json=1 ;;
--cached) cached=1 ;;
--refresh)
dd_pve_refresh
return 0
;;
-*) dd_die "usage: devdesk guests [--json] [--cached] [ID|IP|NAME...]" ;;
*) filters+=("$1") ;;
esac
shift
done
[ -n "${DEVDESK_PVE_NODES:-}" ] || dd_die "no Proxmox nodes configured (DEVDESK_PVE_NODES in /etc/devdesk/env.local)"
if [ -z "$cached" ] && ! dd_pve_refresh wait; then
[ -s "$DEVDESK_GUESTS" ] || dd_die "no Proxmox node answered ($DEVDESK_PVE_NODES)"
printf 'devdesk: no Proxmox node answered; showing what was known %s minutes ago\n' "$(($(dd_guests_age) / 60))" >&2
fi
[ -s "$DEVDESK_GUESTS" ] || dd_die "nothing known yet: run devdesk guests without --cached"
local select='[.guests[] | . as $x | select(($f | length) == 0 or any($f[]; . as $q
| ($x.id | tostring) == $q or ($x.ips | index([$q])) or ($x.name | ascii_downcase | contains($q | ascii_downcase))
or ($x.node | ascii_downcase | contains($q | ascii_downcase))))]'
local f
f=$(printf '%s\n' "${filters[@]}" | jq -R . | jq -cs 'map(select(length > 0))')
if [ -n "$json" ]; then
jq -cM --argjson f "$f" "{updated, nodes, guests: $select}" "$DEVDESK_GUESTS"
return
fi
jq -rM --argjson f "$f" "$select"' as $g
| if ($g | length) == 0 then "no matching guests" else
(["ID", "TYPE", "NAME", "NODE", "STATUS", "HA", "IP"] | @tsv),
($g[] | [(.id | tostring), (if .type == "qemu" then "vm" else "ct" end), .name, .node, .status, (.ha // "-"),
((.ips | join(",")) + (if (.ips | length) > 0 and (.ips_live | not) then " (last seen)" else "" end) | if . == "" then "-" else . end)] | @tsv)
end' "$DEVDESK_GUESTS" | column -t -s $'\t'
}
# ---------------------------------------------------------------- investigate
# The opening prompt for an investigation of alert $a (one element of the
# alerts output), with $since already readable.
DD_PROMPT_JQ='
def ref: if .kind == "ct" then "CT \(.id) \"\(.name)\"" elif .kind == "vm" then "VM \(.id) \"\(.name)\"" else "Proxmox node \(.name)" end;
def state: [(if .status then .status else empty end), (if .ha then "HA state \(.ha)" else empty end)] | join(", ");
def how:
if .kind == "ct" then "commands inside it: `ssh \(.node) pct exec \(.id) -- <command>`"
elif .kind == "vm" then "commands inside it (needs the guest agent): `ssh \(.node) qm guest exec \(.id) -- <command>`"
else "reach it with `ssh \(.name)`" end;
def where:
if .kind == "unknown" then "- \(.ip) is not a Proxmox guest or node that DevDesk knows (`devdesk guests` lists them all)."
elif .kind == "node" then "- \(if .ip then "\(.ip) is " else "" end)\(ref)\(if .by == "name" then " (probably: matched by name)" else "" end), \(.status); \(how)"
else "- \(if .by == "name" then "Probably " else "" end)\(if .ip then "\(.ip) is " else "" end)\(ref) on node \(.node)"
+ "\(if .by == "name" then " (matched by name, not by IP)" elif .ip_seen then " (its IP when last seen running)" else "" end), \(state); \(how)"
end;
$a
| ([.targets[] | select(.kind == "ct" or .kind == "vm")] | first) as $guest
| [
"Investigate this Grafana alert and find the cause. Look first and change nothing yet: tell me what you found and what you would change, then wait for my go-ahead.",
"",
"Alert: \(.name) (\(.severity // "no severity"), \(.state) since \($since))",
"Rule: \(.folder) / \(.group), uid \(.uid). Look at it with the grafana MCP server: \(.url)",
(if .query then "Query: `\(.query)`" else empty end),
(if .summary != "" then "Summary: \(.summary)" else empty end),
(if .description then "Details: \(.description)" else empty end),
(if (.instances | length) > 0 then "Firing for: \(.instances | join("; "))"
else "Firing for: no labels. The rule aggregates, so it does not say what it is about: run the inner query to find the series"
+ (if $pve then ", then `devdesk guests <IP>` shows which Proxmox guest each one is and where it runs." else "." end) end),
(if $pve and (.targets | length) > 0 then
("", "Where it runs (Proxmox, looked up just now):"),
(.targets[] | where),
(if $guest then "Guests are HA and can migrate between nodes, so check again before acting: `devdesk guests \($guest.id)` shows where it is now." else empty end)
else empty end)
] | join("\n")'
# --investigate SESSION RULE_UID: start Claude in a new session SESSION with an
# investigation of that alert as its first prompt. An existing session is left as
# it is: {"ok":true,"session":…,"started":false}.
cmd_investigate() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --investigate SESSION ALERT_RULE_UID"
local name=$1 uid=$2 alerts alert since prompt dir pve=false
dd_valid_name "$name" || dd_die "invalid session name: $name"
[[ $uid =~ ^[A-Za-z0-9_-]{1,64}$ ]] || dd_die "invalid alert rule id: $uid"
if dd_server_up && dd_tmux has-session -t "=$name" 2>/dev/null; then
jq -cMn --arg s "$name" '{ok: true, session: $s, started: false}'
return
fi
[ -n "$DEVDESK_GRAFANA_URL" ] || dd_die "Grafana is not configured"
# Where the guests are right now (HA moves them); the cache if no node answers.
[ -z "${DEVDESK_PVE_NODES:-}" ] || dd_pve_refresh wait || true
alerts=$(cmd_alerts)
alert=$(jq -cM --arg uid "$uid" '.alerts[] | select(.uid == $uid)' <<<"$alerts" | head -n1)
if [ -z "$alert" ]; then
dd_die "$(jq -rM 'if .error then "Grafana unreachable: \(.error)" else "that alert is no longer firing" end' <<<"$alerts")"
fi
since=$(jq -rM '.since // empty' <<<"$alert")
since=$(date -d "$since" '+%a %-d %b %H:%M %Z' 2>/dev/null || echo "${since:-an unknown time}")
[ -s "$DEVDESK_GUESTS" ] && [ -n "${DEVDESK_PVE_NODES:-}" ] && pve=true
prompt=$(jq -rnM --argjson a "$alert" --arg since "$since" --argjson pve "$pve" "$DD_PROMPT_JQ") || dd_die "could not write the prompt"
# devdesk-claude-run sends this once, as the new conversation's first prompt.
mkdir -p "$DEVDESK_STATE_DIR/prompts"
(umask 077 && printf '%s\n' "$prompt" >"$DEVDESK_STATE_DIR/prompts/$name.txt")
# A new conversation, even if an earlier investigation used this name (that
# one stays in Search past conversations).
dd_registry_update 'if .sessions[$n] then .sessions[$n] |= del(.claude_session_id, .claude_source, .fork) else . end' --arg n "$name"
dir=$(dd_resolve_dir "$name")
dd_ensure_server
create_detached "$name" "$dir"
jq -cMn --arg s "$name" '{ok: true, session: $s, started: true}'
}
+11 -5
View File
@@ -7,14 +7,20 @@
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" || exit 0
session=$(dd_current_session)
[ -n "$session" ] || exit 0
dd_valid_name "$session" || exit 0
input=$(cat)
id=$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null)
source=$(jq -r '.source // empty' <<<"$input" 2>/dev/null)
[ -n "$id" ] || exit 0
dd_registry_set_claude "$session" "$id" "$source" 2>/dev/null
# The session's name is read under the registry lock: a rename holds it too,
# so the id can't land under the old name after the entry moved.
# shellcheck disable=SC2317 # called through dd_registry_locked
record() {
local session
session=$(dd_current_session)
if [ -z "$session" ] || ! dd_valid_name "$session"; then return 0; fi
dd_registry_set_claude "$session" "$id" "$source"
}
dd_registry_locked record 2>/dev/null
dd_sweep_tmp
exit 0
+12 -4
View File
@@ -43,13 +43,21 @@ if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
fi
fi
# Footer line: session · model · context % · cost. ANSI colour numbers, not RGB,
# so the browser's dark or light palette applies (devdesk#31).
# 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,
# not a bill, so show the 5-hour plan use instead and keep $ for API-key billing.
jq -rM --arg s "$session" '
def c($sgr; $t): "\u001b[\($sgr)m\($t)\u001b[0m";
def pct: if . == null then "—" else "\(. | floor)%" end;
def tok: if . >= 1000000 then "\(. / 100000 | round / 10)M" elif . >= 1000 then "\(. / 1000 | round)k" else "\(.)" end;
def ctxtok: .context_window | (.current_usage | if . then (.input_tokens // 0) + (.cache_creation_input_tokens // 0)
+ (.cache_read_input_tokens // 0) else null end) as $u
| if $u == null then "" else " \($u | tok)\(if .context_window_size then "/\(.context_window_size | tok)" else "" end)" end;
[ (if $s != "" then c("33"; "◈ \($s)") else empty end),
c("36"; .model.display_name // "Claude"),
c("37"; "ctx \(.context_window.used_percentage | pct)"),
c("37"; "$\((.cost.total_cost_usd // 0) * 100 | round / 100)")
c("37"; "ctx \(.context_window.used_percentage | pct)\(ctxtok)"),
(if .rate_limits.five_hour.used_percentage != null
then c("37"; "5h \(.rate_limits.five_hour.used_percentage | pct)")
else c("37"; "$\((.cost.total_cost_usd // 0) * 100 | round / 100)") end)
] | join(c("90"; " · "))' <<<"$input" 2>/dev/null || true
+226 -4
View File
@@ -5,6 +5,7 @@ REPO=$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)
setup() {
TMP=$(mktemp -d /tmp/devdesk-test.XXXXXX) # short path: unix socket limit
unset DEVDESK_REMOTE_CONTROL CLAUDE_ARGS DEVDESK_SESSION TMUX TMUX_PANE # when run from inside DevDesk
export DEVDESK_ENV=/dev/null
export DEVDESK_PROJECTS_ROOT="$TMP/projects"
export DEVDESK_STATE_DIR="$TMP/state"
@@ -15,12 +16,14 @@ setup() {
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room
export FAKE_LOG="$TMP/claude-args"
export CLAUDE_BIN="$TMP/fake-claude"
export CLAUDE_CONFIG_DIR="$TMP/claude" # never the real conversation history
export PATH="$REPO/bin:$PATH"
mkdir -p "$DEVDESK_PROJECTS_ROOT/alpha" "$DEVDESK_PROJECTS_ROOT/test code" "$TMP/outside" "$FAKE_LOG"
ln -s "$TMP/outside" "$DEVDESK_PROJECTS_ROOT/escape"
cat >"$CLAUDE_BIN" <<'EOF'
#!/bin/bash
printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION"
[ -z "${FAKE_EXIT:-}" ] || exit "$FAKE_EXIT"
exec sleep 300
EOF
chmod +x "$CLAUDE_BIN"
@@ -192,12 +195,23 @@ claude_args() {
"cost":{"total_cost_usd":1.5,"total_lines_added":10,"total_lines_removed":2}}'
run bash -c "echo '$status_json' | TMUX='$DEVDESK_SOCKET,1,0' TMUX_PANE='$pane' '$REPO/libexec/devdesk-statusline'"
[ "$status" -eq 0 ]
[[ $output == *"alpha"* && $output == *"ctx 34%"* ]]
[[ $output == *"alpha"* && $output == *"ctx 34% 346k/1M"* ]]
run devdesk attach --list
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.used_tokens' <<<"$output")" = 346000 ]
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.model' <<<"$output")" = Opus ]
}
@test "statusline footer shows plan use on a subscription and dollars only on API billing" {
sub='{"model":{"display_name":"Opus"},"context_window":{"used_percentage":10},
"cost":{"total_cost_usd":1186.99},"rate_limits":{"five_hour":{"used_percentage":8.4}}}'
run bash -c "echo '$sub' | '$REPO/libexec/devdesk-statusline'"
[[ $output == *"5h 8%"* ]]
[[ $output != *'$'* ]]
api='{"model":{"display_name":"Opus"},"context_window":{"used_percentage":10},"cost":{"total_cost_usd":1.5}}'
run bash -c "echo '$api' | '$REPO/libexec/devdesk-statusline'"
[[ $output == *'$1.5'* ]]
}
@test "hooks ignore Claude running in some other tmux server" {
start_server
other="$TMP/other.sock"
@@ -440,12 +454,91 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
@test "session hook records the Claude session and the next start resumes it" {
start_server
echo '{"session_id":"abc-123","source":"startup"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
id=abcabcab-1234-1234-1234-123412341234
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p" && touch "$CLAUDE_CONFIG_DIR/projects/-p/$id.jsonl"
echo "{\"session_id\":\"$id\",\"source\":\"startup\"}" | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
run jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json"
[ "$output" = "abc-123" ]
[ "$output" = "$id" ]
devdesk new alpha
run claude_args alpha
[ "$output" = "--resume abc-123 --settings $REPO/share/claude-settings.json" ]
[ "$output" = "--resume $id --settings $REPO/share/claude-settings.json" ]
}
@test "a conversation whose file is gone starts fresh; a failed resume keeps the id (#12)" {
start_server
id=abcabcab-1234-1234-1234-123412341234
echo "{\"session_id\":\"$id\"}" | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
devdesk new alpha
run claude_args alpha
[ "$output" = "-n alpha --settings $REPO/share/claude-settings.json" ]
tmux -S "$DEVDESK_SOCKET" kill-server
# The file exists, but Claude exits at once (Ctrl+C, network): the menu, not a new conversation.
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p" && touch "$CLAUDE_CONFIG_DIR/projects/-p/$id.jsonl"
rm -f "$FAKE_LOG/alpha"
FAKE_EXIT=1 start_server
devdesk new alpha
run claude_args alpha
[ "$output" = "--resume $id --settings $REPO/share/claude-settings.json" ]
for _ in $(seq 1 30); do
tmux -S "$DEVDESK_SOCKET" capture-pane -p -t =alpha:claude | grep -q "Claude exited" && break
sleep 0.1
done
tmux -S "$DEVDESK_SOCKET" capture-pane -p -t =alpha:claude | grep -q "Claude exited"
[ "$(wc -l <"$FAKE_LOG/alpha")" = 1 ]
[ "$(jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = "$id" ]
}
@test "env files are parsed like systemd does, never run (#12)" {
cat >"$TMP/env" <<'ENV'
# comment
; also a comment
DEVDESK_DEFAULT_DIR=/srv/ops
CLAUDE_ARGS=--model opus --verbose
DEVDESK_PUBLIC_URL="https://dev.example.org"
DEVDESK_GRAFANA_URL='http://g:3000'
DEVDESK_MATRIX_DELAY=$(touch /tmp/devdesk-env-pwned)
lowercase=x
not a line
DEVDESK_BIND="unterminated
ENV
run env DEVDESK_ENV="$TMP/env" DEVDESK_ENV_LOCAL=/dev/null bash -c '. "$1/libexec/devdesk-lib.sh"; printf "%s|" "$DEVDESK_DEFAULT_DIR" "$CLAUDE_ARGS" "$DEVDESK_PUBLIC_URL" "$DEVDESK_GRAFANA_URL" "$DEVDESK_MATRIX_DELAY" "${lowercase:-unset}" "${DEVDESK_BIND:-unset}"' _ "$REPO"
[ ! -e /tmp/devdesk-env-pwned ]
[[ $output == *'/srv/ops|--model opus --verbose|https://dev.example.org|http://g:3000|$(touch /tmp/devdesk-env-pwned)|unset|unset|'* ]]
[[ $output == *"env:8: not a KEY=value line"* ]]
[[ $output == *"env:10: unmatched quote"* ]]
}
@test "the SessionStart hook reads the session name under the rename lock (#12)" {
start_server
devdesk new alpha
claude_args alpha >/dev/null
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:claude '#{pane_id}')
# A rename holds the lock; the hook fires meanwhile, while tmux still says "alpha".
(
flock 9
sleep 0.5
tmux -S "$DEVDESK_SOCKET" rename-session -t =alpha beta
DEVDESK_ENV=/dev/null bash -c '. "$1/libexec/devdesk-lib.sh"; DD_REGISTRY_LOCKED=1 dd_registry_rename alpha beta' _ "$REPO"
) 9>"$DEVDESK_STATE_DIR/registry.json.lock" 3>&- &
renamer=$!
sleep 0.1
echo '{"session_id":"ffffffff-0000-0000-0000-000000000000","source":"clear"}' |
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE=$pane "$REPO/libexec/devdesk-session-hook"
wait "$renamer"
[ "$(jq -r '.sessions | keys | join(",")' "$DEVDESK_STATE_DIR/registry.json")" = beta ]
[ "$(jq -r '.sessions.beta.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = ffffffff-0000-0000-0000-000000000000 ]
}
@test "leftover temp files of killed writers are swept (#12)" {
mkdir -p "$DEVDESK_STATE_DIR/state" "$DEVDESK_STATE_DIR/context"
touch -d '20 minutes ago' "$DEVDESK_STATE_DIR/state/.alpha.Ab12Cd" "$DEVDESK_STATE_DIR/context/.alpha.Xy34Zw" "$DEVDESK_STATE_DIR/registry.json.Qq11Rr"
touch "$DEVDESK_STATE_DIR/state/.beta.Ab12Cd" "$DEVDESK_STATE_DIR/state/alpha.json"
echo '{"session_id":"x"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
[ ! -e "$DEVDESK_STATE_DIR/state/.alpha.Ab12Cd" ]
[ ! -e "$DEVDESK_STATE_DIR/context/.alpha.Xy34Zw" ]
[ ! -e "$DEVDESK_STATE_DIR/registry.json.Qq11Rr" ]
[ -e "$DEVDESK_STATE_DIR/state/.beta.Ab12Cd" ]
[ -e "$DEVDESK_STATE_DIR/state/alpha.json" ]
}
@test "session hook ignores sessions it does not own" {
@@ -846,3 +939,132 @@ fake_convo() {
run devdesk attach --presence alpha off
[ ! -e "$TMP/presence/${sid#\$}" ]
}
# A fake Proxmox cluster for `devdesk guests`: DEVDESK_PVE_SSH answers the
# inventory (pvesh) from $FAKE_PVE/inventory and each node's scan from node-<name>.
fake_pve() {
export FAKE_PVE="$TMP/pve" DEVDESK_PVE_SSH="$TMP/fake-ssh" DEVDESK_PVE_NODES="down-node large1"
mkdir -p "$FAKE_PVE"
cat >"$DEVDESK_PVE_SSH" <<'EOF'
#!/bin/bash
while [ "${1:-}" = -o ]; do shift 2; done
case $1:$2 in
down-node:*) exit 255 ;;
*:*pvesh*) cat "$FAKE_PVE/inventory" ;;
*) cat "$FAKE_PVE/node-$1" 2>/dev/null || exit 255 ;;
esac
EOF
chmod +x "$DEVDESK_PVE_SSH"
pve_state running
printf 'large1\t10.10.10.2 10.10.90.10 fe80::1\n139\t10.10.10.27 \n102\t127.0.0.1 10.10.10.23 \n' >"$FAKE_PVE/node-large1"
}
# Draupnir (CT 110) running on monitor-02, or stopped (and so absent from the node scan).
pve_state() {
printf '%s\n' \
'[{"vmid":110,"name":"draupnir","node":"monitor-02","status":"'"$1"'","type":"lxc","hastate":"'"${1/running/started}"'"},' \
' {"vmid":139,"name":"nginxproxymanager","node":"large1","status":"running","type":"lxc","hastate":"started"},' \
' {"vmid":102,"name":"homeAssistant","node":"large1","status":"running","type":"qemu"}]' \
'[{"id":"cluster","type":"cluster"},{"name":"large1","ip":"10.10.90.10","online":1,"type":"node"},' \
' {"name":"monitor-02","ip":"10.10.10.9","online":1,"type":"node"}]' >"$FAKE_PVE/inventory"
if [ "$1" = running ]; then
printf 'monitor-02\t10.10.10.9\n110\t10.10.10.24\n' >"$FAKE_PVE/node-monitor-02"
else
printf 'monitor-02\t10.10.10.9\n' >"$FAKE_PVE/node-monitor-02"
fi
}
# Grafana's rules API, served from a file.
fake_grafana() {
export DEVDESK_GRAFANA_URL="file://$TMP/grafana" DEVDESK_GRAFANA_PUBLIC_URL=https://grafana.example
mkdir -p "$TMP/grafana/api/prometheus/grafana/api/v1"
jq -n '{data: {groups: [{name: "Bots", file: "Matrix", rules: [
{uid: "u-draupnir", name: "Draupnir Down", state: "firing", query: "up{job=\"draupnir-node\"}", labels: {severity: "critical"},
annotations: {summary: "Draupnir is unreachable", description: "Cannot scrape 10.10.10.24:9100"},
alerts: [{state: "Alerting", activeAt: "2026-10-11T07:10:00Z", value: "0", labels: {instance: "10.10.10.24:9100", job: "draupnir-node", alertname: "Draupnir Down"}}]},
{uid: "u-ceph", name: "Ceph Unhealthy", state: "firing", query: "ceph_health_status", labels: {severity: "warning"},
annotations: {summary: "status={{ $value }}"},
alerts: [{state: "Alerting", value: "1e+00", labels: {instance: "http://10.10.10.2:9283/metrics", job: "ceph"}}]},
{uid: "u-count", name: "Exporter Down", state: "firing", query: "count(up == 0)", labels: {},
annotations: {summary: "{{ $value }} down"}, alerts: [{state: "Alerting", value: "2", labels: {alertname: "Exporter Down"}}]},
{uid: "u-lan", name: "Printer Offline", state: "pending", query: "up", labels: {},
annotations: {}, alerts: [{state: "Pending", value: "0", labels: {instance: "10.99.0.1:9100"}}]},
{uid: "u-ok", name: "Fine", state: "inactive", alerts: []}]}]}}' >"$TMP/grafana/api/prometheus/grafana/api/v1/rules"
}
@test "guests: where each CT/VM runs, from any node that answers; stopped ones keep their last IP (#39)" {
fake_pve
run devdesk guests
[ "$status" -eq 0 ]
[[ $output == *"110"*"draupnir"*"monitor-02"*"running"*"started"*"10.10.10.24"* ]]
[[ $output == *"102"*"vm"*"homeAssistant"*"large1"*"10.10.10.23"* ]]
[[ $output != *"127.0.0.1"* ]]
run devdesk guests --json 10.10.10.27
[ "$(jq -r '[.guests[].id] | join(",")' <<<"$output")" = 139 ]
[ "$(jq -r '.nodes[] | select(.name == "large1") | .ips | join(",")' <<<"$output")" = 10.10.10.2,10.10.90.10 ]
# Draupnir stops: its IP is remembered, marked as last seen.
pve_state stopped
run devdesk guests drau
[[ $output == *"stopped"*"10.10.10.24 (last seen)"* ]]
# No node answers: the cache, with a warning.
run env DEVDESK_PVE_NODES=down-node devdesk guests draupnir
[ "$status" -eq 0 ]
[[ $output == *"no Proxmox node answered"*"10.10.10.24"* ]]
run env DEVDESK_PVE_NODES= devdesk guests
[ "$status" -ne 0 ]
[[ $output == *"DEVDESK_PVE_NODES"* ]]
}
@test "alerts name the guest or node each one is about (#39)" {
fake_pve
fake_grafana
devdesk guests >/dev/null
pve_state stopped
devdesk guests >/dev/null
run devdesk alerts
[ "$status" -eq 0 ]
t() { jq -c --arg u "$1" '.alerts[] | select(.uid == $u) | .targets | map({kind, id, name, node, by})' <<<"$output"; }
[ "$(t u-draupnir)" = '[{"kind":"ct","id":110,"name":"draupnir","node":"monitor-02","by":"ip"}]' ]
[ "$(t u-ceph)" = '[{"kind":"node","id":null,"name":"large1","node":"large1","by":"ip"}]' ]
[ "$(t u-count)" = '[]' ]
[ "$(t u-lan)" = '[{"kind":"unknown","id":null,"name":null,"node":null,"by":null}]' ]
[ "$(jq -r '.alerts[] | select(.uid == "u-ceph") | .summary' <<<"$output")" = "status=1" ]
[ "$(jq -r '[.alerts[].uid] | join(",")' <<<"$output")" = u-draupnir,u-ceph,u-count,u-lan ]
# Never seen running: matched by the name in its job label instead.
rm "$DEVDESK_STATE_DIR/guests.json"
devdesk guests >/dev/null
run devdesk alerts
[ "$(t u-draupnir)" = '[{"kind":"ct","id":110,"name":"draupnir","node":"monitor-02","by":"name"}]' ]
}
@test "investigate starts Claude in its own session with the alert and where it runs (#39)" {
fake_pve
fake_grafana
devdesk guests >/dev/null
start_server
run devdesk attach --investigate alert-draupnir-down u-draupnir
[ "$status" -eq 0 ]
[ "$(jq -c . <<<"$output")" = '{"ok":true,"session":"alert-draupnir-down","started":true}' ]
claude_args alert-draupnir-down >/dev/null
log="$FAKE_LOG/alert-draupnir-down"
grep -q '^-n alert-draupnir-down Investigate this Grafana alert' "$log"
grep -q 'Alert: Draupnir Down (critical, firing since' "$log"
grep -q 'Query: `up{job="draupnir-node"}`' "$log"
grep -q '10.10.10.24 is CT 110 "draupnir" on node monitor-02, running, HA state started; commands inside it: `ssh monitor-02 pct exec 110 -- <command>`' "$log"
grep -q '`devdesk guests 110` shows where it is now' "$log"
# The prompt is sent once: not again when Claude is resumed.
[ ! -e "$DEVDESK_STATE_DIR/prompts/alert-draupnir-down.txt" ]
run devdesk attach --investigate alert-draupnir-down u-draupnir
[ "$(jq -r .started <<<"$output")" = false ]
# Aggregates get a hint instead; alerts that resolved meanwhile are refused.
run devdesk attach --investigate exporters u-count
claude_args exporters >/dev/null
grep -q 'The rule aggregates' "$FAKE_LOG/exporters"
run grep -c 'Where it runs' "$FAKE_LOG/exporters"
[ "$output" = 0 ]
run devdesk attach --investigate gone u-ok
[ "$status" -ne 0 ]
[[ $output == *"no longer firing"* ]]
run devdesk attach --investigate 'bad name' u-ok
[[ $output == *"invalid session name"* ]]
}
+27 -12
View File
@@ -1,8 +1,10 @@
// 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).
// the Proxmox guest or node each is about (CT id, the node it runs on now), and
// an "Ask Claude" button that starts an investigation in a session of its own
// (devdesk#39).
import { el } from './dom.js';
import { slugify } from './protocol.js';
const SEVERITY = {
critical: 'lt-chip-critical',
@@ -18,14 +20,24 @@ function age(iso) {
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.';
// "CT 110 draupnir · monitor-02 · stopped", "node compute-storage-01".
function targetLabel(t) {
if (t.kind === 'unknown') return null;
const what = t.kind === 'node' ? `node ${t.name}` : `${t.kind.toUpperCase()} ${t.id} ${t.name}`;
const parts = [what, t.kind === 'node' ? null : t.node, t.status === 'running' || t.status === 'online' ? null : t.status];
return parts.filter(Boolean).join(' · ') + (t.by === 'name' ? ' (by name)' : '');
}
function targetTitle(t) {
const how = t.kind === 'ct' ? `ssh ${t.node} pct exec ${t.id} -- …`
: t.kind === 'vm' ? `ssh ${t.node} qm guest exec ${t.id} -- …` : `ssh ${t.name}`;
const note = t.by === 'name' ? '\nMatched by name, not by IP (the guest has not been seen running with this IP).'
: t.ip_seen ? '\nIts IP when it was last seen running.' : '';
return `${how}${t.ha ? `\nHA state: ${t.ha}` : ''}${note}`;
}
export const alertSession = (alert) => slugify(`alert-${alert.name}`).toLowerCase().slice(0, 48) || 'alert';
export function renderAlerts(root, data) {
const list = root.querySelector('.dd-alert-list');
const note = root.querySelector('.dd-alerts-note');
@@ -59,11 +71,14 @@ export function renderAlerts(root, data) {
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.targets ?? []).map((t) => {
const label = targetLabel(t);
return label ? el('div', { class: `dd-alert-where${t.by === 'name' ? ' is-guess' : ''}`, title: targetTitle(t) }, label) : null;
}),
a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null,
el('div', { class: 'dd-ask-row' },
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a), title: 'Type the prompt into the current session (not sent)' }, 'Ask Claude'),
el('button', {
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a),
'data-ask-session': `alert-${a.name}`, title: 'Open a separate ops session for this alert and type the prompt there (not sent)',
}, 'In a new session')))));
type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-investigate': a.uid, 'data-session': alertSession(a),
title: `Start Claude in a new session (${alertSession(a)}) on this alert, with the rule, the query and where it runs (CT id, node)`,
}, 'Ask Claude')))));
}
+9
View File
@@ -376,6 +376,15 @@ export async function sessionAction(action, ...args) {
throw new Error(message.replace(/^devdesk:\s*/, ''));
}
// Ops Alerts → Ask Claude (devdesk#39): start Claude in SESSION on alert rule UID.
// The server looks the alert up and writes the prompt. {session, started}.
export async function investigateAlert(session, uid) {
const output = stripAnsi(await runCommand(['--investigate', session, uid], 40000)).trim();
if (/"ok"\s*:\s*true/.test(output)) return parseListOutput(output);
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
throw new Error(message.replace(/^devdesk:\s*/, ''));
}
function concat(chunks) {
const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total);
+26 -2
View File
@@ -69,7 +69,27 @@ export class Inbox {
this.button.setAttribute('aria-expanded', String(open));
if (!open) this.peeking = null;
this.panel.classList.toggle('is-peek', false);
if (open) this.render();
if (open) {
this.render();
this.markSeen();
}
}
// Opening the bell counts as seeing what's in it (devdesk#42): the badge clears
// but the entries keep their highlight until the panel is redrawn.
markSeen() {
if (!this.events.some((e) => !e.read)) return;
this.events = this.events.map((e) => ({ ...e, read: true }));
save(this.events);
this.renderBadge();
}
// Looking at a session reads what it reported (finished, waiting) while you were away.
markSessionRead(session) {
if (!session || !this.events.some((e) => !e.read && e.session === session && !e.url)) return;
this.events = this.events.map((e) => (e.session === session && !e.url ? { ...e, read: true } : e));
save(this.events);
this.render();
}
// Something happened. The same kind for the same session within a minute is
@@ -102,6 +122,11 @@ export class Inbox {
}
render() {
this.renderBadge();
if (this.isOpen) this.renderPanel();
}
renderBadge() {
const count = this.waiting.length + this.alerts.length + this.unread();
const urgent = this.waiting.length > 0 || this.alerts.some((a) => a.severity === 'critical');
this.badge.textContent = count ? String(count) : '';
@@ -114,7 +139,6 @@ export class Inbox {
this.unread() && `${this.unread()} new event(s)`,
].filter(Boolean).join(' · ')
: 'Inbox: nothing new';
if (this.isOpen) this.renderPanel();
}
// ------------------------------------------------------------ answer from here
+34 -38
View File
@@ -2,10 +2,10 @@
import { THEMES, createTerminal, loadFonts } from './term.js';
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, sessionAction, uploadFile, AuthExpiredError, UnauthorizedError,
TtydConnection, fetchAlerts, fetchRepo, fetchToken, investigateAlert, sessionAction, uploadFile, AuthExpiredError, UnauthorizedError,
} from './connection.js';
import { parseTitle, pasteSafe, sessionFromSearch, slugify } from './protocol.js';
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
import { SessionsPanel, ctxLabel, recentNames, rememberSession, sessionStatus } from './sessions.js';
import { renderAlerts } from './alerts.js';
import { renderRepo } from './repo.js';
import { bindAskClaude } from './widgets.js';
@@ -181,6 +181,7 @@ function renderPanelsBadge() {
}
function onSessionData({ fetched = false } = {}) {
if (document.visibilityState === 'visible') inbox?.markSessionRead(state.session);
renderUsage();
renderSessionChip();
renderAttention(fetched);
@@ -546,7 +547,7 @@ function renderSwitcher() {
};
const items = sessions.map((s) => {
const status = sessionStatus(s);
const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : '';
const ctx = s.context?.used_pct != null ? ` · ${ctxLabel(s.context)}` : '';
return el('button', {
type: 'button', role: 'menuitem',
class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`,
@@ -595,36 +596,28 @@ async function toggleNotifications() {
}
}
// "In a new session" (devdesk#18): open (or reuse) a session for this alert,
// wait until its Claude is up (SessionStart sets it idle), then type the
// prompt without sending it.
let pendingAsk = null;
function askInSession(name, text) {
// Ops Alerts → Ask Claude (devdesk#39): the server starts Claude in a session of
// its own with the alert, its rule and where it runs (CT id, node) as the first
// prompt; then we open that session. A session already running is just opened.
async function investigate(button) {
const { investigate: uid, session } = button.dataset;
if (!uid || !session || button.disabled) return;
closeDrawerIfNarrow();
pendingAsk = { session: name, text, until: Date.now() + 60000 };
connect(name);
toast.info(`Opening ${name}; the prompt will be typed when Claude is ready`);
const tick = async () => {
if (!pendingAsk || pendingAsk.session !== name) return;
if (Date.now() > pendingAsk.until) {
toast.warning(`${name}: Claude didn't start in time; the prompt was not typed`);
pendingAsk = null;
return;
}
button.disabled = true;
const label = button.textContent;
button.textContent = 'Starting…';
try {
const result = await investigateAlert(session, uid);
if (result.started) toast.success(`Claude is on it in ${session}`);
else toast.info(`${session} is already running: opening it`);
await sessionsPanel.reload();
const s = sessionsPanel.data?.sessions?.find((x) => x.name === name);
const ready = s && ['idle', 'waiting', 'working'].includes(sessionStatus(s).key);
if (ready && state.session === name && state.conn?.isOpen) {
setTimeout(() => {
term.paste(pasteSafe(pendingAsk.text));
term.focus();
pendingAsk = null;
}, 800);
return;
}
setTimeout(tick, 1500);
};
setTimeout(tick, 1500);
connect(session, { existing: true });
} catch (error) {
toast.error(`Ask Claude: ${error.message}`);
} finally {
button.disabled = false;
button.textContent = label;
}
}
// ---------------------------------------------------------------- history & usage
@@ -1082,7 +1075,10 @@ function wireChrome() {
window.addEventListener('online', retrySoon);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) retrySoon();
if (!document.hidden) {
retrySoon();
inbox.markSessionRead(state.session);
}
});
// Session states feed the header chip and the notifications for other sessions,
// so poll even in a background tab (browsers throttle that to about once a
@@ -1189,11 +1185,11 @@ async function boot() {
onData: (_data, meta) => onSessionData(meta),
},
});
bindAskClaude($('sidebar'), (text, session) => {
if (session) {
askInSession(slugify(session).slice(0, 48) || 'alert', text);
return;
}
$('widget-alerts').addEventListener('click', (event) => {
const button = event.target.closest('[data-investigate]');
if (button) investigate(button);
});
bindAskClaude($('sidebar'), (text) => {
// Alert text is untrusted: no control characters (a newline would run it in a
// shell window, CR would submit), and bracketed paste around it (devdesk#2).
term.paste(pasteSafe(text));
+11 -2
View File
@@ -46,6 +46,14 @@ function renameRecent(from, to) {
writeRecents(readRecents().map((r) => (r.name === from ? { ...r, name: to } : r)));
}
// "ctx 34% · 346k/1M": context use as a share of the window and in tokens.
const tokens = (n) => (n >= 1e6 ? `${+(n / 1e6).toFixed(n % 1e6 ? 1 : 0)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
export function ctxLabel(c) {
if (c?.used_pct == null) return '';
const count = c.used_tokens != null ? ` · ${tokens(c.used_tokens)}${c.window ? `/${tokens(c.window)}` : ''}` : '';
return `ctx ${Math.floor(c.used_pct)}%${count}`;
}
export function ago(epochSeconds) {
if (!epochSeconds) return '';
const s = Math.max(0, Date.now() / 1000 - epochSeconds);
@@ -267,7 +275,7 @@ export class SessionsPanel {
renderSession(s) {
const isCurrent = s.name === this.current;
const status = sessionStatus(s);
const ctx = s.context?.used_pct != null ? `ctx ${Math.floor(s.context.used_pct)}%` : '';
const ctx = ctxLabel(s.context);
const since = s.status?.since ? ago(s.status.since) : ago(s.activity);
const model = isCurrent && s.context?.model ? s.context.model.replace(/^Claude\s+/i, '') : '';
const meta = s.running
@@ -278,7 +286,8 @@ export class SessionsPanel {
const title = [
s.dir,
status.key === 'waiting' && s.status?.message,
s.context?.model && `${s.context.model}${s.context.cost_usd != null ? ` · $${s.context.cost_usd.toFixed(2)}` : ''}`,
// rate_limits means a Pro/Max plan, where cost_usd is only an API-price estimate
s.context?.model && `${s.context.model}${s.context.rate_limits ? '' : s.context.cost_usd != null ? ` · $${s.context.cost_usd.toFixed(2)}` : ''}`,
].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);
+4 -5
View File
@@ -1,12 +1,11 @@
// "Ask Claude": any [data-ask-claude] button (Ops Alerts, Repo/CI) types its
// prompt into a session without pressing Enter, so you can review it before
// sending: the current session, or (data-ask-session) a separate one, e.g. an
// ops session per alert (devdesk#18).
// "Ask Claude": any [data-ask-claude] button (Repo/CI) types its prompt into the
// current session without pressing Enter, so you can review it before sending.
// (Ops Alerts start their own session instead: devdesk#39.)
export function bindAskClaude(root, send) {
root.addEventListener('click', (event) => {
const button = event.target.closest('[data-ask-claude]');
if (!button || button.disabled) return;
send(button.dataset.askClaude, button.dataset.askSession || null);
send(button.dataset.askClaude);
});
}
+23 -4
View File
@@ -2,7 +2,7 @@
everything visual comes from the TDS tokens defined in base.css :root. */
:root {
--dd-footer-height: 30px;
--dd-footer-height: 40px;
--dd-sidebar-width: 320px;
--dd-gap: 8px;
}
@@ -402,6 +402,9 @@ body.dd-sidebar-open .dd-sidebar {
.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-where { color: var(--accent-cyan); font-family: var(--font-mono); overflow-wrap: anywhere; }
.dd-alert-where::before { content: "\2192 "; color: var(--text-muted); }
.dd-alert-where.is-guess { color: var(--text-secondary); }
.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; }
@@ -489,7 +492,19 @@ body.dd-sidebar-open .dd-sidebar {
overflow: hidden;
}
.dd-footer .lt-footer-key { margin-right: 4px; }
.dd-footer .lt-footer-key { margin-right: 5px; font-size: 0.86rem; }
/* devdesk#41: the bottom row was too small to read at a glance */
.dd-footer .lt-footer-hint { font-size: 0.9rem; }
.dd-footer .lt-footer-sep { font-size: 0.9rem; }
/* Narrower windows keep the usage meters and drop the least-used hints. */
@media (max-width: 1400px) {
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 13),
.dd-footer .lt-footer-sep:nth-child(n + 12) { display: none; }
}
@media (max-width: 1150px) {
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 9),
.dd-footer .lt-footer-sep:nth-child(n + 8) { display: none; }
}
.dd-keybar { display: flex; gap: 4px; width: 100%; overflow-x: auto; scrollbar-width: none; }
.dd-keybar[hidden] { display: none; }
@@ -622,7 +637,7 @@ html[data-theme="light"] .lt-cmd-item.is-selected { background: var(--accent-cya
background: none;
color: var(--accent-cyan);
font-family: var(--font-mono);
font-size: 0.64rem;
font-size: 0.8rem;
cursor: pointer;
}
.dd-footer-help:hover { border-color: var(--accent-cyan); }
@@ -1002,11 +1017,15 @@ html[data-theme="light"] .dd-files.is-narrow .dd-explorer { box-shadow: 8px 0 24
.dd-history-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
.dd-history-actions .lt-input { width: 220px; font-size: 0.74rem; }
.dd-footer-usage { margin-left: auto; padding-left: 12px; }
.dd-footer-usage { margin-left: auto; padding-left: 16px; }
.dd-footer-usage[hidden],
.dd-usage-row[hidden] { display: none; }
.dd-usage { display: inline-flex; align-items: center; gap: 10px; }
.dd-footer-usage { display: inline-flex; align-items: center; gap: 18px; }
.dd-meter { display: inline-flex; align-items: center; gap: 5px; color: var(--text-secondary); font-size: 0.66rem; white-space: nowrap; }
.dd-footer-usage .dd-meter { gap: 7px; color: var(--text-primary); font-family: var(--font-mono); font-size: 1rem; font-weight: 600; }
.dd-footer-usage .dd-meter-label { font-weight: 400; }
.dd-footer-usage .dd-meter-bar { width: 80px; height: 8px; }
.dd-meter-label { color: var(--text-muted); }
.dd-meter-bar { width: 42px; height: 4px; background: var(--bg-tertiary); }
.dd-meter-fill { display: block; height: 100%; background: var(--accent-cyan); }