Compare commits

..
4 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 03e4a49e78 feat: session status at a glance, file & image drop, live Ops Alerts (0.4.0)
build-deb / Lint, test, package (push) Successful in 1m51s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- devdesk-state-hook (Claude UserPromptSubmit/PostToolUse/Notification/Stop/
  SessionStart/SessionEnd) records whether each session is working, waiting on
  you or idle; --list carries it. The sidebar shows it, the header shows the
  current session's state, and a pulsing "needs you" chip jumps to sessions
  waiting for input. Session refresh no longer depends on the sidebar being open.
- `devdesk attach --upload SESSION FILE BYTES` receives a file as base64
  terminal input (raw mode, exact length, size-checked, sanitised name) into
  /var/lib/devdesk/uploads/<session>/. The UI uploads dropped or pasted files and
  pastes the path into Claude, which attaches images itself.
- `devdesk alerts` / `--alerts`: firing and pending Grafana alert rules with
  severity, instances, summary and a link. The panel's "Ask Claude" types an
  investigation prompt into the current session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:26:06 -04:00
jaredandClaude Opus 5.5 531b640ae9 feat: auto-deploy tagged releases to CT 126 via signed webhook (0.3.1)
build-deb / Lint, test, package (push) Successful in 2m16s
build-deb / Deploy to CT 126 (push) Successful in 3m4s
build-deb / Notify on failure (push) Skipped
CI's new deploy job (tags only) sends the house HMAC webhook to CT 126; the
listener runs /usr/lib/devdesk/devdesk-deploy, which waits for the version in
the apt registry and upgrades devdesk with --force-confold. Running sessions
are untouched. Hook setup is documented in docs/DEPLOY.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:21:11 -04:00
jaredandClaude Opus 5.5 9026238024 feat: live Repo/CI panel, env.local overrides (0.3.0)
build-deb / Lint, test, package (push) Successful in 1m46s
build-deb / Notify on failure (push) Skipped
- `devdesk attach --repo NAME` / `devdesk repo NAME`: git state of the session's
  folder (branch, ahead/behind, uncommitted changes) plus the Gitea commit
  status of HEAD and open PR count. Uses the anonymous API for public repos,
  or /etc/devdesk/gitea-token when present. Runs over the ttyd channel, so no
  sidecar is needed. The panel links to the repo, the CI run and PRs, and a
  notification fires when HEAD's CI finishes while the tab is in the background.
- /etc/devdesk/env.local is read after the packaged env and never shipped, so
  upgrades no longer stop at a conffile prompt.
- Hooks and status line only record for panes on DevDesk's own tmux server; the
  Claude wrapper re-reads env before every launch (so the Remote Control toggle
  applies on the next Claude restart).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:09:32 -04:00
jaredandClaude Opus 5.5 ae8b5d843f fix: scope hooks to DevDesk's tmux and reload env per Claude launch (0.2.1)
build-deb / Lint, test, package (push) Successful in 2m6s
build-deb / Notify on failure (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:54:28 -04:00
24 changed files with 1026 additions and 64 deletions
+23 -1
View File
@@ -149,10 +149,32 @@ jobs:
"$api/releases/$id/assets?name=$name" >/dev/null
echo "release $TAG: $GITEA_URL/$REPO/releases/tag/$TAG"
deploy:
name: Deploy to CT 126
runs-on: ubuntu-latest
needs: [build]
if: startsWith(github.ref, 'refs/tags/v')
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.
- name: Trigger devdesk-deploy webhook
env:
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
GIT_REF: ${{ github.ref }}
run: |
PAYLOAD="{\"ref\":\"${GIT_REF}\"}"
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
curl -sf --connect-timeout 10 \
-X POST \
-H "Content-Type: application/json" \
-H "X-Gitea-Signature: ${SIG}" \
-d "$PAYLOAD" \
"http://10.10.10.58:9000/hooks/devdesk-deploy"
notify-failure:
name: Notify on failure
runs-on: ubuntu-latest
needs: [build]
needs: [build, deploy]
if: failure() && github.event_name == 'push'
steps:
- name: Send Matrix alert
+2 -1
View File
@@ -39,7 +39,8 @@ install: all
install -m 0755 bin/devdesk $(DESTDIR)$(PREFIX)/bin/devdesk
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 $(DESTDIR)$(LIBDIR)/
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
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
+5 -3
View File
@@ -50,8 +50,10 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
Use the **Full** button for fullscreen. In Chromium it also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
## Configuration
- `/etc/devdesk/env` (conffile):
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_REMOTE_CONTROL=1`.
- `/etc/devdesk/env` holds the packaged defaults; don't edit it. Its keys:
- port, bind address, projects root and `DEVDESK_DEFAULT_DIR`
- `DEVDESK_GITEA_URL` / `DEVDESK_GITEA_ALIASES` for the Repo/CI panel. Public repos need no token; for private ones put a `read:repository` token in `/etc/devdesk/gitea-token`
- `CLAUDE_BIN` and `CLAUDE_ARGS`
- `DEVDESK_REMOTE_CONTROL=1`, which also starts each session with Claude Remote Control so it can be continued from the Claude app
- `/etc/devdesk/secret.env` is generated on install. It holds the ttyd credential that NPM must inject; see [docs/DEPLOY.md](docs/DEPLOY.md).
@@ -69,8 +71,8 @@ apt install ../devdesk_*_amd64.deb
CI (`.gitea/workflows/build-deb.yml`):
- Every push lints, tests, builds and smoke-tests the package and uploads it as an artifact.
- A `v*` tag also publishes to `https://code.lotusguild.org/api/packages/LotusGuild/debian` and creates a release.
- A `v*` tag also publishes to `https://code.lotusguild.org/api/packages/LotusGuild/debian`, creates a release, and auto-deploys to CT 126. A signed webhook runs `devdesk-deploy`, which upgrades the package; see [docs/DEPLOY.md](docs/DEPLOY.md).
## Roadmap
- **v1.1:** `devdesk-api` sidecar for the Repo/CI panel, Matrix notifications when you're away, and auto-deploy.
- **Next:** Matrix notifications when you're away (needs the Ci-Alert hookshot URL).
- **v2:** Ops Alerts (Grafana) with "Ask Claude" prompts, and file drop.
+151 -9
View File
@@ -5,10 +5,13 @@
# devdesk new NAME [PROJECT] create session NAME (detached) in PROJECT's folder, or the default folder
# 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 | --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
@@ -16,7 +19,7 @@ set -euo pipefail
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
usage() {
sed -n '2,10s/^# \{0,1\}//p' "$0"
sed -n '2,12s/^# \{0,1\}//p' "$0"
}
# Tab-separated running sessions: name, dir, attached clients, last activity, windows.
@@ -40,11 +43,12 @@ projects_tsv() {
done
}
# {"<session>": {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
# {"<session>": {...}} 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
@@ -58,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"));
@@ -75,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}$"))])}'
@@ -144,6 +149,127 @@ cmd_end() {
dd_registry_remove "$1"
}
# GET $DEVDESK_GITEA_URL/api/v1<path>; token optional (private repos only).
gitea_get() {
local auth=()
if [ -r "$DEVDESK_GITEA_TOKEN_FILE" ]; then
auth=(-H "Authorization: token $(tr -d '[:space:]' <"$DEVDESK_GITEA_TOKEN_FILE")")
fi
curl -fsS --max-time 4 "${auth[@]}" "$DEVDESK_GITEA_URL/api/v1$1" 2>/dev/null
}
# owner/repo when REMOTE points at our Gitea (https or ssh, any configured host), else nothing.
gitea_slug() {
local remote=$1 host hosts
hosts="${DEVDESK_GITEA_URL#*://} $DEVDESK_GITEA_ALIASES"
for host in $hosts; do
host=${host%%/*}
host=${host%%:*}
if [[ $remote =~ [@/]${host//./\.}(:[0-9]+)?[:/]+([^/]+/[^/]+)$ ]]; then
printf '%s\n' "${BASH_REMATCH[2]%.git}"
return
fi
done
}
cmd_repo() {
[ $# -eq 1 ] || dd_die "usage: devdesk repo NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
local dir g branch sha="" dirty ahead=null behind=null upstream=false remote slug="" ci=null prs=null
dir=$(dd_resolve_dir "$1")
g=(git -c safe.directory='*' -C "$dir")
if ! "${g[@]}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
jq -cMn --arg s "$1" --arg dir "$dir" '{session: $s, dir: $dir, git: false}'
return
fi
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
sha=$("${g[@]}" rev-parse -q --verify HEAD 2>/dev/null || true)
dirty=$("${g[@]}" status --porcelain 2>/dev/null | wc -l)
if "${g[@]}" rev-parse -q --verify '@{u}' >/dev/null 2>&1; then
upstream=true
read -r behind ahead < <("${g[@]}" rev-list --left-right --count '@{u}...HEAD')
fi
remote=$("${g[@]}" remote get-url origin 2>/dev/null || true)
[ -n "$remote" ] && slug=$(gitea_slug "$remote")
if [ -n "$slug" ] && [ -n "$sha" ]; then
ci=$(gitea_get "/repos/$slug/commits/$sha/status" |
jq -cM '{state, total: .total_count,
url: (.statuses[0]?.target_url | if . then sub("/jobs/[0-9]+$"; "") else null end),
jobs: [.statuses[]? | {name: .context, status}]}' 2>/dev/null) || ci=null
prs=$(gitea_get "/repos/$slug/pulls?state=open&limit=50" | jq -M 'length' 2>/dev/null) || prs=null
fi
jq -cMn --arg s "$1" --arg dir "$dir" --arg branch "$branch" --arg sha "${sha:0:7}" \
--argjson dirty "$dirty" --argjson ahead "$ahead" --argjson behind "$behind" \
--argjson upstream "$upstream" --arg remote "$remote" --arg slug "$slug" \
--arg web "$DEVDESK_GITEA_URL" --argjson ci "${ci:-null}" --argjson prs "${prs:-null}" '
{session: $s, dir: $dir, git: true, branch: $branch, sha: $sha, dirty: $dirty,
ahead: $ahead, behind: $behind, upstream: $upstream, remote: $remote,
repo: (if $slug == "" then null else $slug end),
url: (if $slug == "" then null else "\($web)/\($slug)" end),
ci: (if $ci == null then null else $ci + {url: (if $ci.url then $web + $ci.url else null end)} end),
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'
}
@@ -158,6 +284,20 @@ cmd_attach() {
cmd_list
return
;;
--repo)
shift
cmd_repo "$@"
return
;;
--alerts)
cmd_alerts
return
;;
--upload)
shift
cmd_upload "$@"
return
;;
--new | --rename | --end)
local action=${1#--}
shift
@@ -195,6 +335,8 @@ case $cmd in
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 "$@" ;;
+48
View File
@@ -1,3 +1,51 @@
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/<session>/ (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 <jjvititoe1@gmail.com> 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
pattern), which runs /usr/lib/devdesk/devdesk-deploy to upgrade the
package from the apt registry. Running sessions are untouched.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 13:20:58 -0400
devdesk (0.3.0) bookworm; urgency=medium
* Repo / CI panel is live: branch, ahead/behind, uncommitted changes, the
Gitea CI status of HEAD (linked to the run) and open PRs for the current
session's folder, via `devdesk attach --repo` over the ttyd channel. No
sidecar needed; public repos need no token (optional
/etc/devdesk/gitea-token for private ones). A notification fires when CI
for HEAD finishes while the tab is in the background.
* Remote Control verified with resume (same claude.ai session link).
* Local settings now go in /etc/devdesk/env.local (read after the packaged
env, never shipped), so upgrades no longer stop at a conffile prompt.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 12:57:04 -0400
devdesk (0.2.1) bookworm; urgency=medium
* Session hook and status line only record for panes on DevDesk's own
tmux server (Claude run with these settings elsewhere no longer writes
into DevDesk's state).
* The Claude wrapper re-reads /etc/devdesk/env before every Claude launch,
so Remote Control and CLAUDE_ARGS changes apply on the next restart.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 12:54:06 -0400
devdesk (0.2.0) bookworm; urgency=medium
* Sessions no longer need a project folder: any name works and starts in
+1
View File
@@ -11,6 +11,7 @@ User=root
Environment=HOME=/root USER=root LOGNAME=root SHELL=/bin/bash LANG=C.UTF-8
Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
EnvironmentFile=-/etc/devdesk/env
EnvironmentFile=-/etc/devdesk/env.local
WorkingDirectory=-/root
RuntimeDirectory=devdesk
RuntimeDirectoryMode=0700
+1 -1
View File
@@ -2,7 +2,7 @@
set -e
if [ "$1" = purge ]; then
rm -f /etc/devdesk/secret.env
rm -f /etc/devdesk/secret.env /etc/devdesk/env.local
rm -rf /var/lib/devdesk
fi
+1
View File
@@ -10,6 +10,7 @@ User=root
Environment=HOME=/root LANG=C.UTF-8
Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
EnvironmentFile=/etc/devdesk/env
EnvironmentFile=-/etc/devdesk/env.local
EnvironmentFile=/etc/devdesk/secret.env
ExecStart=/usr/lib/devdesk/devdesk-serve
Restart=always
+24 -1
View File
@@ -36,7 +36,30 @@ Then run `authelia validate-config --config /etc/authelia/configuration.yml && s
- **Cloudflare:** add a CNAME `dev` → apex, DNS-only (grey cloud).
- **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. Optional hardening
## 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`.
**Setup, done once:**
1. Generate a secret.
2. Add this hook to `/etc/webhook/hooks.json`, next to `126-config-deploy`. The file is local and untracked because it holds secrets.
```json
{
"id": "devdesk-deploy",
"execute-command": "/usr/lib/devdesk/devdesk-deploy",
"command-working-directory": "/",
"pass-arguments-to-command": [{"source": "payload", "name": "ref"}],
"trigger-rule": {"and": [
{"match": {"type": "payload-hash-sha256", "secret": "<WEBHOOK_SECRET>",
"parameter": {"source": "header", "name": "X-Gitea-Signature"}}},
{"match": {"type": "regex", "regex": "^refs/tags/v[0-9]+\\.[0-9]+\\.[0-9]+$",
"parameter": {"source": "payload", "name": "ref"}}}
]}
}
```
3. Run `systemctl restart webhook`.
4. Store the same secret as the devdesk repo's Actions secret `WEBHOOK_SECRET`.
## 6. Optional hardening
Enable the Proxmox firewall on CT 126 (`firewall=1` on net0) and allow tcp/7681 only from 10.10.10.27. The same rule suits code-server on :8680.
## Operations
+16 -2
View File
@@ -1,5 +1,8 @@
# DevDesk configuration. Read by systemd (EnvironmentFile=) and by the devdesk
# scripts. Plain KEY=value lines only: systemd does not support inline comments.
# 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.
# The ttyd credential lives in /etc/devdesk/secret.env (generated on install).
# Port ttyd listens on (NPM proxies dev.lotusguild.org here).
@@ -15,6 +18,17 @@ DEVDESK_DEFAULT_SESSION=main
# Where sessions that are not a project folder start (e.g. ops sessions); empty = projects root.
DEVDESK_DEFAULT_DIR=
# Gitea for the Repo/CI panel. Public repos need no token; for private ones put a
# read:repository token in /etc/devdesk/gitea-token (mode 600).
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=
+9 -2
View File
@@ -10,9 +10,15 @@ set -uo pipefail
NAME=$(dd_current_session)
[ -n "$NAME" ] || dd_die "not running inside a DevDesk session"
CLAUDE_BIN=${CLAUDE_BIN:-claude}
SETTINGS="$DEVDESK_SHARE/claude-settings.json"
read -r -a EXTRA <<<"${CLAUDE_ARGS:-}"
# Re-read /etc/devdesk/env before every Claude launch, so changes (Remote
# Control, CLAUDE_ARGS, …) apply on the next restart of Claude in a session.
reload_config() {
dd_load_env
CLAUDE_BIN=${CLAUDE_BIN:-claude}
read -r -a EXTRA <<<"${CLAUDE_ARGS:-}"
}
ORANGE=$'\033[38;2;255;107;0m'
CYAN=$'\033[38;2;0;212;255m'
@@ -66,6 +72,7 @@ printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\)
mode=resume
while :; do
NAME=$(dd_current_session) # follow renames
reload_config
if [ "$mode" = new ]; then run_new; else run_resume; fi
rc=$?
NAME=$(dd_current_session)
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# Upgrade DevDesk from the LotusGuild apt registry. Run by the host's webhook
# 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.
set -euo pipefail
LOG=${DEVDESK_DEPLOY_LOG:-/var/log/devdesk-deploy.log}
SOURCES=${DEVDESK_APT_SOURCES:-sources.list.d/lotusguild.list}
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"
exit 1
}
want=${1:-}
want=${want#refs/tags/}
want=${want#v}
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=- \
-o APT::Get::List-Cleanup=0 >>"$LOG" 2>&1
}
# The registry can lag the release by a few seconds: wait for the tagged version.
candidate=""
for _ in $(seq 1 18); do
refresh || true
candidate=$(apt-cache policy devdesk | awk '/Candidate:/ {print $2}')
if [ -z "$want" ] || [ "$candidate" = "$want" ]; then
break
fi
sleep 10
done
if [ -n "$want" ] && [ "$candidate" != "$want" ]; then
log "registry still offers $candidate, not $want; giving up"
exit 1
fi
if [ "$candidate" = "$before" ]; then
log "already at $before; nothing to do"
exit 0
fi
DEBIAN_FRONTEND=noninteractive apt-get install -y -q --only-upgrade \
-o Dpkg::Options::=--force-confold devdesk </dev/null >>"$LOG" 2>&1
log "devdesk $before -> $(dpkg-query -W -f='${Version}' devdesk)"
+41 -10
View File
@@ -5,10 +5,19 @@
# against a throwaway tmux server and state directory.
DEVDESK_ENV=${DEVDESK_ENV:-/etc/devdesk/env}
if [ -r "$DEVDESK_ENV" ]; then
# shellcheck source=/dev/null
. "$DEVDESK_ENV"
fi
# 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}
dd_load_env() {
local f
for f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do
if [ -r "$f" ]; then
# shellcheck source=/dev/null
. "$f"
fi
done
}
dd_load_env
: "${DEVDESK_PROJECTS_ROOT:=/root/code}"
: "${DEVDESK_DEFAULT_DIR:=$DEVDESK_PROJECTS_ROOT}"
@@ -18,9 +27,17 @@ fi
: "${DEVDESK_LIBEXEC:=/usr/lib/devdesk}"
: "${DEVDESK_SHARE:=/usr/share/devdesk}"
: "${DEVDESK_TMUX_UNIT:=devdesk-tmux.service}"
: "${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() {
@@ -101,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.
@@ -135,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() {
@@ -152,8 +181,10 @@ dd_resolve_dir() {
# Name of the DevDesk session this process runs in. Asks tmux, so it stays
# right after a rename; falls back to the DEVDESK_SESSION set at creation.
dd_current_session() {
local name=""
if [ -n "${TMUX:-}" ] && [ -n "${TMUX_PANE:-}" ]; then
local name="" outer=${TMUX:-}
# Only DevDesk's own tmux server: a Claude started with these settings in
# some other tmux must not write into DevDesk's state.
if [ "${outer%%,*}" = "$DEVDESK_SOCKET" ] && [ -n "${TMUX_PANE:-}" ]; then
name=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
fi
printf '%s\n' "${name:-${DEVDESK_SESSION:-}}"
+40
View File
@@ -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
+4 -3
View File
@@ -6,11 +6,12 @@
input=$(cat)
session=""
if [ -n "${TMUX:-}" ] && [ -n "${TMUX_PANE:-}" ]; then
# Only record for panes on DevDesk's own tmux server.
session="" outer=${TMUX:-}
if [ "${outer%%,*}" = "${DEVDESK_SOCKET:-/run/devdesk/tmux.sock}" ] && [ -n "${TMUX_PANE:-}" ]; then
session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
session=${session:-${DEVDESK_SESSION:-}}
fi
session=${session:-${DEVDESK_SESSION:-}}
if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
dir="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/context"
+61
View File
@@ -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
}
]
}
+98
View File
@@ -197,6 +197,104 @@ claude_args() {
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.model' <<<"$output")" = Opus ]
}
@test "hooks ignore Claude running in some other tmux server" {
start_server
other="$TMP/other.sock"
tmux -S "$other" new-session -d -s stranger 'sleep 60'
pane=$(tmux -S "$other" display -p -t =stranger '#{pane_id}')
echo '{"session_id":"nope","source":"startup"}' |
TMUX="$other,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-session-hook"
echo '{"model":{"display_name":"Opus"},"context_window":{}}' |
TMUX="$other,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-statusline" >/dev/null
tmux -S "$other" kill-server
[ ! -e "$DEVDESK_STATE_DIR/registry.json" ] || [ "$(jq -r '.sessions | has("stranger")' "$DEVDESK_STATE_DIR/registry.json")" = false ]
[ ! -e "$DEVDESK_STATE_DIR/context/stranger.json" ]
}
@test "repo reports git state and recognises Gitea remotes" {
export DEVDESK_GITEA_URL=http://127.0.0.1:9 # nothing listens: CI lookups fail fast
repo="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$repo" init -q -b main
git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init
git -C "$repo" remote add origin gitea@10.10.10.63:LotusGuild/alpha.git
touch "$repo/new-file"
run devdesk attach --repo alpha
[ "$status" -eq 0 ]
[ "$(jq -r '.git' <<<"$output")" = true ]
[ "$(jq -r '.branch' <<<"$output")" = main ]
[ "$(jq -r '.dirty' <<<"$output")" = 1 ]
[ "$(jq -r '.repo' <<<"$output")" = LotusGuild/alpha ]
[ "$(jq -r '.ci' <<<"$output")" = null ]
git -C "$repo" remote set-url origin https://127.0.0.1:9/LotusGuild/alpha-https.git
run devdesk repo alpha
[ "$(jq -r '.repo' <<<"$output")" = LotusGuild/alpha-https ]
git -C "$repo" remote set-url origin https://github.com/someone/else.git
run devdesk repo alpha
[ "$(jq -r '.repo' <<<"$output")" = null ]
run devdesk repo main
[ "$(jq -r '.git' <<<"$output")" = false ]
}
@test "env.local overrides the packaged env" {
mkdir -p "$TMP/etc" "$DEVDESK_PROJECTS_ROOT/alpha/ops"
printf 'DEVDESK_DEFAULT_DIR=%s\n' "$DEVDESK_PROJECTS_ROOT" >"$TMP/etc/env"
printf 'DEVDESK_DEFAULT_DIR=%s\n' "$DEVDESK_PROJECTS_ROOT/alpha/ops" >"$TMP/etc/env.local"
export DEVDESK_ENV="$TMP/etc/env"
start_server
devdesk new adhoc
run tmux -S "$DEVDESK_SOCKET" display -p -t =adhoc:claude '#{pane_current_path}'
[ "$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
+11 -15
View File
@@ -20,10 +20,12 @@
</div>
<div class="dd-session">
<span class="lt-chip lt-chip-info" id="session-chip">—</span>
<span class="lt-chip dd-state-chip" id="state-chip" data-state=""></span>
<span class="dd-pane-title" id="pane-title"></span>
</div>
</div>
<div class="lt-header-right">
<button type="button" class="lt-chip dd-attention" id="attention-chip" title="Sessions waiting for you"></button>
<span class="lt-ws-status" id="ws-status" data-state="connecting" role="status">
<span class="lt-dot"></span><span id="ws-label">connecting</span>
</span>
@@ -41,6 +43,8 @@
<main class="dd-main">
<section class="lt-frame dd-terminal-frame" aria-label="Terminal">
<div id="terminal" class="dd-terminal"></div>
<div id="drop-overlay" class="dd-drop-overlay" hidden><span>Drop to attach to <b id="drop-session">this session</b></span></div>
<div id="upload-status" class="dd-upload-status" role="status" hidden></div>
<div id="overlay" class="dd-overlay" data-tone="info" hidden>
<div class="lt-frame dd-overlay-card" role="alertdialog" aria-labelledby="overlay-title" aria-describedby="overlay-text">
<div class="lt-section-header" id="overlay-title">Disconnected</div>
@@ -89,8 +93,8 @@
</div>
</section>
<section class="lt-frame dd-widget" id="widget-repo" data-state="stub">
<div class="lt-section-header">Repo / CI <span class="lt-badge lt-badge-sm dd-soon">v1.1</span></div>
<section class="lt-frame dd-widget" id="widget-repo" data-state="empty">
<div class="lt-section-header">Repo / CI</div>
<div class="lt-section-body">
<div class="lt-kv-grid">
<span class="lt-kv-key">Repo</span><span class="lt-kv-val" data-field="repo">—</span>
@@ -99,25 +103,17 @@
<span class="lt-kv-key">CI · HEAD</span><span class="lt-kv-val"><span class="lt-status lt-status-pending" data-field="ci">no data</span></span>
<span class="lt-kv-key">Open PRs</span><span class="lt-kv-val" data-field="prs">—</span>
</div>
<p class="dd-muted dd-stub-note">Gitea status for this session's repo arrives with devdesk-api.</p>
<p class="dd-muted dd-stub-note dd-repo-note"></p>
</div>
</section>
<section class="lt-frame dd-widget" id="widget-alerts" data-state="stub">
<div class="lt-section-header">Ops Alerts <span class="lt-badge lt-badge-sm dd-soon">v2</span></div>
<section class="lt-frame dd-widget" id="widget-alerts" data-state="empty">
<div class="lt-section-header">Ops Alerts <span class="lt-badge lt-badge-sm dd-alert-count"></span></div>
<div class="lt-section-body">
<ul class="dd-alert-list">
<li class="dd-alert">
<span class="lt-dot lt-dot-idle"></span>
<span class="dd-alert-text">No alert source connected</span>
<button type="button" class="lt-btn lt-btn-sm dd-ask" disabled
data-ask-claude="Investigate the firing alert: ">Ask Claude</button>
</li>
</ul>
<p class="dd-muted dd-stub-note">Firing Grafana alerts, each with an “Ask Claude” prompt.</p>
<ul class="dd-alert-list"><li class="dd-empty">Loading…</li></ul>
<p class="dd-muted dd-stub-note dd-alerts-note"></p>
</div>
</section>
</aside>
</main>
+64
View File
@@ -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'))));
}
+72
View File
@@ -124,6 +124,78 @@ 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]));
}
// `devdesk attach --new|--rename|--end …`: resolves on {"ok":true}, otherwise
// rejects with devdesk's own error message.
export async function sessionAction(action, ...args) {
+153 -4
View File
@@ -1,9 +1,13 @@
/* global __DEVDESK_VERSION__ */
import { createTerminal, loadFonts } from './term.js';
import { TtydConnection, 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';
import * as notify from './notify.js';
@@ -33,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'),
@@ -94,6 +100,135 @@ 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.
let lastCi = { key: '', state: '' };
let repoBusy = false;
async function refreshRepo() {
const session = state.session;
if (!session) {
renderRepo($('widget-repo'), null);
return;
}
if (repoBusy) return;
repoBusy = true;
try {
const repo = await fetchRepo(session);
if (session !== state.session) return;
renderRepo($('widget-repo'), repo);
const key = `${session}@${repo.sha}`;
const ci = repo.ci?.total ? repo.ci.state : '';
if (key === lastCi.key && lastCi.state === 'pending' && ['success', 'failure', 'error'].includes(ci)) {
notify.ring(session, `CI ${ci === 'success' ? 'passed' : 'failed'} on ${repo.branch} @ ${repo.sha}`);
}
lastCi = { key, state: ci };
} catch {
// Keep the last rendering; the next refresh retries.
} finally {
repoBusy = false;
}
}
let refreshTimer = 0;
@@ -133,6 +268,7 @@ function connect(session) {
setWsState('connected', 'live');
setSso('ok');
if (session) rememberSession(session);
refreshRepo();
fit.fit();
term.focus();
},
@@ -331,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
@@ -394,7 +537,10 @@ async function boot() {
ui.sessionChip.textContent = to;
sessionsPanel.setCurrent(to);
},
onData: () => showContext(),
onData: () => {
showContext();
refreshRepo();
},
},
});
bindAskClaude($('sidebar'), (text) => {
@@ -402,6 +548,9 @@ async function boot() {
term.focus();
});
wireChrome();
wireUploads();
sessionsPanel.refresh();
refreshAlerts();
connect(state.session);
}
+56
View File
@@ -0,0 +1,56 @@
// Repo / CI panel: git state of the current session's folder plus the Gitea CI
// status of its HEAD commit (`devdesk attach --repo <session>`).
const CI = {
success: ['passing', 'lt-status-completed'],
failure: ['failed', 'lt-status-failed'],
error: ['error', 'lt-status-failed'],
pending: ['running', 'lt-status-running'],
warning: ['warning', 'lt-status-pending'],
};
function link(href, text) {
const a = document.createElement('a');
a.href = href;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = text;
return a;
}
export function renderRepo(root, repo) {
const field = (name) => root.querySelector(`[data-field="${name}"]`);
const set = (name, value) => field(name).replaceChildren(value);
const note = root.querySelector('.dd-repo-note');
if (!repo || !repo.git) {
root.dataset.state = 'empty';
for (const f of ['repo', 'branch', 'dirty', 'prs']) set(f, '—');
const ci = field('ci');
ci.className = 'lt-status lt-status-pending';
ci.textContent = 'no data';
note.textContent = repo
? `${repo.dir} is not a git repository.`
: 'Open a session to see its repository.';
return;
}
root.dataset.state = 'live';
set('repo', repo.url ? link(repo.url, repo.repo) : (repo.remote || 'no remote'));
const sync = repo.upstream
? [repo.ahead ? `↑${repo.ahead}` : '', repo.behind ? `↓${repo.behind}` : ''].filter(Boolean).join(' ') || 'in sync'
: 'no upstream';
set('branch', `${repo.branch} @ ${repo.sha || '—'} · ${sync}`);
set('dirty', repo.dirty ? `${repo.dirty} changed` : 'clean');
const ci = field('ci');
const [label, cls] = repo.ci?.total ? (CI[repo.ci.state] || [repo.ci.state, 'lt-status-pending'])
: [repo.ahead ? 'not pushed' : 'no CI', 'lt-status-pending'];
ci.className = `lt-status ${cls}`;
ci.replaceChildren(repo.ci?.url ? link(repo.ci.url, label) : label);
set('prs', repo.url && repo.prs != null
? link(`${repo.url}/pulls`, `${repo.prs} open`)
: '—');
note.textContent = repo.repo ? '' : 'Not a Gitea repository; CI status unavailable.';
}
+23 -5
View File
@@ -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' },
+68 -7
View File
@@ -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,20 +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 { opacity: 0.6; }
[data-state="stub"] .lt-section-body { opacity: 0.75; }
/* ---- 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 {