Compare commits

..
9 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 0d86f89957 fix: list a just-created session immediately
build-deb / Lint, test, package (push) Successful in 1m55s
build-deb / Deploy to CT 126 (push) Successful in 2s
build-deb / Notify on failure (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:42:03 -04:00
jaredandClaude Opus 5.5 be9b026a01 feat: v0.5.0 header, switcher, phone key bar, shared polling, Matrix "needs you"
build-deb / Lint, test, package (push) Successful in 2m12s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- Header: no SSO chip ("Sign in again" only on expiry), state dot on the
  session chip, LIVE as a dot, ⋯ menu for text size/notifications/fullscreen,
  Panels badge for waiting sessions and alerts.
- Session switcher on the chip; bare URL reattaches the last session; + New
  opens the panel form with project folders.
- Notifications when other sessions start waiting; alerts polled with the
  panels closed; one tab polls for all tabs over BroadcastChannel.
- Sidebar: context bar per session instead of the Context panel; compact
  Repo/CI with "Ask Claude to fix CI"; legible labels.
- Phone: key bar, drawer with backdrop, visualViewport-aware layout, DOM
  renderer on touch, compact connection dot.
- devdesk-notify-matrix: one Matrix message per wait that lasts
  DEVDESK_MATRIX_DELAY seconds with nobody attached.

Closes #8
Closes #13
Closes #14
Closes #15
Closes #16
Closes #17
Closes #28

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:34:59 -04:00
jaredandClaude Opus 5.5 7aa624bc13 fix: v0.5.0 security and robustness fixes from the 2026-10-07 review
build-deb / Lint, test, package (push) Successful in 1m49s
build-deb / Deploy to CT 126 (push) Skipped
build-deb / Notify on failure (push) Skipped
- Ask Claude: strip control characters, send as bracketed paste (closes #2)
- end: forget before kill, so ending from inside a session leaves no ghost (closes #3)
- reconnects use `attach --existing` and never resurrect ended/renamed sessions (closes #4)
- repo: git --no-optional-locks, no safe.directory override, refresh on connect/bell/60s (closes #5)
- uploads: unique names via mktemp, cleanup trap on hangup (closes #6)
- clipboard: write-only OSC 52 provider, tmux allow-passthrough off (closes #7)
- uploads: size check up front, streamed slices, idle timeout (closes #9)
- --list: path last in TSV rows, tolerant numbers (closes #10)
- tokens via curl config on stdin; userinfo stripped from remote URLs (closes #11)
- ttyd max clients 32 and at most 2 concurrent side-channel calls (#8; BroadcastChannel polling still open)
- tmux: fixed terminal-features index (no duplicates on re-source), no status-right clock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:15:47 -04:00
jaredandClaude Opus 5.5 364bd9b66f docs: README for 0.4.0 (status, file drop, Ops Alerts)
build-deb / Lint, test, package (push) Successful in 1m45s
build-deb / Deploy to CT 126 (push) Skipped
build-deb / Notify on failure (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 19:31:01 -04:00
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
jaredandClaude Opus 5.5 527cb1fda2 feat: free-form sessions, rename/end from the panel, live Claude Context (0.2.0)
build-deb / Lint, test, package (push) Successful in 2m0s
build-deb / Notify on failure (push) Skipped
- Sessions no longer need a project folder: any name works and starts in
  DEVDESK_DEFAULT_DIR unless it matches a project; `devdesk new NAME [PROJECT]`.
- Rename (✎) and end/forget (×) sessions from the Sessions panel, plus
  `devdesk rename`. The browser runs them as `devdesk attach --new|--rename|--end`
  through ttyd. Renames keep the running Claude and its recorded conversation:
  the hook, the run-wrapper and the statusline ask tmux for the current name.
- Claude Context panel is live: devdesk-statusline snapshots model,
  context-window use, cost and edits per session, which --list reports;
  Claude's footer gets a one-line summary. The panel now sits under Sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:03:09 -04:00
36 changed files with 2565 additions and 331 deletions
+1 -1
View File
@@ -8,6 +8,6 @@
"no-undef": "error",
"no-empty": "warn",
"semi": ["error", "always"],
"eqeqeq": "error"
"eqeqeq": ["error", "always", { "null": "ignore" }]
}
}
+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 $(DESTDIR)$(LIBDIR)/
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix $(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
+32 -10
View File
@@ -22,12 +22,32 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- **Only `systemctl stop devdesk-tmux` (or removing the package) ends the sessions.**
## Sessions
- **How the session is chosen:**
- `https://dev.lotusguild.org/?arg=<name>` opens session `<name>` and creates it on demand in `$DEVDESK_PROJECTS_ROOT/<name>`. `main` uses the root itself, and folders with spaces are reached by slug (`test code` → `test-code`).
- With no `?arg=`, an fzf picker opens, listing running (●), dormant (○) and new (+) sessions.
- **Any name works.** Sessions don't have to be repo work:
- A name matching a project folder (`tinker_tickets`, or `test-code` for `test code`) starts there.
- Anything else (`pbs-upgrade`, `ceph-check`) starts in `$DEVDESK_DEFAULT_DIR`, which is `/root/code` by default.
- `main` is the projects root.
- **Opening one:**
- `https://dev.lotusguild.org/?arg=<name>` opens or creates that session.
- With no `?arg=`, DevDesk reattaches the session you used last in this browser (or the most recently active one). The fzf picker, listing running (●), dormant (○) and new (+) sessions, is under the session chip's menu.
- **Session chip** (header): click to switch sessions; its dot shows Claude's state.
- **+ New** in the Sessions panel takes any name plus an optional project folder.
- **Rename / end:** the ✎ and × buttons on each session in the panel. Renaming keeps the session, its running Claude and its recorded conversation. Ending stops Claude; the conversation stays in Claude's history.
- **Windows:** each session has a `claude` window (0) and a `shell` window (1).
- **Conversation tracking:** a Claude `SessionStart` hook records each session's conversation ID in `/var/lib/devdesk/registry.json`. After a reboot the session resumes *that* conversation, even when other Claude instances, such as code-server, use the same directory.
- **SSH:** `devdesk attach <name>`, `devdesk ls`, `devdesk pick`, `devdesk end <name>`.
- **Session status:** Claude hooks (`devdesk-state-hook`) mark each session **working**, **waiting on you** (with Claude's message) or **idle**.
- The sidebar shows it with context % and a context bar; the session chip's dot shows the current session's state.
- A "… needs you" chip jumps to other sessions that are waiting, and a desktop notification fires when one starts waiting. With the panels closed, the **Panels** button shows a count of waiting sessions and Grafana alerts.
- **Matrix "Claude needs you":** when a session has waited for `DEVDESK_MATRIX_DELAY` seconds (30) and no browser tab or SSH client is attached to it, `devdesk-notify-matrix` posts one message per wait, with a link, to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off).
- **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`.
- **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.
- **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.
- **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`.
## Keys
| Key | Action |
@@ -40,13 +60,16 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
| Shift+drag | Native browser selection (plain drag goes to tmux copy-mode, which copies via OSC 52) |
| Ctrl+Shift+C / V | Copy selection / paste |
Use the **Full** button for fullscreen. In Chromium it also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
Text size, desktop notifications (with a mute) and fullscreen are in the **⋯** menu. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
## Configuration
- `/etc/devdesk/env` (conffile):
- port, bind address and projects root
- **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
- `DEVDESK_PUBLIC_URL`, `DEVDESK_MATRIX_WEBHOOK_FILE` and `DEVDESK_MATRIX_DELAY` for the Matrix notifications
- `/etc/devdesk/secret.env` is generated on install. It holds the ttyd credential that NPM must inject; see [docs/DEPLOY.md](docs/DEPLOY.md).
- `/etc/devdesk/tmux.local.conf` is optional and is sourced after the packaged `tmux.conf`.
@@ -62,8 +85,7 @@ 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 and Claude Context panels, Matrix notifications when you're away, and auto-deploy.
- **v2:** Ops Alerts (Grafana) with "Ask Claude" prompts, and file drop.
Tracked in [Gitea issues](https://code.lotusguild.org/LotusGuild/devdesk/issues) by milestone: v0.6.0 file viewer, v0.7.0 editor + inbox, backlog.
+252 -40
View File
@@ -1,24 +1,32 @@
#!/bin/bash
# devdesk — persistent Claude Code sessions in tmux, shared by the browser UI and SSH.
#
# devdesk attach [NAME] attach to session NAME (created on demand); no NAME opens the picker
# devdesk attach --list print sessions/projects as JSON (used by the web UI over ttyd)
# devdesk new NAME create session NAME detached, if it does not exist
# devdesk ls list sessions
# devdesk end NAME end session NAME and forget it
# devdesk attach [NAME] attach to session NAME, creating it if needed; no NAME opens the picker
# devdesk new NAME [PROJECT] create session NAME (detached) in PROJECT's folder, or the default folder
# devdesk rename OLD NEW rename a session (running or dormant)
# devdesk end NAME stop session NAME and forget it
# devdesk repo NAME git + Gitea CI status of the session's folder (JSON)
# devdesk alerts firing/pending Grafana alerts (JSON)
# devdesk ls list sessions
# devdesk pick interactive picker
#
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --upload SESSION FILE BYTES |
# --new NAME [PROJECT] | --rename OLD NEW | --end NAME | --existing NAME`
# through ttyd; those print JSON.
set -euo pipefail
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
usage() {
sed -n '2,9s/^# \{0,1\}//p' "$0"
sed -n '2,12s/^# \{0,1\}//p' "$0"
}
# Tab-separated running sessions: name, dir, attached clients, last activity, windows.
running_tsv() {
dd_server_up || return 0
dd_tmux list-sessions -F '#{session_name} #{session_path} #{session_attached} #{session_activity} #{session_windows}' 2>/dev/null || true
# Path last: it may contain anything (tabs included) and is re-joined below.
dd_tmux list-sessions -F '#{session_name} #{session_attached} #{session_activity} #{session_windows} #{session_path}' 2>/dev/null || true
}
# Tab-separated top-level project directories: slug, dir.
@@ -36,6 +44,18 @@ projects_tsv() {
done
}
# {"<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
fi | jq -cMs 'add // {}'
}
cmd_list() {
local reg='{"sessions":{}}'
[ -s "$DEVDESK_REGISTRY" ] && reg=$(cat "$DEVDESK_REGISTRY")
@@ -43,13 +63,15 @@ cmd_list() {
--argjson reg "$reg" \
--arg run "$(running_tsv)" \
--arg proj "$(projects_tsv)" \
--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"));
($reg.sessions // {}) as $known
| [rows($run)[] | {name: .[0], dir: .[1], running: true,
attached: (.[2] | tonumber), activity: (.[3] | tonumber),
windows: (.[4] | tonumber)}] as $live
| [rows($run)[] | {name: .[0], dir: (.[4:] | join("\t")), running: true,
attached: (.[1] | tonumber? // 0), activity: (.[2] | tonumber? // 0),
windows: (.[3] | tonumber? // 0)}] as $live
| ($live | map(.name)) as $liveNames
| {default: $dflt, root: $root,
sessions: (
@@ -59,30 +81,23 @@ 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), status: ($st[.name] // null)}),
projects: ([{name: $dflt, dir: $root}]
+ [rows($proj)[] | {name: .[0], dir: .[1]}
+ [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))}
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
}
cmd_ls() {
cmd_list | jq -Mr '
.sessions | if length == 0 then "no sessions" else
(["", "SESSION", "CLIENTS", "DIRECTORY"] | @tsv),
(.[] | [(if .running then "●" else "○" end), .name, (.attached | tostring), .dir] | @tsv)
(["", "SESSION", "CLIENTS", "CONTEXT", "DIRECTORY"] | @tsv),
(.[] | [(if .running then "●" else "○" end), .name, (.attached | tostring),
(if .context.used_pct then "\(.context.used_pct | floor)%" else "-" end), .dir] | @tsv)
end' | column -t -s $'\t'
}
# Prints the project directory for NAME, or "" if NAME already runs and has none we can resolve.
session_dir() {
local name=$1 dir
if dir=$(dd_resolve_dir "$name"); then
printf '%s\n' "$dir"
elif dd_tmux has-session -t "=$name" 2>/dev/null; then
printf '%s\n' "$DEVDESK_PROJECTS_ROOT"
else
dd_die "no project directory for '$name' under $DEVDESK_PROJECTS_ROOT"
fi
session_exists() {
{ dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } || [ -n "$(dd_registry_get "$1" dir)" ]
}
create_detached() {
@@ -96,27 +111,230 @@ create_detached() {
}
cmd_new() {
[ $# -eq 1 ] || dd_die "usage: devdesk new NAME"
{ [ $# -ge 1 ] && [ $# -le 2 ]; } || dd_die "usage: devdesk new NAME [PROJECT]"
dd_valid_name "$1" || dd_die "invalid session name: $1"
dd_ensure_server
local dir
dir=$(session_dir "$1")
if [ $# -eq 2 ]; then
dd_valid_name "$2" || dd_die "invalid project name: $2"
dir=$(dd_project_dir "$2") || dd_die "no project '$2' under $DEVDESK_PROJECTS_ROOT"
! session_exists "$1" || dd_die "a session named '$1' already exists"
else
dir=$(dd_resolve_dir "$1")
fi
dd_ensure_server
create_detached "$1" "$dir"
}
cmd_rename() {
[ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW"
local old=$1 new=$2 running=0
dd_valid_name "$old" || dd_die "invalid session name: $old"
dd_valid_name "$new" || dd_die "invalid session name: $new"
[ "$old" != "$new" ] || return 0
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"
fi
dd_registry_rename "$old" "$new"
}
cmd_end() {
[ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
# Forget first: killing the session SIGHUPs us when run from inside it.
dd_registry_remove "$1"
rm -rf "${DEVDESK_STATE_DIR:?}/uploads/$1"
if dd_server_up; then
dd_tmux kill-session -t "=$1" 2>/dev/null || true
fi
}
# GET $DEVDESK_GITEA_URL/api/v1<path>; token optional (private repos only).
gitea_get() {
# The token goes in via a curl config on stdin, never on the command line (ps).
{
if [ -r "$DEVDESK_GITEA_TOKEN_FILE" ]; then
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token
printf 'header = "Authorization: token %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GITEA_TOKEN_FILE")"
fi
} | curl -fsS --max-time 4 -K - "$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")
# --no-optional-locks: never take .git/index.lock (Claude's own git would collide).
# No safe.directory override: repos owned by other users stay untrusted.
g=(git --no-optional-locks -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 | sed -E 's#^([a-z+]+://)[^/@]*@#\1#' || 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 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() {
[ $# -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
# Unique even for same-second uploads of the same name.
dest=$(mktemp --tmpdir="$dir" --suffix="-$name" "$(date +%Y%m%d-%H%M%S)-XXXX")
# A dropped connection SIGHUPs us mid-transfer: never leave a partial file.
trap 'rm -f "$dest"' HUP INT TERM
# 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
trap - HUP INT TERM
chmod 0644 "$dest"
jq -cMn --arg p "$dest" '{ok: true, path: $p}'
}
ok_json() {
printf '{"ok":true}\n'
}
cmd_attach() {
if [ $# -eq 0 ]; then
exec "$DEVDESK_LIBEXEC/devdesk-picker"
fi
[ $# -eq 1 ] || dd_die "attach takes exactly one session name"
if [ "$1" = "--list" ]; then
case $1 in
--list)
[ $# -eq 1 ] || dd_die "--list takes no arguments"
cmd_list
return
fi
;;
--repo)
shift
cmd_repo "$@"
return
;;
--alerts)
cmd_alerts
return
;;
--upload)
shift
cmd_upload "$@"
return
;;
--new | --rename | --end)
local action=${1#--}
shift
"cmd_$action" "$@"
ok_json
return
;;
--existing)
# Reconnects: never re-create a session that was ended or renamed meanwhile.
shift
[ $# -eq 1 ] || dd_die "attach --existing takes exactly one session name"
dd_valid_name "$1" || dd_die "invalid session name: $1"
if ! { dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } && [ -z "$(dd_registry_get "$1" dir)" ]; then
dd_die "session '$1' no longer exists (it was ended or renamed)"
fi
;;
-*) dd_die "unknown option: $1" ;;
esac
[ $# -eq 1 ] || dd_die "attach takes exactly one session name"
local name=$1 dir args
dd_valid_name "$name" || dd_die "invalid session name: $name"
dd_ensure_server
dir=$(session_dir "$name")
dir=$(dd_resolve_dir "$name")
# Already inside a DevDesk pane: switch this client instead of nesting.
local outer=${TMUX:-}
@@ -134,20 +352,14 @@ cmd_attach() {
exec tmux -N -u -S "$DEVDESK_SOCKET" new-session -A "${args[@]}"
}
cmd_end() {
[ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
if dd_server_up; then
dd_tmux kill-session -t "=$1" 2>/dev/null || true
fi
dd_registry_remove "$1"
}
cmd=${1:-help}
[ $# -gt 0 ] && shift
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 "$@" ;;
+113
View File
@@ -1,3 +1,116 @@
devdesk (0.5.1) bookworm; urgency=medium
* A session created by opening its URL shows up in the Sessions panel and
the switcher right away instead of at the next poll.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 23:42:01 -0400
devdesk (0.5.0) bookworm; urgency=medium
* Security (from the 2026-10-07 review):
- ttyd :7681 is firewalled to NPM in CT 126 and its credential was
rotated (#1).
- "Ask Claude" strips control characters from Grafana text and pastes it
as bracketed paste, so alert text can never run a command (#2).
- OSC 52 clipboard is write-only: terminal output can no longer read the
browser clipboard; tmux allow-passthrough is off (#7).
- Gitea/Grafana tokens reach curl on stdin, never on its command line;
remote URLs lose any userinfo (#11).
* Fixes:
- Ending the session you are in no longer leaves a ghost entry (#3).
- Reconnecting after a drop never re-creates a session that was ended or
renamed meanwhile (`attach --existing`) (#4).
- Repo panel uses git --no-optional-locks and drops the safe.directory
override (#5); uploads get unique names and are cleaned up on hangup
(#6); uploads are size-checked first and streamed in slices with an
idle timeout (#9); paths with tabs no longer break the session list
(#10).
- ttyd allows 32 clients; side-channel calls run two at a time and one
tab polls for all open tabs (#8).
* Header: the SSO chip is gone ("Sign in again" appears only when the
session expires); the session chip shows Claude's state as a dot and
opens a session switcher; LIVE is a dot; text size, notifications (with
mute) and fullscreen move into a ⋯ menu; the Panels button shows a badge
for waiting sessions and alerts (#13, #16).
* A bare URL reattaches your last session instead of opening the picker
(#16).
* Notifications when *other* sessions start waiting, and alerts are polled
with the panels closed (#15).
* Sidebar: the Claude Context panel is folded into a context bar in each
session row; Repo/CI collapses for non-git folders and offers "Ask Claude
to fix CI"; the new-session form hides behind "+ New"; more legible
labels (#17).
* Phone: key bar (Esc, Tab, ⇧Tab, ^C, arrows, ⏎, Claude/Shell), drawer
with backdrop, layout that follows the on-screen keyboard, DOM renderer
on touch devices, no clock in the tmux status line (#14).
* Matrix "Claude needs you": a wait that lasts 30 s with nobody attached
posts one message with a link to the Devdesk hookshot webhook (#28).
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 23:34:48 -0400
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
DEVDESK_DEFAULT_DIR unless it matches a project; new NAME [PROJECT].
* Rename and end sessions from the Sessions panel (and devdesk rename).
Renames keep the running Claude and its recorded conversation.
* Claude Context panel is live: a statusLine command snapshots model,
context-window use, cost and edits per session; Claude's footer shows a
one-line summary.
* Drop GANDALF from the Ops Alerts panel.
* CI: bash shell, download retries, Node 22 test invocation,
build-essential, smoke-test pipe fix.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 03:01:21 -0400
devdesk (0.1.0) bookworm; urgency=medium
* Initial release: persistent browser terminal for Claude Code
+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
+46 -3
View File
@@ -36,15 +36,58 @@ 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
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.
## 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. Firewall (required)
The ttyd credential is the only thing between the LAN and a root shell, so only NPM may reach :7681. CT 126 ships this in pve-infra (`containers/126-codingenvironment/etc/nftables.conf`, installed by its `deploy.sh`):
```
table inet devdesk {
chain input {
type filter hook input priority filter - 10; policy accept;
tcp dport 7681 ip saddr { 127.0.0.1, 10.10.10.27 } accept
tcp dport 7681 ip6 saddr ::1 accept
tcp dport 7681 counter reject with tcp reset
}
}
```
Check from another LAN host: `curl -m3 http://10.10.10.58:7681/` must fail with "connection refused". To rotate the credential: `rm /etc/devdesk/secret.env && dpkg-reconfigure devdesk`, put the new `Authorization` value in NPM proxy host 58's Advanced tab, then `systemctl restart devdesk`.
## 7. Matrix "Claude needs you" (optional)
1. Deploy a hookshot transform named `Devdesk` (`hookshot/devdesk.js` in the matrix repo; pushing it to `main` auto-deploys it on LXC 151). Look up the hook UUID as described in that repo's README.
2. `umask 077; echo https://matrix.lotusguild.org/webhook/<uuid> > /etc/devdesk/matrix-webhook`.
3. Nothing to restart: the next wait that lasts `DEVDESK_MATRIX_DELAY` seconds with nobody attached posts a message.
## Operations
| Task | Command |
|---|---|
| Sessions | `devdesk ls` |
| Attach over SSH | `devdesk attach <name>` (or `devdesk pick`) |
| End a session | `devdesk end <name>` (or `[x]` in the Claude window's exit menu) |
| New session (optionally in a project) | `devdesk new <name> [project]`, or the Sessions panel form |
| Rename a session | `devdesk rename <old> <new>`, or ✎ in the Sessions panel |
| End a session | `devdesk end <name>`, × in the Sessions panel, or `[x]` in the Claude window's exit menu |
| Restart the web side (safe) | `systemctl restart devdesk` |
| **Stop every session** | `systemctl stop devdesk-tmux` |
| Logs | `journalctl -u devdesk -u devdesk-tmux` |
+14 -6
View File
@@ -7,6 +7,10 @@ devdesk \- persistent Claude Code sessions in tmux, shared by the browser and SS
.br
.B devdesk new
.I NAME
.RI [ PROJECT ]
.br
.B devdesk rename
.I OLD NEW
.br
.B devdesk ls
.br
@@ -26,16 +30,20 @@ resumes its own conversation.
.SH COMMANDS
.TP
.B attach \fR[\fINAME\fR]
Attach to session \fINAME\fR, creating it if needed. The default session
(\fBmain\fR) uses the projects root; other names map to the project directory of
that name or slug (\(lqtest code\(rq becomes \fBtest-code\fR). Without \fINAME\fR,
opens the picker.
Attach to session \fINAME\fR, creating it if needed. Names matching a project
folder (or its slug: \(lqtest code\(rq becomes \fBtest-code\fR) start there; any
other name starts in \fBDEVDESK_DEFAULT_DIR\fR. Without \fINAME\fR, opens the picker.
.TP
.B attach --list
Print sessions and projects as JSON (used by the web UI).
.TP
.B new \fINAME\fR
Create session \fINAME\fR detached, if it does not exist.
.B new \fINAME\fR [\fIPROJECT\fR]
Create session \fINAME\fR detached, in \fIPROJECT\fR's folder if given, otherwise
in the folder its name maps to (a project of that name, or \fBDEVDESK_DEFAULT_DIR\fR).
.TP
.B rename \fIOLD NEW\fR
Rename a running or dormant session; Claude keeps running and its recorded
conversation moves with it.
.TP
.B ls
List running (\(bu) and dormant (\(ci) sessions.
+28 -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).
@@ -12,9 +15,32 @@ DEVDESK_BIND=
DEVDESK_PROJECTS_ROOT=/root/code
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=
# Where DevDesk is reached in a browser; used for links in Matrix notifications.
DEVDESK_PUBLIC_URL=https://dev.lotusguild.org
# "Claude needs you" in Matrix when a session waits and no tab or SSH client is
# attached: put a hookshot webhook URL in this file (mode 600). No file = off.
# DEVDESK_MATRIX_DELAY: seconds the wait must last before posting.
DEVDESK_MATRIX_WEBHOOK_FILE=/etc/devdesk/matrix-webhook
DEVDESK_MATRIX_DELAY=30
# 1 = also enable Remote Control so sessions can be continued from the Claude app.
DEVDESK_REMOTE_CONTROL=0
+14 -3
View File
@@ -7,10 +7,18 @@ set -uo pipefail
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
NAME=${DEVDESK_SESSION:?DEVDESK_SESSION is not set}
CLAUDE_BIN=${CLAUDE_BIN:-claude}
NAME=$(dd_current_session)
[ -n "$NAME" ] || dd_die "not running inside a DevDesk session"
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'
@@ -63,8 +71,11 @@ menu() {
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)
while :; do
menu "$rc"
IFS= read -rsn1 key || exit 0
+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)"
+85 -24
View File
@@ -5,20 +5,42 @@
# 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}"
: "${DEVDESK_DEFAULT_SESSION:=main}"
: "${DEVDESK_SOCKET:=/run/devdesk/tmux.sock}"
: "${DEVDESK_STATE_DIR:=/var/lib/devdesk}"
: "${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_PUBLIC_URL:=}"
: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}"
: "${DEVDESK_MATRIX_DELAY:=30}"
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() {
@@ -99,29 +121,33 @@ 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"
}
# Session name -> absolute project directory (must resolve inside the projects root).
dd_resolve_dir() {
local name=$1 dir="" d root
if [ "$name" = "$DEVDESK_DEFAULT_SESSION" ]; then
dir=$DEVDESK_PROJECTS_ROOT
else
dir=$(dd_registry_get "$name" dir)
if [ -z "$dir" ] || [ ! -d "$dir" ]; then
dir=""
if [ -d "$DEVDESK_PROJECTS_ROOT/$name" ]; then
dir="$DEVDESK_PROJECTS_ROOT/$name"
else
for d in "$DEVDESK_PROJECTS_ROOT"/*/; do
d=${d%/}
if [ "$(dd_slugify "${d##*/}")" = "$name" ]; then
dir=$d
break
fi
done
fi
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"
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.
dd_project_dir() {
local name=$1 dir="" d root
if [ -d "$DEVDESK_PROJECTS_ROOT/$name" ]; then
dir="$DEVDESK_PROJECTS_ROOT/$name"
else
for d in "$DEVDESK_PROJECTS_ROOT"/*/; do
d=${d%/}
if [ "$(dd_slugify "${d##*/}")" = "$name" ]; then
dir=$d
break
fi
done
fi
[ -n "$dir" ] || return 1
dir=$(realpath -e -- "$dir" 2>/dev/null) || return 1
@@ -132,6 +158,41 @@ dd_resolve_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() {
local name=$1 dir
dir=$(dd_registry_get "$name" dir)
if [ -n "$dir" ] && [ -d "$dir" ]; then
printf '%s\n' "$dir"
elif [ "$name" != "$DEVDESK_DEFAULT_SESSION" ] && dir=$(dd_project_dir "$name"); then
printf '%s\n' "$dir"
else
realpath -e -- "$DEVDESK_DEFAULT_DIR" 2>/dev/null || printf '%s\n' "$HOME"
fi
}
# 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="" 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:-}}"
}
# Arguments for `tmux new-session` that create session NAME in DIR running Claude.
dd_new_session_args() {
printf '%s\0' -s "$1" -c "$2" -n claude -e "DEVDESK_SESSION=$1" "$DEVDESK_LIBEXEC/devdesk-claude-run"
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# devdesk-notify-matrix SESSION SINCE
#
# Started (detached) by devdesk-state-hook when a session starts waiting on you.
# After DEVDESK_MATRIX_DELAY seconds, if that same wait is still on and nobody is
# attached to the session (no browser tab, no SSH client), post one "Claude needs
# you" message to the hookshot webhook in DEVDESK_MATRIX_WEBHOOK_FILE (devdesk#28).
set -euo pipefail
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
session=${1:-}
since=${2:-}
dd_valid_name "$session" || exit 0
[[ $since =~ ^[0-9]+$ ]] || exit 0
[ -r "$DEVDESK_MATRIX_WEBHOOK_FILE" ] || exit 0
sleep "$DEVDESK_MATRIX_DELAY"
state_file="$DEVDESK_STATUS_DIR/$session.json"
# Still the same wait? (Answering it moves the state on; a new wait has its own notifier.)
[ "$(jq -rM '"\(.state) \(.since)"' "$state_file" 2>/dev/null)" = "waiting $since" ] || exit 0
# Anyone looking at it already?
attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null |
awk -v s="$session" '$1 == s { print $2 }')
[ "${attached:-0}" = 0 ] || exit 0
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
url=""
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session"
body=$(jq -cMn --arg s "$session" --arg m "$message" --arg u "$url" --arg h "$(hostname)" \
'{session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h,
text: ("Claude needs you in " + $s + ": " + $m + (if $u == "" then "" else " — " + $u end))}')
# The webhook URL is a secret (anyone holding it can post to the room): pass it
# to curl on stdin, never on the command line.
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the URL
webhook=$(tr -d '[:space:]"\\' <"$DEVDESK_MATRIX_WEBHOOK_FILE")
printf 'url = "%s"\n' "$webhook" |
curl -fsS --max-time 10 --retry 2 -K - -H 'Content-Type: application/json' --data-binary "$body" >/dev/null
+1 -1
View File
@@ -13,7 +13,7 @@ args=(
--writable # ttyd >= 1.7.4 is read-only by default
--url-arg # ?arg=<session> becomes `devdesk attach <session>`
--check-origin # refuse cross-site WebSocket connections
--max-clients "${DEVDESK_MAX_CLIENTS:-8}"
--max-clients "${DEVDESK_MAX_CLIENTS:-32}"
--credential "$DEVDESK_CREDENTIAL" # injected by NPM as an Authorization header
--index "${DEVDESK_INDEX:-/usr/share/devdesk/index.html}"
--debug "${DEVDESK_TTYD_LOG_LEVEL:-3}" # errors+warnings only: notice level would log the credential
+4 -3
View File
@@ -7,13 +7,14 @@
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" || exit 0
[ -n "${DEVDESK_SESSION:-}" ] || exit 0
dd_valid_name "$DEVDESK_SESSION" || 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 "$DEVDESK_SESSION" "$id" "$source" 2>/dev/null
dd_registry_set_claude "$session" "$id" "$source" 2>/dev/null
exit 0
+56
View File
@@ -0,0 +1,56 @@
#!/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.
# Entering "waiting" also starts devdesk-notify-matrix, which posts to Matrix if
# the wait lasts and nobody is attached (devdesk#28).
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
# A second Notification during the same wait updates the message but keeps
# `since`, so the wait stays one wait (one Matrix message at most).
since=$(date +%s)
was_waiting=0
if [ "$state" = waiting ] && grep -q '"state":"waiting"' "$file" 2>/dev/null; then
was_waiting=1
since=$(jq -rM '.since // empty' "$file" 2>/dev/null) || since=$(date +%s)
[[ $since =~ ^[0-9]+$ ]] || since=$(date +%s)
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" --argjson t "$since" \
'{state: $s, since: $t, event: $e, message: (if $m == "" then null else $m end)}' >"$tmp"; then
mv -f "$tmp" "$file"
else
rm -f "$tmp"
exit 0
fi
if [ "$state" = waiting ] && [ "$was_waiting" = 0 ]; then
setsid -f "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-notify-matrix" "$session" "$since" \
</dev/null >/dev/null 2>&1 || true
fi
exit 0
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# Claude Code statusLine command (wired in via claude-settings.json). Claude pipes
# its status JSON in on every update; we keep a snapshot per DevDesk session for
# the web UI's Claude Context panel and print a one-line status for Claude's footer.
# Runs often while Claude streams, so it stays cheap and never fails loudly.
input=$(cat)
# 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
if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
dir="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/context"
if mkdir -p "$dir" 2>/dev/null && tmp=$(mktemp "$dir/.$session.XXXXXX" 2>/dev/null); then
if jq -cM '{
updated: (now | floor),
claude_session_id: .session_id,
model: .model.display_name,
model_id: .model.id,
effort: .effort.level,
used_pct: .context_window.used_percentage,
window: .context_window.context_window_size,
used_tokens: (.context_window.current_usage
| if . then ((.input_tokens // 0) + (.cache_creation_input_tokens // 0)
+ (.cache_read_input_tokens // 0)) else null end),
cost_usd: .cost.total_cost_usd,
lines_added: .cost.total_lines_added,
lines_removed: .cost.total_lines_removed
}' <<<"$input" >"$tmp" 2>/dev/null; then
mv -f "$tmp" "$dir/$session.json"
else
rm -f "$tmp"
fi
fi
fi
# Footer line, in TDS colours: session · model · context % · cost
jq -rM --arg s "$session" '
def c($rgb; $t): "\u001b[38;2;\($rgb)m\($t)\u001b[0m";
def pct: if . == null then "—" else "\(. | floor)%" end;
[ (if $s != "" then c("255;107;0"; "◈ \($s)") else empty end),
c("0;212;255"; .model.display_name // "Claude"),
c("127;163;191"; "ctx \(.context_window.used_percentage | pct)"),
c("62;96;122"; "$\((.cost.total_cost_usd // 0) * 100 | round / 100)")
] | join(c("62;96;122"; " · "))' <<<"$input" 2>/dev/null || true
+66
View File
@@ -8,9 +8,75 @@
"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
}
]
}
]
},
"statusLine": {
"type": "command",
"command": "/usr/lib/devdesk/devdesk-statusline",
"padding": 0
}
}
+3 -3
View File
@@ -6,10 +6,10 @@ set -s escape-time 10 # Esc reaches Claude (interrupt) immediat
set -s focus-events on
set -s set-clipboard on # tmux copy-mode -> OSC 52 -> browser clipboard
set -s default-terminal "tmux-256color"
set -as terminal-features ",xterm-256color:RGB"
set -s terminal-features[90] "xterm-256color:RGB" # fixed index: re-sourcing must not append
set -g history-limit 50000
set -g mouse on # wheel scrolls history; Shift+drag selects natively
set -g allow-passthrough on
set -g allow-passthrough off # output may not bypass tmux (title/clipboard spoofing)
set -g renumber-windows on
# --- bells reach the browser (Claude's terminal_bell notifications) ----------
@@ -29,7 +29,7 @@ set -g status-position bottom
set -g status-style "bg=#060c14,fg=#7fa3bf"
set -g status-left "#[fg=#FF6B00,bold] #S #[fg=#3e607a,nobold]│ "
set -g status-left-length 40
set -g status-right "#[fg=#3e607a]devdesk #[fg=#00D4FF]%H:%M "
set -g status-right ""
set -g window-status-format " #I:#W "
set -g window-status-current-format "#[fg=#030508,bg=#00D4FF,bold] #I:#W "
set -g window-status-bell-style "fg=#FFB300,bold"
+315 -3
View File
@@ -12,6 +12,7 @@ setup() {
export DEVDESK_LIBEXEC="$REPO/libexec"
export DEVDESK_SHARE="$REPO/share"
export DEVDESK_NO_SYSTEMD=1
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 PATH="$REPO/bin:$PATH"
@@ -46,11 +47,14 @@ claude_args() {
@test "rejects invalid session names" {
start_server
for bad in ../etc "a b" -x .hidden "$(printf 'x%.0s' {1..70})"; do
for bad in ../etc "a b" .hidden "$(printf 'x%.0s' {1..70})"; do
run devdesk attach "$bad"
[ "$status" -ne 0 ]
[[ $output == *"invalid session name"* ]]
done
run devdesk attach -x
[ "$status" -ne 0 ]
[[ $output == *"unknown option"* ]]
run devdesk attach one two
[ "$status" -ne 0 ]
}
@@ -98,12 +102,320 @@ claude_args() {
[ "$output" = "$DEVDESK_PROJECTS_ROOT/test code" ]
}
@test "sessions without a project start in the default folder" {
start_server
run devdesk new pbs-upgrade
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" display -p -t =pbs-upgrade:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT" ]
}
@test "symlinks may not escape the projects root" {
start_server
run devdesk new escape
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" display -p -t =escape:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT" ]
run devdesk new other escape
[ "$status" -ne 0 ]
[[ $output == *"no project directory"* ]]
run devdesk new nonexistent
[[ $output == *"no project 'escape'"* ]]
}
@test "new NAME PROJECT starts the session in that project" {
start_server
run devdesk new ops-alpha alpha
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" display -p -t =ops-alpha:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha" ]
run devdesk new ops-alpha alpha
[ "$status" -ne 0 ]
[[ $output == *"already exists"* ]]
}
@test "rename moves a running session, its registry entry and its context" {
start_server
devdesk new alpha
echo '{"session_id":"abc-123","source":"startup"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
mkdir -p "$DEVDESK_STATE_DIR/context" && echo '{"used_pct":12}' >"$DEVDESK_STATE_DIR/context/alpha.json"
run devdesk rename alpha ops
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" has-session -t =ops
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" show-environment -t =ops DEVDESK_SESSION
[ "$output" = "DEVDESK_SESSION=ops" ]
[ "$(jq -r '.sessions.ops.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = abc-123 ]
[ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ]
[ -f "$DEVDESK_STATE_DIR/context/ops.json" ]
[ ! -e "$DEVDESK_STATE_DIR/context/alpha.json" ]
}
@test "rename refuses clashes and unknown sessions" {
start_server
devdesk new alpha
devdesk new main
run devdesk rename alpha main
[ "$status" -ne 0 ]
[[ $output == *"already exists"* ]]
run devdesk rename nosuch other
[ "$status" -ne 0 ]
[[ $output == *"no session named"* ]]
run devdesk rename alpha "bad name"
[ "$status" -ne 0 ]
}
@test "rename works for dormant sessions" {
echo '{"session_id":"old-1","source":"resume"}' | DEVDESK_SESSION=gone "$REPO/libexec/devdesk-session-hook"
jq '.sessions.gone.dir = "/tmp"' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
run devdesk rename gone archived
[ "$status" -eq 0 ]
[ "$(jq -r '.sessions.archived.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = old-1 ]
}
@test "hooks follow the tmux session name after a rename" {
start_server
devdesk new alpha
devdesk rename alpha renamed
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =renamed:shell '#{pane_id}')
echo '{"session_id":"after-rename","source":"clear"}' |
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
[ "$(jq -r '.sessions.renamed.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = after-rename ]
[ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ]
}
@test "statusline snapshots context for the session and --list reports it" {
start_server
devdesk new alpha
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
status_json='{"session_id":"s1","model":{"id":"m","display_name":"Opus"},"effort":{"level":"high"},
"context_window":{"context_window_size":1000000,"used_percentage":34.6,
"current_usage":{"input_tokens":1000,"cache_creation_input_tokens":2000,"cache_read_input_tokens":343000}},
"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%"* ]]
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 "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" ]
}
# Local stand-in for the hookshot webhook: appends each POST body to $TMP/posts.
start_receiver() {
python3 - "$TMP" <<'PY' &
import http.server, sys
tmp = sys.argv[1]
class H(http.server.BaseHTTPRequestHandler):
def do_POST(self):
body = self.rfile.read(int(self.headers['Content-Length']))
with open(f'{tmp}/posts', 'ab') as f:
f.write(body + b'\n')
self.send_response(200); self.end_headers(); self.wfile.write(b'{"ok":true}')
def log_message(self, *a):
pass
srv = http.server.HTTPServer(('127.0.0.1', 0), H)
open(f'{tmp}/port', 'w').write(str(srv.server_port))
srv.serve_forever()
PY
RECEIVER=$!
for _ in $(seq 1 50); do [ -s "$TMP/port" ] && break; sleep 0.1; done
printf 'http://127.0.0.1:%s/webhook/test\n' "$(cat "$TMP/port")" >"$TMP/webhook"
chmod 600 "$TMP/webhook"
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/webhook"
}
posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
@test "a wait nobody is attached to is posted to Matrix once (#28)" {
start_server
start_receiver
export DEVDESK_MATRIX_DELAY=1 DEVDESK_PUBLIC_URL=https://dev.example.org
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"; }
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | hook
# a second notification in the same wait: no second message
echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input"}' | hook
sleep 2.5
[ "$(posts)" -eq 1 ]
run jq -r '[.session, .url, (.text | test("Claude needs you in alpha"))] | @tsv' "$TMP/posts"
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha\ttrue')" ]
# answered within the delay: nothing is posted
echo '{"hook_event_name":"PostToolUse"}' | hook
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Edit"}' | hook
echo '{"hook_event_name":"PostToolUse"}' | hook
sleep 2
[ "$(posts)" -eq 1 ]
# no webhook file: feature off
rm "$TMP/webhook"
echo '{"hook_event_name":"Notification","message":"again"}' | hook
sleep 2
[ "$(posts)" -eq 1 ]
kill "$RECEIVER"
}
@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 "end run from inside the session leaves no ghost registry entry (#3)" {
start_server
devdesk new alpha
tmux -S "$DEVDESK_SOCKET" send-keys -t =alpha:shell "$REPO/bin/devdesk end alpha" Enter
for _ in $(seq 1 50); do
tmux -S "$DEVDESK_SOCKET" has-session -t =alpha 2>/dev/null || break
sleep 0.1
done
run tmux -S "$DEVDESK_SOCKET" has-session -t =alpha
[ "$status" -ne 0 ]
[ "$(jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json")" = false ]
}
@test "attach --existing refuses sessions that were ended or renamed (#4)" {
start_server
run devdesk attach --existing ghost
[ "$status" -ne 0 ]
[[ $output == *"no longer exists"* ]]
run devdesk attach --existing ../x
[ "$status" -ne 0 ]
}
@test "same-second uploads of the same name get distinct files (#6)" {
printf 'one' | base64 -w0 >"$TMP/a.b64"
printf 'two' | base64 -w0 >"$TMP/b.b64"
p1=$(devdesk attach --upload alpha image.png 3 <"$TMP/a.b64" | tail -n1 | jq -r .path)
p2=$(devdesk attach --upload alpha image.png 3 <"$TMP/b.b64" | tail -n1 | jq -r .path)
[ "$p1" != "$p2" ]
[ "$(cat "$p1")" = one ]
[ "$(cat "$p2")" = two ]
[[ $p1 == *-image.png ]]
}
@test "a tab in a project path does not break --list (#10)" {
mkdir -p "$DEVDESK_PROJECTS_ROOT/odd name"
start_server
run devdesk attach --list
[ "$status" -eq 0 ]
jq -e '.projects | map(.dir) | any(endswith("odd\tname"))' <<<"$output" >/dev/null
}
@test "repo hides credentials embedded in the remote URL (#11)" {
export DEVDESK_GITEA_URL=http://127.0.0.1:9
repo="$DEVDESK_PROJECTS_ROOT/alpha"
git -C "$repo" init -q -b main
git -C "$repo" remote add origin https://jared:hunter2@example.com/x/y.git
run devdesk repo alpha
[ "$status" -eq 0 ]
[ "$(jq -r .remote <<<"$output")" = https://example.com/x/y.git ]
[[ $output != *hunter2* ]]
}
@test "web actions run through attach and answer with JSON" {
start_server
run devdesk attach --new web1 alpha
[ "$output" = '{"ok":true}' ]
run devdesk attach --rename web1 web2
[ "$output" = '{"ok":true}' ]
run devdesk attach --end web2
[ "$output" = '{"ok":true}' ]
run tmux -S "$DEVDESK_SOCKET" has-session -t =web2
[ "$status" -ne 0 ]
run devdesk attach --bogus
[ "$status" -ne 0 ]
}
+20 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
Client, FlowControl, decodeServer, encodeBinaryInput, encodeInit, encodeInput, encodeResize,
parseListOutput, parseTitle, sessionFromSearch, tokenUrl, wsUrl,
parseListOutput, parseTitle, pasteSafe, sessionFromSearch, slugify, stripAnsi, tokenUrl, wsUrl,
} from '../web/src/protocol.js';
const text = (bytes) => new TextDecoder().decode(bytes);
@@ -34,6 +34,19 @@ test('URLs follow the page location, including a path prefix', () => {
assert.equal(tokenUrl(loc), 'https://dev.lotusguild.org/token');
const sub = { protocol: 'http:', host: 'localhost:7681', pathname: '/desk/index.html' };
assert.equal(wsUrl(sub, '--list'), 'ws://localhost:7681/desk/ws?arg=--list');
assert.equal(wsUrl(loc, ['--rename', 'old', 'new']), 'wss://dev.lotusguild.org/ws?arg=--rename&arg=old&arg=new');
});
test('free text becomes a valid session name', () => {
assert.equal(slugify(' PBS upgrade! '), 'PBS-upgrade');
assert.equal(slugify('--../etc'), 'etc');
assert.equal(slugify('tinker_tickets'), 'tinker_tickets');
assert.equal(slugify('!!!'), '');
assert.equal(slugify('x'.repeat(80)).length, 64);
});
test('ANSI colour and title sequences are stripped', () => {
assert.equal(stripAnsi('\x1b[1;31mdevdesk: no\x1b[0m\x1b]0;t\x07'), 'devdesk: no');
});
test('only valid session names are taken from the URL', () => {
@@ -67,3 +80,9 @@ test('flow control pauses after highWater pending writes and resumes below lowWa
assert.equal(flow.release(), null); // 1
assert.equal(flow.release(), Client.RESUME); // 0
});
test('untrusted text loses control characters before it is typed (#2)', () => {
assert.equal(pasteSafe('ok\nrm -rf /\r\x1b[31mred\x07'), 'ok rm -rf / [31mred ');
assert.equal(pasteSafe('plain text'), 'plain text');
assert.equal(pasteSafe(null), '');
});
+66 -56
View File
@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#030508">
<meta name="generator" content="devdesk">
@@ -16,31 +16,46 @@
<div class="lt-header-left">
<div class="lt-brand">
<span class="lt-brand-title">DevDesk</span>
<span class="lt-brand-subtitle">Claude Code workspace</span>
</div>
<div class="dd-session">
<span class="lt-chip lt-chip-info" id="session-chip">—</span>
<button type="button" class="dd-session-chip" id="session-chip" data-state="" aria-haspopup="menu" aria-expanded="false" title="Switch session">
<span class="dd-state-dot" aria-hidden="true"></span><span class="dd-session-label" id="session-name">—</span><span class="dd-caret" aria-hidden="true">▾</span>
</button>
<span class="dd-pane-title" id="pane-title"></span>
<div class="dd-menu dd-switcher" id="switcher" role="menu" hidden></div>
</div>
</div>
<div class="lt-header-right">
<span class="lt-ws-status" id="ws-status" data-state="connecting" role="status">
<button type="button" class="lt-chip dd-attention" id="attention-chip" title="Sessions waiting for you"></button>
<button type="button" class="lt-btn lt-btn-danger lt-btn-sm dd-reauth" id="reauth" hidden title="Your Authelia session expired. Claude keeps running; sign in again to keep using the panels.">Sign in again</button>
<span class="lt-ws-status dd-ws" id="ws-status" data-state="connecting" role="status">
<span class="lt-dot"></span><span id="ws-label">connecting</span>
</span>
<button type="button" class="lt-chip dd-sso" id="sso-badge" data-state="unknown" title="Signed in through Authelia">SSO</button>
<div class="lt-btn-group dd-tools">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="font-down" title="Smaller text" aria-label="Smaller text">A−</button>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="font-up" title="Larger text" aria-label="Larger text">A+</button>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="notify-toggle" title="Desktop notification when Claude needs you">Alerts off</button>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="fullscreen-toggle" title="Fullscreen (also captures browser shortcuts in Chromium)">Full</button>
<button type="button" class="lt-btn lt-btn-sm" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false">Panels</button>
<div class="dd-more">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-more-toggle" id="more-toggle" aria-haspopup="menu" aria-expanded="false" title="Settings" aria-label="Settings">⋯</button>
<div class="dd-menu dd-more-menu" id="more-menu" role="menu" hidden>
<div class="dd-menu-row">
<span>Text size</span>
<span class="dd-menu-inline">
<button type="button" class="dd-icon-btn" id="font-down" aria-label="Smaller text">A−</button>
<span id="font-size">14</span>
<button type="button" class="dd-icon-btn" id="font-up" aria-label="Larger text">A+</button>
</span>
</div>
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
<button type="button" class="dd-menu-item" id="fullscreen-toggle" role="menuitem" title="In Chromium, fullscreen also captures Ctrl+W/Ctrl+T for the terminal">Fullscreen</button>
<div class="dd-menu-foot" id="version">devdesk</div>
</div>
</div>
<button type="button" class="lt-btn lt-btn-sm dd-panels" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false">Panels<span class="dd-badge" id="panels-badge" hidden></span></button>
</div>
</header>
<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>
@@ -52,71 +67,54 @@
</div>
</section>
<div class="dd-backdrop" id="backdrop" hidden></div>
<aside id="sidebar" class="dd-sidebar" aria-label="Panels">
<section class="lt-frame dd-widget" id="widget-sessions" data-state="live">
<div class="lt-section-header">Sessions
<button type="button" class="dd-header-action" id="sessions-refresh" title="Refresh" aria-label="Refresh sessions">↻</button>
<span class="dd-header-actions">
<button type="button" class="dd-header-action" id="new-session-toggle" aria-expanded="false" title="New session">+ New</button>
<button type="button" class="dd-header-action" id="sessions-refresh" title="Refresh" aria-label="Refresh sessions">↻</button>
</span>
</div>
<div class="lt-section-body">
<form class="dd-new-session" id="new-session-form" autocomplete="off" hidden>
<input class="lt-input" id="new-session-name" maxlength="64" placeholder="Name, e.g. pbs-upgrade"
aria-label="New session name" spellcheck="false">
<select class="lt-select" id="project-select" aria-label="Start the new session in a project folder (optional)"></select>
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">Open</button>
</form>
<ul class="dd-session-list" id="sessions-list"></ul>
<div class="dd-new-session">
<select class="lt-select" id="project-select" aria-label="Project for a new session"></select>
<button type="button" class="lt-btn lt-btn-primary lt-btn-sm" id="project-open">Open</button>
</div>
<div class="dd-widget-foot">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="open-picker">Picker</button>
<span class="dd-muted" id="sessions-status"></span>
</div>
<span class="dd-muted dd-status" id="sessions-status" role="status"></span>
</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-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">
<div class="lt-kv-grid">
<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>
<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 dd-repo-grid">
<span class="lt-kv-key">Repo</span><span class="lt-kv-val" data-field="repo">—</span>
<span class="lt-kv-key">Branch</span><span class="lt-kv-val" data-field="branch">—</span>
<span class="lt-kv-key">Tree</span><span class="lt-kv-val" data-field="dirty">—</span>
<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">CI · HEAD</span><span class="lt-kv-val"><span class="lt-status" data-field="ci">—</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>
</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>
<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>
</div>
</section>
<section class="lt-frame dd-widget" id="widget-context" data-state="stub">
<div class="lt-section-header">Claude Context <span class="lt-badge lt-badge-sm dd-soon">v1.1</span></div>
<div class="lt-section-body">
<div class="lt-progress lt-progress--cyan" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="lt-progress-bar" style="width:0%"></div>
</div>
<div class="lt-kv-grid dd-context-grid">
<span class="lt-kv-key">Used</span><span class="lt-kv-val" data-field="used">—</span>
<span class="lt-kv-key">Model</span><span class="lt-kv-val" data-field="model">—</span>
</div>
<p class="dd-muted dd-stub-note">Fed by a Claude statusLine hook through devdesk-api.</p>
<p class="dd-muted dd-repo-note"></p>
<button type="button" class="lt-btn lt-btn-sm dd-ask dd-fix-ci" hidden>Ask Claude to fix CI</button>
</div>
</section>
</aside>
</main>
<footer class="lt-footer dd-footer">
<div class="lt-footer-hints">
<div class="lt-footer-hints dd-hints">
<span class="lt-footer-hint"><span class="lt-footer-key">⇧⏎</span>newline</span>
<span class="lt-footer-sep">│</span>
<span class="lt-footer-hint"><span class="lt-footer-key">Esc</span>interrupt</span>
@@ -128,8 +126,20 @@
<span class="lt-footer-hint"><span class="lt-footer-key">^b d</span>detach</span>
<span class="lt-footer-sep">│</span>
<span class="lt-footer-hint"><span class="lt-footer-key">⇧drag</span>select</span>
<span class="lt-footer-sep">│</span>
<span class="lt-footer-hint"><span class="lt-footer-key">drop / paste</span>attach files</span>
</div>
<div class="dd-keybar" id="keybar" role="toolbar" aria-label="Terminal keys" hidden>
<button type="button" data-keys="esc">Esc</button>
<button type="button" data-keys="tab">Tab</button>
<button type="button" data-keys="stab">⇧Tab</button>
<button type="button" data-keys="ctrlc">^C</button>
<button type="button" data-keys="up">↑</button>
<button type="button" data-keys="down">↓</button>
<button type="button" data-keys="enter">⏎</button>
<button type="button" data-keys="claude">Claude</button>
<button type="button" data-keys="shell">Shell</button>
</div>
<span class="dd-muted" id="version">devdesk</span>
</footer>
<!--SCRIPT-->
+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'))));
}
+135 -12
View File
@@ -1,6 +1,6 @@
import {
Server, LIST_ARG, decodeServer, encodeBinaryInput, encodeCommand, encodeInit,
encodeInput, encodeResize, parseListOutput, tokenUrl, wsUrl, FlowControl,
encodeInput, encodeResize, parseListOutput, stripAnsi, tokenUrl, wsUrl, FlowControl,
} from './protocol.js';
export class AuthExpiredError extends Error {}
@@ -12,11 +12,17 @@ export async function fetchToken() {
const res = await fetch(tokenUrl(window.location), {
cache: 'no-store', credentials: 'same-origin', redirect: 'manual',
});
if (res.type === 'opaqueredirect') throw new AuthExpiredError('SSO session expired');
if (res.type === 'opaqueredirect') {
// Any request can discover an expired Authelia session; the header shows
// "Sign in again" when this fires (devdesk#13).
window.dispatchEvent(new CustomEvent('devdesk:auth', { detail: 'expired' }));
throw new AuthExpiredError('SSO session expired');
}
if (res.status === 401 || res.status === 403 || res.status === 407) {
throw new UnauthorizedError(`HTTP ${res.status}`);
}
if (!res.ok) throw new Error(`token request failed: HTTP ${res.status}`);
window.dispatchEvent(new CustomEvent('devdesk:auth', { detail: 'ok' }));
const body = await res.json();
return body.token || '';
}
@@ -94,16 +100,36 @@ export class TtydConnection {
}
}
// Ask the server for sessions + projects: a short-lived `devdesk attach --list`.
export async function fetchSessionList(timeoutMs = 8000) {
// Side-channel commands each occupy a ttyd client slot and a server process:
// run at most two at a time (devdesk#8).
let active = 0;
const waiting = [];
async function limited(task) {
if (active >= 2) await new Promise((resolve) => waiting.push(resolve));
active += 1;
try {
return await task();
} finally {
active -= 1;
waiting.shift()?.();
}
}
// Run a short-lived `devdesk attach <args…>` and return its output as text.
// ttyd is the only server-side endpoint, so session actions travel this way.
export function runCommand(args, timeoutMs = 8000) {
return limited(() => runCommandNow(args, timeoutMs));
}
async function runCommandNow(args, timeoutMs) {
const token = await fetchToken();
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl(window.location, LIST_ARG), ['tty']);
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
socket.binaryType = 'arraybuffer';
const chunks = [];
const timer = setTimeout(() => {
socket.close();
reject(new Error('session list timed out'));
reject(new Error('devdesk did not answer'));
}, timeoutMs);
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => {
@@ -113,16 +139,113 @@ export async function fetchSessionList(timeoutMs = 8000) {
socket.onerror = () => {};
socket.onclose = () => {
clearTimeout(timer);
try {
const text = new TextDecoder().decode(concat(chunks));
resolve(parseListOutput(text));
} catch (err) {
reject(err);
}
resolve(new TextDecoder().decode(concat(chunks)));
};
});
}
// Sessions, projects and per-session Claude context (`devdesk attach --list`).
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));
export const UPLOAD_MAX = 25 * 1024 * 1024;
// Upload a file into the session's upload folder through ttyd: wait for the
// server's ready line, stream the file as base64 input in slices (never the
// whole file in memory), read back {ok, path}. Fails on 20 s of inactivity
// rather than a fixed total, so slow links still work (devdesk#9).
export function uploadFile(session, file, onProgress) {
if (!file.size) return Promise.reject(new Error('file is empty'));
if (file.size > UPLOAD_MAX) return Promise.reject(new Error('larger than 25 MB'));
return limited(() => uploadNow(session, file, onProgress));
}
async function uploadNow(session, file, onProgress) {
const token = await fetchToken();
const name = (file.name || 'pasted-image.png').slice(-100);
const slice = 3 * 16 * 1024; // multiple of 3: base64 of slices concatenates cleanly
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(file.size)]), ['tty']);
socket.binaryType = 'arraybuffer';
const decoder = new TextDecoder();
let output = '';
let sending = false;
let idle = 0;
const touch = () => {
clearTimeout(idle);
idle = setTimeout(() => socket.close(), 20000);
};
const send = async () => {
for (let offset = 0; offset < file.size; offset += slice) {
while (socket.bufferedAmount > 1 << 20) await sleep(20);
if (socket.readyState !== WebSocket.OPEN) return;
const bytes = new Uint8Array(await file.slice(offset, offset + slice).arrayBuffer());
socket.send(encodeInput(toBase64(bytes)));
touch();
onProgress?.(Math.min(1, (offset + slice) / file.size));
}
};
touch();
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => {
touch();
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(idle);
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) {
const output = stripAnsi(await runCommand([`--${action}`, ...args])).trim();
if (/"ok"\s*:\s*true/.test(output)) return;
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);
+449 -55
View File
@@ -1,9 +1,14 @@
/* global __DEVDESK_VERSION__ */
import { createTerminal, loadFonts } from './term.js';
import { TtydConnection, fetchToken, AuthExpiredError, UnauthorizedError } from './connection.js';
import { parseTitle, sessionFromSearch } from './protocol.js';
import { SessionsPanel, rememberSession } from './sessions.js';
import {
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
} from './connection.js';
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
import { SessionsPanel, el, recentNames, rememberSession, sessionStatus } from './sessions.js';
import { renderAlerts } from './alerts.js';
import { renderRepo } from './repo.js';
import { bindAskClaude } from './widgets.js';
import { heardWithin, onShared, share } from './shared.js';
import * as notify from './notify.js';
const $ = (id) => document.getElementById(id);
@@ -30,43 +35,51 @@ const ui = {
body: document.body,
wsStatus: $('ws-status'),
wsLabel: $('ws-label'),
sso: $('sso-badge'),
reauth: $('reauth'),
sessionChip: $('session-chip'),
sessionName: $('session-name'),
switcher: $('switcher'),
attention: $('attention-chip'),
paneTitle: $('pane-title'),
overlay: $('overlay'),
overlayTitle: $('overlay-title'),
overlayText: $('overlay-text'),
overlayActions: $('overlay-actions'),
moreToggle: $('more-toggle'),
moreMenu: $('more-menu'),
notifyButton: $('notify-toggle'),
sidebarButton: $('sidebar-toggle'),
panelsBadge: $('panels-badge'),
backdrop: $('backdrop'),
};
const urlSession = sessionFromSearch(window.location.search);
const state = {
session: sessionFromSearch(window.location.search), // null = picker
session: urlSession, // null = picker
conn: null,
attempts: 0,
retryTimer: null,
openedAt: 0,
endingCurrent: false,
lastActivity: Date.now(),
};
const touch = window.matchMedia('(pointer: coarse)').matches;
const isNarrow = () => !window.matchMedia('(min-width: 1100px)').matches;
let term;
let fit;
let sessionsPanel;
let lastAlerts = null;
let waitingOthers = [];
let paneTitleRaw = '';
// ---------------------------------------------------------------- status UI
function setWsState(stateName, label) {
ui.wsStatus.dataset.state = stateName;
ui.wsLabel.textContent = label;
}
function setSso(stateName) {
ui.sso.dataset.state = stateName;
ui.sso.textContent = stateName === 'expired' ? 'SSO expired' : 'SSO';
ui.sso.title = stateName === 'expired'
? 'Authelia session expired. Click to sign in again.'
: 'Signed in through Authelia';
ui.wsStatus.title = label;
}
function showOverlay({ title, text, actions = [], tone = 'info' }) {
@@ -88,6 +101,190 @@ function hideOverlay() {
ui.overlay.hidden = true;
}
// Pane title (Claude's own title) only when it says more than the session name.
function renderPaneTitle() {
const bare = paneTitleRaw.replace(/^[^\p{L}\p{N}]+/u, '').trim();
ui.paneTitle.textContent = bare && bare !== state.session ? paneTitleRaw : '';
}
function renderSessionChip() {
const data = sessionsPanel?.data;
const current = data?.sessions?.find((s) => s.name === state.session);
const status = current ? sessionStatus(current) : null;
ui.sessionName.textContent = state.session || 'picker';
ui.sessionChip.dataset.state = status?.key ?? '';
ui.sessionChip.title = [
state.session ? `Session ${state.session}${status ? ` · ${status.label}` : ''}` : 'Session picker',
current?.status?.message,
current?.context?.used_pct != null ? `context ${Math.floor(current.context.used_pct)}%` : '',
'Click to switch session',
].filter(Boolean).join('\n');
}
// Other sessions waiting on you: header chip, a notification when one starts
// waiting (devdesk#15), and the Panels badge. Only the tab that fetched the list
// notifies, so several open tabs don't ring once each.
let prevWaiting = new Set();
function renderAttention(fetched) {
const data = sessionsPanel?.data;
waitingOthers = (data?.sessions ?? []).filter((s) => s.name !== state.session && sessionStatus(s).key === 'waiting');
for (const s of waitingOthers) {
if (fetched && !prevWaiting.has(s.name)) notify.ring(s.name, s.status?.message || 'Claude needs your input');
}
prevWaiting = new Set(waitingOthers.map((s) => s.name));
ui.attention.classList.toggle('is-active', waitingOthers.length > 0);
ui.attention.textContent = waitingOthers.length === 1 ? `${waitingOthers[0].name} needs you` : `${waitingOthers.length} need you`;
ui.attention.title = waitingOthers.map((s) => `${s.name}: ${s.status?.message || 'waiting'}`).join('\n');
ui.attention.dataset.target = waitingOthers[0]?.name || '';
renderPanelsBadge();
}
function renderPanelsBadge() {
const alerts = lastAlerts?.alerts ?? [];
const count = waitingOthers.length + alerts.length;
const urgent = waitingOthers.length > 0 || alerts.some((a) => a.severity === 'critical');
ui.panelsBadge.hidden = count === 0 || ui.body.classList.contains('dd-sidebar-open');
ui.panelsBadge.textContent = String(count);
ui.panelsBadge.classList.toggle('is-urgent', urgent);
ui.panelsBadge.title = [
waitingOthers.length ? `${waitingOthers.length} session(s) need you` : '',
alerts.length ? `${alerts.length} Grafana alert(s)` : '',
].filter(Boolean).join(' · ');
}
function onSessionData({ fetched = false } = {}) {
renderSessionChip();
renderAttention(fetched);
if (!ui.switcher.hidden) renderSwitcher();
}
function showAlerts(data) {
lastAlerts = data;
renderAlerts($('widget-alerts'), data);
renderPanelsBadge();
}
let alertsBusy = false;
async function refreshAlerts() {
if (alertsBusy) return;
alertsBusy = true;
try {
showAlerts(await fetchAlerts());
share('alerts', lastAlerts);
} 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;
function refreshSoon(delay = 1500) {
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => sessionsPanel?.refresh(), delay);
}
function updateUrl(session) {
const url = new URL(window.location.href);
if (session) url.searchParams.set('arg', session);
@@ -97,27 +294,32 @@ function updateUrl(session) {
// ---------------------------------------------------------------- connection
function connect(session) {
// reconnect: automatic re-attach after a drop. It must not re-create a session
// that was ended or renamed meanwhile, so it asks for --existing (devdesk#4).
function connect(session, { reconnect = false } = {}) {
clearTimeout(state.retryTimer);
state.conn?.dispose();
state.session = session;
updateUrl(session);
sessionsPanel?.setCurrent(session);
ui.sessionChip.textContent = session || 'picker';
paneTitleRaw = '';
renderPaneTitle();
onSessionData();
hideOverlay();
setWsState('connecting', 'connecting');
state.openedAt = 0;
term.reset();
const conn = new TtydConnection({
arg: session,
arg: session && reconnect ? ['--existing', session] : session,
size: () => ({ cols: term.cols, rows: term.rows }),
onOpen: () => {
state.openedAt = Date.now();
state.attempts = 0;
setWsState('connected', 'live');
setSso('ok');
if (session) rememberSession(session);
refreshSoon(800); // opening ?arg=<new name> just created this session
refreshRepo();
fit.fit();
term.focus();
},
@@ -132,6 +334,12 @@ const pickerAction = { label: 'Sessions…', run: () => connect(null) };
function onClosed(code) {
setWsState('disconnected', 'offline');
if (state.endingCurrent) {
// We just ended the session we were attached to: go back to the picker.
state.endingCurrent = false;
connect(null);
return;
}
// ttyd reports a non-zero exit of `devdesk attach` with an invalid close code,
// which browsers surface as 1006 — same as a dropped network. A close within a
// few seconds of opening means the session could not start (the reason is
@@ -164,12 +372,11 @@ function onClosed(code) {
function onFailure(error) {
if (error instanceof AuthExpiredError) {
setSso('expired');
setWsState('disconnected', 'signed out');
showOverlay({
title: 'Sign-in expired',
text: 'Your Authelia session has expired. Claude keeps running on the server; sign in again to reattach.',
actions: [{ label: 'Re-authenticate', primary: true, run: () => window.location.reload() }],
actions: [{ label: 'Sign in again', primary: true, run: () => window.location.reload() }],
tone: 'warn',
});
return;
@@ -196,13 +403,13 @@ function scheduleReconnect() {
showOverlay({
title: 'Reconnecting',
text: `Connection lost; Claude keeps running on the server. Retrying in ${seconds}s (attempt ${state.attempts}).`,
actions: [{ label: 'Retry now', primary: true, run: () => connect(state.session) }, pickerAction],
actions: [{ label: 'Retry now', primary: true, run: () => connect(state.session, { reconnect: true }) }, pickerAction],
});
state.retryTimer = setTimeout(() => connect(state.session), delay);
state.retryTimer = setTimeout(() => connect(state.session, { reconnect: true }), delay);
}
function retrySoon() {
if (state.retryTimer && !state.conn?.isOpen) connect(state.session);
if (state.retryTimer && !state.conn?.isOpen) connect(state.session, { reconnect: true });
}
// ---------------------------------------------------------------- titles & bell
@@ -214,12 +421,14 @@ function applyTitle() {
titleFrame = 0;
const parsed = parseTitle(pendingTitle);
if (!parsed) {
ui.paneTitle.textContent = pendingTitle;
paneTitleRaw = pendingTitle;
renderPaneTitle();
return;
}
if (parsed.session === 'picker') {
ui.sessionChip.textContent = 'picker';
ui.paneTitle.textContent = 'choose a session';
ui.sessionName.textContent = 'picker';
paneTitleRaw = 'choose a session';
renderPaneTitle();
notify.setBaseTitle('DevDesk · picker');
return;
}
@@ -229,44 +438,152 @@ function applyTitle() {
updateUrl(parsed.session);
rememberSession(parsed.session);
sessionsPanel.setCurrent(parsed.session);
onSessionData();
refreshRepo();
}
ui.sessionChip.textContent = parsed.session;
ui.paneTitle.textContent = parsed.pane;
paneTitleRaw = parsed.pane;
renderPaneTitle();
notify.setBaseTitle(`${parsed.session} · DevDesk`);
}
// ---------------------------------------------------------------- menus
const menus = [];
function wireMenu(button, menu, onOpen) {
menus.push({ button, menu });
button.addEventListener('click', (event) => {
event.stopPropagation();
const open = menu.hidden;
closeMenus();
if (open) {
onOpen?.();
menu.hidden = false;
button.setAttribute('aria-expanded', 'true');
}
});
menu.addEventListener('click', (event) => event.stopPropagation());
}
function closeMenus() {
for (const { button, menu } of menus) {
menu.hidden = true;
button.setAttribute('aria-expanded', 'false');
}
}
// Session switcher on the session chip (devdesk#16).
function renderSwitcher() {
const sessions = [...(sessionsPanel?.data?.sessions ?? [])].sort((a, b) =>
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name));
const choose = (run) => () => {
closeMenus();
run();
};
const items = sessions.map((s) => {
const status = sessionStatus(s);
const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : '';
return el('button', {
type: 'button', role: 'menuitem',
class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`,
title: s.dir || '', onclick: choose(() => connect(s.name)),
},
el('span', { class: `lt-dot ${status.dot}` }),
el('span', { class: 'dd-switch-name' }, s.name),
el('span', { class: 'dd-switch-meta' }, `${status.label}${ctx}`));
});
if (!items.length) items.push(el('div', { class: 'dd-menu-note' }, sessionsPanel?.data ? 'No sessions yet.' : 'Loading…'));
ui.switcher.replaceChildren(
...items,
el('div', { class: 'dd-menu-sep' }),
el('button', {
type: 'button', role: 'menuitem', class: 'dd-menu-item',
onclick: choose(() => {
setSidebar(true);
sessionsPanel.toggleForm(true);
}),
}, '+ New session…'),
el('button', { type: 'button', role: 'menuitem', class: 'dd-menu-item', onclick: choose(() => connect(null)) }, 'Open picker'),
);
}
// ---------------------------------------------------------------- chrome controls
function setFontSize(size) {
const clamped = Math.min(24, Math.max(10, size));
term.options.fontSize = clamped;
store.set('fontSize', clamped);
$('font-size').textContent = String(clamped);
fit.fit();
}
function renderNotifyButton() {
const labels = { granted: 'Alerts on', denied: 'Alerts blocked', default: 'Alerts off', unsupported: 'No alerts' };
const current = notify.notificationsState();
const labels = {
unsupported: 'Desktop notifications: unsupported',
denied: 'Desktop notifications: blocked by the browser',
default: 'Enable desktop notifications',
granted: notify.isMuted() ? 'Desktop notifications: muted (unmute)' : 'Desktop notifications: on (mute)',
};
ui.notifyButton.textContent = labels[current];
ui.notifyButton.dataset.state = current;
ui.notifyButton.disabled = current === 'unsupported' || current === 'denied';
}
function setSidebar(open) {
ui.body.classList.toggle('dd-sidebar-open', open);
ui.sidebarButton.setAttribute('aria-expanded', String(open));
store.set('sidebar', open ? 'open' : 'closed');
if (open) sessionsPanel?.refresh();
ui.backdrop.hidden = !(open && isNarrow());
if (!isNarrow()) store.set('sidebar', open ? 'open' : 'closed');
if (open) {
sessionsPanel?.refresh();
refreshAlerts();
refreshRepo();
}
renderPanelsBadge();
}
function closeDrawerIfNarrow() {
if (isNarrow() && ui.body.classList.contains('dd-sidebar-open')) setSidebar(false);
}
// Phone key bar: keys an on-screen keyboard can't send (devdesk#14).
const KEYS = {
esc: '\x1b', tab: '\t', stab: '\x1b[Z', ctrlc: '\x03', up: '\x1b[A', down: '\x1b[B', enter: '\r',
claude: '\x020', shell: '\x021', // tmux prefix (C-b) + window number
};
function wireKeybar() {
if (!touch) return;
ui.body.classList.add('dd-touch');
const bar = $('keybar');
bar.hidden = false;
// Don't steal focus from the terminal (that would close the on-screen keyboard).
bar.addEventListener('pointerdown', (event) => event.preventDefault());
bar.addEventListener('click', (event) => {
const key = event.target.closest('[data-keys]')?.dataset.keys;
if (key && KEYS[key]) state.conn?.sendInput(KEYS[key]);
});
// Keep the terminal above the on-screen keyboard.
const vv = window.visualViewport;
if (vv) {
const update = () => {
const hidden = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
document.documentElement.style.setProperty('--dd-kb', `${hidden}px`);
};
vv.addEventListener('resize', update);
vv.addEventListener('scroll', update);
}
}
function wireChrome() {
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
ui.notifyButton.addEventListener('click', async () => {
await notify.requestNotifications();
if (notify.notificationsState() === 'default') await notify.requestNotifications();
else if (notify.notificationsState() === 'granted') notify.setMuted(!notify.isMuted());
renderNotifyButton();
});
renderNotifyButton();
$('fullscreen-toggle').addEventListener('click', async () => {
closeMenus();
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
@@ -275,13 +592,37 @@ function wireChrome() {
// Chromium: capture Ctrl+W/Ctrl+T/Esc etc. for the terminal while fullscreen.
navigator.keyboard?.lock?.().catch(() => {});
});
ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open')));
const wide = window.matchMedia('(min-width: 1100px)').matches;
setSidebar(store.get('sidebar', wide ? 'open' : 'closed') === 'open');
ui.sso.addEventListener('click', () => {
if (ui.sso.dataset.state === 'expired') window.location.reload();
wireMenu(ui.moreToggle, ui.moreMenu, renderNotifyButton);
wireMenu(ui.sessionChip, ui.switcher, () => {
renderSwitcher();
sessionsPanel.refresh();
});
document.addEventListener('click', closeMenus);
ui.sidebarButton.addEventListener('click', () => setSidebar(!ui.body.classList.contains('dd-sidebar-open')));
ui.backdrop.addEventListener('click', () => setSidebar(false));
setSidebar(!isNarrow() && store.get('sidebar', 'open') === 'open');
window.matchMedia('(min-width: 1100px)').addEventListener('change', () => {
ui.backdrop.hidden = !(ui.body.classList.contains('dd-sidebar-open') && isNarrow());
});
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
const menuOpen = menus.some(({ menu }) => !menu.hidden);
const drawerOpen = isNarrow() && ui.body.classList.contains('dd-sidebar-open');
if (!menuOpen && !drawerOpen) return;
event.preventDefault();
event.stopPropagation();
closeMenus();
if (drawerOpen) setSidebar(false);
}, true);
// "Sign in again" appears only when a request finds the SSO session expired (devdesk#13).
window.addEventListener('devdesk:auth', (event) => {
ui.reauth.hidden = event.detail !== 'expired';
});
ui.reauth.addEventListener('click', () => window.location.reload());
// Ctrl+W closes the tab before the terminal sees it; ask first.
window.addEventListener('beforeunload', (event) => {
@@ -295,23 +636,45 @@ function wireChrome() {
document.addEventListener(type, () => { state.lastActivity = Date.now(); }, { capture: true, passive: true });
}
// While you're active, touch the Authelia gate so its inactivity timer doesn't lapse.
setInterval(async () => {
setInterval(() => {
if (Date.now() - state.lastActivity > 10 * 60 * 1000) return;
try {
await fetchToken();
setSso('ok');
} catch (error) {
if (error instanceof AuthExpiredError) setSso('expired');
}
fetchToken().catch(() => {});
}, 3 * 60 * 1000);
window.addEventListener('online', retrySoon);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) retrySoon();
});
// 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
// minute) — unless another tab just shared a fresh list (devdesk#8, #15).
onShared('sessions', (data) => sessionsPanel.accept(data));
setInterval(() => {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) sessionsPanel.refresh();
}, 30 * 1000);
if (!heardWithin('sessions', 16 * 1000)) sessionsPanel.refresh();
}, 15 * 1000);
// Alerts feed the Panels badge too.
onShared('alerts', showAlerts);
setInterval(() => {
if (!heardWithin('alerts', 62 * 1000)) refreshAlerts();
}, 60 * 1000);
// Repo status: on connect, after Claude's turns, and once a minute (devdesk#5, #8).
setInterval(() => {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo();
}, 60 * 1000);
ui.attention.addEventListener('click', () => {
if (ui.attention.dataset.target) connect(ui.attention.dataset.target);
});
}
// A bare URL (no ?arg=) reattaches the session you used last, or the most
// recently active one; the picker is only the fallback (devdesk#16).
function defaultSession(data) {
const sessions = data?.sessions ?? [];
const known = new Set(sessions.map((s) => s.name));
const recent = recentNames().find((name) => known.has(name));
if (recent) return recent;
const running = sessions.filter((s) => s.running).sort((a, b) => (b.activity || 0) - (a.activity || 0));
return running[0]?.name ?? null;
}
// ---------------------------------------------------------------- boot
@@ -319,6 +682,7 @@ function wireChrome() {
async function boot() {
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
$('font-size').textContent = String(fontSize);
await loadFonts(fontSize);
({ term, fit } = createTerminal($('terminal'), {
@@ -332,7 +696,11 @@ async function boot() {
pendingTitle = title;
if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle);
});
term.onBell(() => notify.ring(state.session, ui.paneTitle.textContent));
term.onBell(() => {
notify.ring(state.session, ui.paneTitle.textContent || 'Claude needs your attention');
refreshSoon(); // Claude finished a turn: states and context just changed
setTimeout(refreshRepo, 2000); // …and maybe committed or pushed
});
let fitFrame = 0;
new ResizeObserver(() => {
@@ -347,20 +715,46 @@ async function boot() {
sessionsPanel = new SessionsPanel({
list: $('sessions-list'),
form: $('new-session-form'),
nameInput: $('new-session-name'),
select: $('project-select'),
openButton: $('project-open'),
refreshButton: $('sessions-refresh'),
pickerButton: $('open-picker'),
newToggle: $('new-session-toggle'),
status: $('sessions-status'),
onOpen: (name) => connect(name),
onPicker: () => connect(null),
hooks: {
onOpen: (name) => {
closeDrawerIfNarrow();
connect(name);
},
onEnding: () => { state.endingCurrent = true; },
onEnded: (name) => {
if (name !== state.session) state.endingCurrent = false;
},
onEndAborted: () => { state.endingCurrent = false; },
onRenamed: (from, to) => {
if (from !== state.session) return;
state.session = to;
updateUrl(to);
rememberSession(to);
sessionsPanel.setCurrent(to);
renderSessionChip();
},
onData: (_data, meta) => onSessionData(meta),
},
});
bindAskClaude($('sidebar'), (text) => {
state.conn?.sendInput(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));
closeDrawerIfNarrow();
term.focus();
});
wireChrome();
connect(state.session);
wireKeybar();
wireUploads();
refreshAlerts();
await sessionsPanel.refresh();
connect(urlSession ?? defaultSession(sessionsPanel.data));
}
boot();
+19 -1
View File
@@ -32,11 +32,29 @@ export async function requestNotifications() {
return Notification.requestPermission();
}
const MUTE_KEY = 'devdesk.notify.muted';
export function isMuted() {
try {
return localStorage.getItem(MUTE_KEY) === '1';
} catch {
return false;
}
}
export function setMuted(muted) {
try {
localStorage.setItem(MUTE_KEY, muted ? '1' : '0');
} catch {
// Private mode: the mute just won't persist.
}
}
export function ring(session, detail) {
if (attentive()) return;
unread += 1;
render();
if (notificationsState() === 'granted') {
if (notificationsState() === 'granted' && !isMuted()) {
const note = new Notification(`Claude · ${session || 'devdesk'}`, {
body: detail || 'Claude needs your attention',
tag: `devdesk-${session}`,
+24 -4
View File
@@ -53,12 +53,33 @@ export function tokenUrl(location) {
return `${location.protocol}//${location.host}${basePath(location)}token`;
}
export function wsUrl(location, arg) {
// ttyd --url-arg appends each ?arg= to the command line: `devdesk attach <args…>`.
export function wsUrl(location, args) {
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
const query = arg ? `?arg=${encodeURIComponent(arg)}` : '';
const list = Array.isArray(args) ? args : args ? [args] : [];
const query = list.length ? `?${list.map((a) => `arg=${encodeURIComponent(a)}`).join('&')}` : '';
return `${scheme}//${location.host}${basePath(location)}ws${query}`;
}
// Session names are [A-Za-z0-9_-]; turn free text ("PBS upgrade!") into one ("PBS-upgrade").
export function slugify(text) {
return String(text || '').trim().replace(/[^A-Za-z0-9_-]+/g, '-')
.replace(/^[-_]+/, '').replace(/-+$/, '').slice(0, 64);
}
// Untrusted text (e.g. Grafana alert annotations) typed into a terminal: drop
// every control character so it can't run a command (newline in a shell),
// submit early (CR) or inject escape sequences (devdesk#2).
export function pasteSafe(text) {
// eslint-disable-next-line no-control-regex
return String(text ?? '').replace(/[\x00-\x1f\x7f]+/g, ' ');
}
export function stripAnsi(text) {
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '');
}
export function sessionFromSearch(search) {
const value = new URLSearchParams(search).get('arg');
return value && SESSION_RE.test(value) ? value : null;
@@ -74,8 +95,7 @@ export function parseTitle(title) {
// Output of `devdesk attach --list` arrives through a pty: CRLF line endings and
// possibly split across frames. Pull the JSON object out of the joined text.
export function parseListOutput(raw) {
// eslint-disable-next-line no-control-regex
const text = raw.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ''); // tools may colorize on a tty
const text = stripAnsi(raw); // tools may colorize on a tty
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start < 0 || end < start) throw new Error('no session list in output');
+61
View File
@@ -0,0 +1,61 @@
// 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 grid = root.querySelector('.dd-repo-grid');
const note = root.querySelector('.dd-repo-note');
const fix = root.querySelector('.dd-fix-ci');
fix.hidden = true;
// No repository: one line instead of a grid of dashes (devdesk#17).
if (!repo || !repo.git) {
root.dataset.state = 'empty';
grid.hidden = true;
note.textContent = repo ? `Not a git repository: ${repo.dir}` : 'Open a session to see its repository.';
return;
}
root.dataset.state = 'live';
grid.hidden = false;
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', ''];
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.';
if (repo.ci?.total && ['failure', 'error'].includes(repo.ci.state)) {
fix.hidden = false;
fix.dataset.askClaude = `CI is failing for ${repo.repo} on ${repo.branch} at ${repo.sha}`
+ ` (${repo.ci.url || 'see Gitea Actions'}). Use the gitea MCP server to read the failed job's log,`
+ ' find the cause, and fix it. Tell me the plan before pushing anything.';
}
}
+191 -48
View File
@@ -1,7 +1,9 @@
// Sessions panel: server state (via `devdesk attach --list`) plus recently used
// sessions remembered in this browser.
// Sessions panel: server state (via `devdesk attach --list`), recently used
// sessions remembered in this browser, and session actions (new/rename/end).
import { fetchSessionList } from './connection.js';
import { fetchSessionList, sessionAction } from './connection.js';
import { slugify } from './protocol.js';
import { share } from './shared.js';
const RECENTS_KEY = 'devdesk.recents';
const MAX_RECENTS = 12;
@@ -15,18 +17,32 @@ function readRecents() {
}
}
export function rememberSession(name) {
if (!name) return;
function writeRecents(recents) {
try {
const recents = readRecents().filter((r) => r.name !== name);
recents.unshift({ name, at: Date.now() });
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS)));
} catch {
// Storage unavailable (private mode): recents are a convenience only.
}
}
function ago(epochSeconds) {
// Session names this browser used most recently, newest first.
export function recentNames() {
return readRecents().map((r) => r.name);
}
export function rememberSession(name) {
if (name) writeRecents([{ name, at: Date.now() }, ...readRecents().filter((r) => r.name !== name)]);
}
function forgetRecent(name) {
writeRecents(readRecents().filter((r) => r.name !== name));
}
function renameRecent(from, to) {
writeRecents(readRecents().map((r) => (r.name === from ? { ...r, name: to } : r)));
}
export function ago(epochSeconds) {
if (!epochSeconds) return '';
const s = Math.max(0, Date.now() / 1000 - epochSeconds);
if (s < 90) return 'now';
@@ -35,84 +51,211 @@ function ago(epochSeconds) {
return `${Math.floor(s / 86400)}d`;
}
function el(tag, attrs = {}, ...children) {
export function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === null || value === false) continue;
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value);
else node.setAttribute(key, value === true ? '' : value);
}
node.append(...children.filter((c) => c !== null && c !== undefined));
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 {
constructor({ list, select, openButton, refreshButton, pickerButton, status, onOpen, onPicker }) {
Object.assign(this, { list, select, status, onOpen });
// hooks: onOpen(name), onEnding(name), onEnded(name), onEndAborted(), onRenamed(from, to), onData(data)
constructor({ list, form, nameInput, select, refreshButton, newToggle, status, hooks }) {
Object.assign(this, { list, form, nameInput, select, newToggle, status, hooks });
this.current = null;
this.data = null;
this.pending = null;
refreshButton.addEventListener('click', () => this.refresh());
pickerButton.addEventListener('click', () => onPicker());
openButton.addEventListener('click', () => {
if (select.value) onOpen(select.value);
newToggle.addEventListener('click', () => this.toggleForm());
form.addEventListener('submit', (event) => {
event.preventDefault();
this.createFromForm();
});
}
toggleForm(open = this.form.hidden) {
this.form.hidden = !open;
this.newToggle.setAttribute('aria-expanded', String(open));
if (open) this.nameInput.focus();
}
setCurrent(name) {
this.current = name;
if (this.data) this.render();
}
async refresh() {
this.status.textContent = 'refreshing…';
setStatus(text, isError = false) {
this.status.textContent = text;
this.status.classList.toggle('is-error', isError);
}
// Concurrent callers share the request in flight (boot awaits the first list).
refresh() {
this.pending ??= this.load().finally(() => { this.pending = null; });
return this.pending;
}
async load() {
let fetched = false;
try {
this.data = await fetchSessionList();
this.status.textContent = '';
fetched = true;
share('sessions', this.data);
if (!this.status.classList.contains('is-error')) this.setStatus('');
} catch (err) {
this.status.textContent = `list unavailable: ${err.message}`;
this.setStatus(`list unavailable: ${err.message}`, true);
}
this.render();
this.hooks.onData?.(this.data, { fetched });
}
// A list another tab fetched (devdesk#8).
accept(data) {
this.data = data;
this.render();
this.hooks.onData?.(this.data, { fetched: false });
}
async act(label, action, ...args) {
this.setStatus(`${label}…`);
try {
await sessionAction(action, ...args);
this.setStatus('');
return true;
} catch (err) {
this.setStatus(err.message, true);
return false;
}
}
async createFromForm() {
const project = this.select.value;
const name = slugify(this.nameInput.value) || project;
if (!name) {
this.setStatus('Type a session name or pick a project.', true);
this.nameInput.focus();
return;
}
// With a project the session starts in that folder; without one, in the default folder.
if (project && !(await this.act('creating', 'new', name, project))) return;
this.nameInput.value = '';
this.select.value = '';
this.toggleForm(false);
this.hooks.onOpen(name);
}
async rename(name) {
const answer = window.prompt(`Rename session "${name}" to:`, name);
if (answer === null) return;
const next = slugify(answer);
if (!next || next === name) {
if (!next) this.setStatus('Names use letters, numbers, - and _.', true);
return;
}
if (await this.act('renaming', 'rename', name, next)) {
renameRecent(name, next);
this.hooks.onRenamed(name, next);
await this.refresh();
}
}
async end(session) {
const question = session.running
? `End session "${session.name}"?\n\nClaude in this session stops. The conversation stays in Claude's history (claude --resume).`
: `Forget dormant session "${session.name}"?`;
if (!window.confirm(question)) return;
if (session.name === this.current) this.hooks.onEnding?.(session.name);
if (await this.act(session.running ? 'ending' : 'forgetting', 'end', session.name)) {
forgetRecent(session.name);
this.hooks.onEnded(session.name);
await this.refresh();
} else if (session.name === this.current) {
this.hooks.onEndAborted?.();
}
}
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 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
? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, model, s.attached ? `${s.attached} open` : '']
.filter(Boolean).join(' · ')
: 'dormant · resumes Claude';
const pct = s.context?.used_pct;
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)}` : ''}`,
].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 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),
pct != null ? el('span', { class: `dd-ctx${pct >= 90 ? ' is-full' : pct >= 70 ? ' is-high' : ''}`, 'aria-hidden': 'true' },
el('span', { class: 'dd-ctx-fill', style: `width:${Math.min(100, pct)}%` })) : null),
el('span', { class: 'dd-session-actions' },
iconButton('Rename', '✎', () => this.rename(s.name)),
el('a', {
class: 'dd-icon-btn', href: `?arg=${encodeURIComponent(s.name)}`, target: '_blank', rel: 'noopener',
title: 'Open in a new tab', 'aria-label': `Open ${s.name} in a new tab`,
}, '↗'),
iconButton(s.running ? 'End session' : 'Forget', '×', () => this.end(s), 'dd-danger')));
}
render() {
const sessions = this.data?.sessions ?? [];
const known = new Set(sessions.map((s) => s.name));
const recentsOnly = readRecents().filter((r) => !known.has(r.name)).slice(0, 5);
this.list.replaceChildren();
const ordered = [...sessions].sort((a, b) =>
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)));
for (const s of ordered) {
const isCurrent = s.name === this.current;
const meta = s.running
? `${s.attached} attached · ${ago(s.activity)}`
: 'dormant · resumes Claude';
this.list.append(el('li', { class: `dd-session-item${isCurrent ? ' is-current' : ''}` },
el('button', {
class: 'dd-session-open', type: 'button', title: s.dir || '',
onclick: () => this.onOpen(s.name),
},
el('span', { class: `lt-dot ${s.running ? 'lt-dot-up' : 'lt-dot-idle'}` }),
el('span', { class: 'dd-session-name' }, s.name),
el('span', { class: 'dd-session-meta' }, meta)),
el('a', {
class: 'dd-session-tab', href: `?arg=${encodeURIComponent(s.name)}`,
target: '_blank', rel: 'noopener', title: 'Open in a new tab', 'aria-label': `Open ${s.name} in a new tab`,
}, '↗')));
}
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name));
const items = ordered.map((s) => this.renderSession(s));
for (const r of recentsOnly) {
this.list.append(el('li', { class: 'dd-session-item is-recent' },
el('button', { class: 'dd-session-open', type: 'button', onclick: () => this.onOpen(r.name) },
items.push(el('li', { class: 'dd-session-item is-recent' },
el('button', { class: 'dd-session-open', type: 'button', onclick: () => this.hooks.onOpen(r.name) },
el('span', { class: 'lt-dot lt-dot-idle' }),
el('span', { class: 'dd-session-name' }, r.name),
el('span', { class: 'dd-session-meta' }, 'recent'))));
}
if (!this.list.children.length) {
this.list.append(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
el('span', { class: 'dd-session-meta' }, 'recent in this browser')),
el('span', { class: 'dd-session-actions' },
el('button', {
class: 'dd-icon-btn dd-danger', type: 'button', title: 'Remove from recents',
onclick: () => { forgetRecent(r.name); this.render(); },
}, '×'))));
}
if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
this.list.replaceChildren(...items);
const projects = (this.data?.projects ?? []).filter((p) => !known.has(p.name));
this.select.replaceChildren(el('option', { value: '' }, projects.length ? 'New session in…' : 'No other projects'));
for (const p of projects) this.select.append(el('option', { value: p.name, title: p.dir }, p.name));
const projects = this.data?.projects ?? [];
const chosen = this.select.value;
this.select.replaceChildren(
el('option', { value: '' }, `No project (${this.data?.root ?? 'default folder'})`),
...projects.filter((p) => p.name !== this.data?.default)
.map((p) => el('option', { value: p.name, title: p.dir }, p.name)));
this.select.value = chosen;
}
}
+31
View File
@@ -0,0 +1,31 @@
// One tab polls for every open DevDesk tab (devdesk#8): whoever fetches the
// session list or the alerts broadcasts the result, and a tab that heard a fresh
// result recently skips its own poll. Each poll costs a ttyd client and a PTY.
const channel = typeof BroadcastChannel === 'function' ? new BroadcastChannel('devdesk') : null;
const heard = new Map();
const handlers = new Map();
channel?.addEventListener('message', (event) => {
const { key, data } = event.data ?? {};
const handler = handlers.get(key);
if (!handler) return;
heard.set(key, Date.now());
handler(data);
});
export function onShared(key, handler) {
handlers.set(key, handler);
}
export function share(key, data) {
try {
channel?.postMessage({ key, data });
} catch {
// Not cloneable or channel closed: the other tabs just poll themselves.
}
}
export function heardWithin(key, ms) {
return Date.now() - (heard.get(key) ?? 0) < ms;
}
+14 -2
View File
@@ -3,7 +3,17 @@ import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { ClipboardAddon } from '@xterm/addon-clipboard';
import { Base64, ClipboardAddon } from '@xterm/addon-clipboard';
// Terminal output (tmux copy-mode, Claude) may *write* the browser clipboard
// via OSC 52, but must never read it: anything printed could exfiltrate it (devdesk#7).
const writeOnlyClipboard = {
readText: () => '',
writeText: (selection, text) => {
if (selection === 'c') return navigator.clipboard?.writeText(text).catch(() => {});
return undefined;
},
};
// LotusGuild TDS tokens (web_template base.css :root) mapped onto the ANSI palette.
export const THEME = Object.freeze({
@@ -62,7 +72,7 @@ export function createTerminal(container, { fontSize, onShiftEnter }) {
term.loadAddon(fit);
term.loadAddon(new Unicode11Addon());
term.unicode.activeVersion = '11';
term.loadAddon(new ClipboardAddon()); // OSC 52 from tmux copy-mode / Claude
term.loadAddon(new ClipboardAddon(new Base64(), writeOnlyClipboard));
term.loadAddon(new WebLinksAddon((event, uri) => {
event.preventDefault();
window.open(uri, '_blank', 'noopener');
@@ -90,6 +100,8 @@ export function createTerminal(container, { fontSize, onShiftEnter }) {
term.open(container);
// Touch devices: the DOM renderer is more reliable at high DPR than WebGL.
if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit };
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose());
+3 -4
View File
@@ -1,7 +1,6 @@
// Sidebar widgets. In v1 only Sessions is live; Repo/CI and Claude Context get
// data from devdesk-api in v1.1, Ops Alerts in v2. The "Ask Claude" pattern is
// already wired: any [data-ask-claude] button types its prompt into the current
// session without pressing Enter, so you can review it before sending.
// "Ask Claude": any [data-ask-claude] button (Ops Alerts, Repo/CI) types its
// prompt into the current session without pressing Enter, so you can review it
// before sending.
export function bindAskClaude(root, send) {
root.addEventListener('click', (event) => {
+286 -46
View File
@@ -37,9 +37,12 @@ body.dd-app::after {
}
.dd-header .lt-header-left {
flex: 1 1 auto;
min-width: 0;
gap: var(--space-lg);
}
.dd-header .lt-header-right { flex: 0 0 auto; }
.dd-session { flex: 0 1 auto; }
.dd-session {
display: flex;
@@ -57,26 +60,115 @@ body.dd-app::after {
max-width: 38vw;
}
.dd-sso {
cursor: default;
background: transparent;
.dd-session-chip {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 6px;
max-width: 34vw;
padding: 3px 8px;
border: 1px solid var(--accent-cyan-border);
background: var(--accent-cyan-dim);
color: var(--accent-cyan);
font-family: var(--font-mono);
}
.dd-sso[data-state="ok"] { color: var(--accent-green); background: var(--accent-green-dim); }
.dd-sso[data-state="unknown"] { color: var(--text-muted); }
.dd-sso[data-state="expired"] {
color: var(--accent-red);
background: var(--accent-red-dim);
text-shadow: var(--glow-red);
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
}
.dd-session-chip:hover,
.dd-session-chip[aria-expanded="true"] { border-color: var(--accent-cyan); box-shadow: var(--box-glow-cyan); }
.dd-session-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dd-caret { color: var(--text-muted); font-size: 0.7rem; }
/* Current session's Claude state as a dot on the chip (devdesk#13). */
.dd-state-dot { flex: 0 0 auto; width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted); }
.dd-session-chip[data-state="working"] .dd-state-dot { background: var(--accent-amber); box-shadow: 0 0 6px var(--accent-amber); animation: lt-pulse 1.2s ease-in-out infinite; }
.dd-session-chip[data-state="waiting"] { border-color: rgba(255, 45, 85, 0.6); color: var(--accent-red); background: var(--accent-red-dim); }
.dd-session-chip[data-state="waiting"] .dd-state-dot { background: var(--accent-red); box-shadow: 0 0 6px var(--accent-red); animation: lt-pulse 1.2s ease-in-out infinite; }
.dd-session-chip[data-state="idle"] .dd-state-dot { background: var(--accent-green); }
.dd-tools .lt-btn[data-state="granted"] { color: var(--accent-green); border-color: var(--accent-green-border); }
.dd-tools .lt-btn[data-state="denied"] { color: var(--accent-red); }
/* Connection status: a dot; the word only when it isn't "live". */
.dd-ws[data-state="connected"] #ws-label { display: none; }
.dd-panels { position: relative; clip-path: none; } /* the corner cut hid the active border */
@media (prefers-reduced-motion: reduce), (pointer: coarse) {
.dd-attention,
.dd-session-chip .dd-state-dot { animation: none !important; }
}
/* base.css draws keys in --text-dim (~1.5:1 on this background). */
.dd-sidebar .lt-kv-key { color: var(--text-secondary); font-size: 0.7rem; }
#sidebar-toggle[aria-expanded="true"] {
color: var(--accent-orange);
border-color: var(--accent-orange-border);
}
.dd-badge {
margin-left: 6px;
min-width: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--accent-amber);
color: var(--bg-primary);
font-size: 0.62rem;
font-weight: 700;
line-height: 16px;
text-align: center;
}
.dd-badge.is-urgent { background: var(--accent-red); box-shadow: 0 0 8px var(--accent-red); }
.dd-badge[hidden] { display: none; }
/* ---- menus (session switcher, ⋯) ----------------------------------------------- */
.dd-session,
.dd-more { position: relative; }
.dd-more-toggle { font-size: 1rem; line-height: 1; padding-inline: 8px; }
.dd-menu {
position: absolute;
top: calc(100% + 6px);
z-index: 40;
min-width: 240px;
max-width: min(360px, calc(100vw - 16px));
max-height: 70vh;
overflow-y: auto;
padding: 4px;
border: 1px solid var(--accent-cyan-border);
background: var(--bg-secondary);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7), var(--box-glow-cyan);
}
.dd-menu[hidden] { display: none; }
.dd-switcher { left: 0; }
.dd-more-menu { right: 0; }
.dd-menu-item {
display: flex;
align-items: center;
gap: var(--space-sm);
width: 100%;
padding: 6px 8px;
background: none;
border: 1px solid transparent;
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.74rem;
text-align: left;
cursor: pointer;
}
.dd-menu-item:hover:not(:disabled),
.dd-menu-item:focus-visible { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
.dd-menu-item:disabled { color: var(--text-muted); cursor: default; }
.dd-menu-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 4px 8px;
font-size: 0.74rem;
color: var(--text-secondary);
}
.dd-menu-inline { display: inline-flex; align-items: center; gap: 4px; color: var(--text-primary); }
.dd-menu-sep { height: 1px; margin: 4px 0; background: var(--border-dim); }
.dd-menu-note { padding: 6px 8px; color: var(--text-muted); font-size: 0.72rem; }
.dd-menu-foot { padding: 6px 8px 2px; border-top: 1px solid var(--border-dim); margin-top: 4px; color: var(--text-muted); font-size: 0.62rem; }
.dd-switch-item.is-current { border-color: var(--accent-orange-border); background: var(--accent-orange-dim); }
.dd-switch-name { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 700; }
.dd-switch-meta { color: var(--text-muted); font-size: 0.64rem; white-space: nowrap; }
.dd-switch-item.is-waiting .dd-switch-meta { color: var(--accent-red); }
.dd-switch-item.is-working .dd-switch-meta { color: var(--accent-amber); }
/* ---- layout ---------------------------------------------------------------- */
.dd-main {
@@ -134,14 +226,8 @@ body.dd-sidebar-open .dd-sidebar {
justify-content: flex-start;
}
.dd-soon {
margin-left: auto;
color: var(--text-muted);
letter-spacing: 0.12em;
}
.dd-header-actions { margin-left: auto; display: inline-flex; gap: var(--space-sm); }
.dd-header-action {
margin-left: auto;
background: none;
border: 0;
color: var(--accent-cyan);
@@ -153,7 +239,7 @@ body.dd-sidebar-open .dd-sidebar {
.dd-muted {
color: var(--text-muted);
font-size: 0.68rem;
font-size: 0.7rem;
}
.dd-stub-note {
@@ -193,7 +279,7 @@ body.dd-sidebar-open .dd-sidebar {
text-align: left;
}
.dd-session-open:hover { background: var(--accent-cyan-dim); }
.dd-session-open .lt-dot { grid-row: 1 / span 2; }
.dd-session-open .lt-dot { grid-row: 1 / span 3; }
.dd-session-name {
font-size: 0.78rem;
@@ -204,19 +290,88 @@ body.dd-sidebar-open .dd-sidebar {
}
.dd-session-meta {
font-size: 0.62rem;
color: var(--text-muted);
font-size: 0.68rem;
color: var(--text-secondary);
}
/* Context-window fill under each session (devdesk#17). */
.dd-ctx {
grid-column: 2;
height: 2px;
margin-top: 3px;
background: var(--bg-tertiary);
overflow: hidden;
}
.dd-ctx-fill { display: block; height: 100%; background: var(--accent-cyan); }
.dd-ctx.is-high .dd-ctx-fill { background: var(--accent-amber); }
.dd-ctx.is-full .dd-ctx-fill { background: var(--accent-red); }
.dd-session-item.is-recent .dd-session-name { color: var(--text-secondary); font-weight: 400; }
.dd-session-tab {
.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); }
/* base.css sets display on .lt-btn/.lt-chip, which beats the [hidden] attribute. */
.dd-header [hidden] { display: none !important; }
/* Header chip: other sessions waiting on you. */
.dd-attention {
text-transform: none;
letter-spacing: 0;
max-width: 22vw;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
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;
align-items: center;
padding: 0 8px;
color: var(--text-muted);
gap: 2px;
padding-right: 2px;
opacity: 0.55;
transition: var(--transition-fast);
}
.dd-session-item:hover .dd-session-actions,
.dd-session-item:focus-within .dd-session-actions,
.dd-session-item.is-current .dd-session-actions { opacity: 1; }
.dd-icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
padding: 0;
background: none;
border: 1px solid transparent;
color: var(--text-muted);
font-size: 0.8rem;
line-height: 1;
text-decoration: none;
}
.dd-icon-btn:hover,
.dd-icon-btn:focus-visible {
color: var(--accent-cyan);
border-color: var(--accent-cyan-border);
text-shadow: var(--glow-cyan);
text-decoration: none;
}
.dd-icon-btn.dd-danger:hover,
.dd-icon-btn.dd-danger:focus-visible {
color: var(--accent-red);
border-color: rgba(255, 45, 85, 0.4);
text-shadow: var(--glow-red);
}
.dd-session-tab:hover { color: var(--accent-orange); }
.dd-empty {
color: var(--text-muted);
@@ -224,16 +379,17 @@ body.dd-sidebar-open .dd-sidebar {
padding: 4px 6px;
}
.dd-new-session[hidden] { display: none; }
.dd-new-session {
display: flex;
display: grid;
grid-template-columns: 1fr auto;
gap: var(--space-sm);
margin-bottom: var(--space-sm);
}
.dd-new-session .lt-select {
flex: 1 1 auto;
min-width: 0;
font-size: 0.72rem;
}
.dd-new-session .lt-input { grid-column: 1 / -1; font-size: 0.74rem; }
.dd-new-session .lt-select { min-width: 0; font-size: 0.72rem; }
.dd-status.is-error { color: var(--accent-red); }
.dd-widget-foot {
display: flex;
@@ -241,19 +397,65 @@ 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-context-grid { margin-top: var(--space-sm); }
.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; }
[data-state="stub"] .lt-section-body { opacity: 0.75; }
.dd-repo-grid[hidden] { display: none; }
.dd-repo-note:empty { display: none; }
.dd-fix-ci { margin-top: var(--space-sm); }
.dd-fix-ci[hidden] { display: none; }
/* ---- 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 {
@@ -302,6 +504,33 @@ body.dd-sidebar-open .dd-sidebar {
.dd-footer .lt-footer-key { margin-right: 4px; }
.dd-keybar { display: flex; gap: 4px; width: 100%; overflow-x: auto; scrollbar-width: none; }
.dd-keybar[hidden] { display: none; }
.dd-keybar button {
flex: 1 1 auto;
min-width: 0;
height: 34px;
padding: 0 4px;
border: 1px solid var(--border-dim);
background: var(--bg-secondary);
color: var(--text-primary);
font-family: var(--font-mono);
font-size: 0.8rem;
touch-action: manipulation;
}
.dd-keybar button:active { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
/* Touch: the key bar replaces the shortcut hints; the page shrinks above the
on-screen keyboard (--dd-kb is set from visualViewport). */
body.dd-touch { --dd-footer-height: 44px; }
body.dd-touch .dd-hints { display: none; }
body.dd-touch .dd-main { bottom: calc(var(--dd-footer-height) + var(--dd-kb, 0px) + env(safe-area-inset-bottom, 0px)); }
body.dd-touch .dd-footer {
bottom: var(--dd-kb, 0px);
height: calc(var(--dd-footer-height) + env(safe-area-inset-bottom, 0px));
padding: 4px 4px calc(4px + env(safe-area-inset-bottom, 0px));
}
/* ---- small screens ----------------------------------------------------------- */
@media (max-width: 1099px) {
.dd-sidebar {
@@ -309,7 +538,7 @@ body.dd-sidebar-open .dd-sidebar {
top: var(--dd-gap);
right: var(--dd-gap);
bottom: var(--dd-gap);
width: min(var(--dd-sidebar-width), calc(100vw - 2 * var(--dd-gap)));
width: min(var(--dd-sidebar-width), calc(100vw - 56px)); /* leave a strip to tap out */
z-index: 20;
background-color: var(--bg-secondary);
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.6);
@@ -318,14 +547,25 @@ body.dd-sidebar-open .dd-sidebar {
.dd-pane-title { display: none; }
}
.dd-backdrop {
position: absolute;
inset: 0;
z-index: 15;
background: rgba(1, 3, 4, 0.55);
}
.dd-backdrop[hidden] { display: none; }
@media (max-width: 719px) {
:root { --dd-gap: 4px; }
.lt-brand-subtitle,
#font-down,
#font-up,
#fullscreen-toggle,
.dd-sso { display: none; }
.lt-brand { display: none; }
.dd-session-chip { max-width: 46vw; }
/* Phones: the red Panels badge and the switcher carry "needs you" instead. */
.dd-attention.is-active { display: none; }
.dd-header { padding: 0 var(--space-sm); gap: var(--space-sm); }
.dd-header .lt-header-left { gap: var(--space-sm); }
/* base.css hides the WS indicator on phones; keep it as a bare dot. */
.dd-header .dd-ws { display: inline-flex; padding: 4px; }
.dd-header .dd-ws #ws-label { display: none; }
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 5),
.dd-footer .lt-footer-sep:nth-child(n + 4) { display: none; }
}