feat: DevDesk v0.1.0, a persistent browser terminal for Claude Code
build-deb / Lint, test, package (push) Canceled after 1m13s
build-deb / Notify on failure (push) Canceled after 0s

ttyd serves a single-file xterm.js UI styled with the LotusGuild TDS
(web_template base.css). Sessions live in a dedicated tmux server unit
(devdesk-tmux.service), so restarting or upgrading the web unit never
stops Claude. Each named session maps to a project directory. A Claude
SessionStart hook records the conversation ID, so after a reboot the
session resumes that exact conversation.

- bin/devdesk: attach / new / ls / end / pick, with an fzf picker
- ttyd requires a credential that NPM injects; origin check enabled
- debian/: dh 13 with --no-stop-on-upgrade for the tmux unit, bundled
  sha256-verified ttyd 1.7.7, lintian overrides, man page
- CI: lint, test, build, smoke-test, artifact; v* tags publish to the
  Gitea apt registry and create a release

Refs: tinker ticket 887531976

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 23:11:07 -04:00
co-authored by Claude Opus 5.5
commit 07a41bd625
52 changed files with 10769 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"root": true,
"env": { "browser": true, "node": true, "es2022": true },
"parserOptions": { "ecmaVersion": 2022, "sourceType": "module" },
"extends": "eslint:recommended",
"rules": {
"no-unused-vars": "warn",
"no-undef": "error",
"no-empty": "warn",
"semi": ["error", "always"],
"eqeqeq": "error"
}
}
+158
View File
@@ -0,0 +1,158 @@
name: build-deb
on:
push:
branches: ["**"]
tags: ["v*"]
pull_request:
concurrency:
group: build-deb-${{ github.ref }}
cancel-in-progress: true
env:
GITEA_URL: https://code.lotusguild.org
OWNER: LotusGuild
REPO: LotusGuild/devdesk
jobs:
build:
name: Lint, test, package
runs-on: ubuntu-latest
# Bookworm (tmux 3.3, debhelper 13); the node image keeps actions/checkout working.
container:
image: node:22-bookworm
steps:
- uses: actions/checkout@v3
- name: Install build tools
run: |
apt-get update -qq
apt-get install -y -qq --no-install-recommends \
debhelper devscripts lintian fakeroot bats tmux fzf jq curl ca-certificates xz-utils >/dev/null
VER=v0.10.0
curl -fsSL "https://github.com/koalaman/shellcheck/releases/download/${VER}/shellcheck-${VER}.linux.x86_64.tar.xz" | tar -xJ
install "shellcheck-${VER}/shellcheck" /usr/local/bin/shellcheck
shellcheck --version | head -2
- name: npm ci
run: |
for i in 1 2 3; do
npm ci --no-audit --no-fund && break
echo "npm ci failed (attempt $i), retrying"; sleep 10
done
git diff --exit-code package-lock.json
- name: Lint
run: make lint
- name: Test
run: make test
- name: Set package version
id: version
env:
DEBEMAIL: ci@lotusguild.org
DEBFULLNAME: Lotus CI
run: |
base=$(dpkg-parsechangelog -S Version)
case "$GITHUB_REF" in
refs/tags/v*) version=${GITHUB_REF#refs/tags/v} ;;
*) version="${base}+ci${GITHUB_RUN_NUMBER}.g${GITHUB_SHA::7}" ;;
esac
if [ "$version" != "$base" ]; then
dch --newversion "$version" --distribution bookworm --force-distribution "CI build of ${GITHUB_SHA::7}"
fi
echo "version=$version" >> "$GITHUB_OUTPUT"
echo "Building devdesk $version"
- name: Build package
run: |
DEB_BUILD_OPTIONS=nocheck dpkg-buildpackage -us -uc -b
mkdir -p out && mv ../devdesk_*.deb out/
ls -l out/
- name: Lintian
run: lintian --fail-on error --info out/*.deb
- name: Smoke test
run: |
apt-get install -y -qq ./out/devdesk_*.deb >/dev/null
/usr/lib/devdesk/bin/ttyd --version
test -s /etc/devdesk/secret.env
. /etc/devdesk/secret.env
mkdir -p /run/devdesk
tmux -u -D -S /run/devdesk/tmux.sock -f /usr/share/devdesk/tmux.conf &
/usr/lib/devdesk/devdesk-tmux-ready
DEVDESK_CREDENTIAL="$DEVDESK_CREDENTIAL" /usr/lib/devdesk/devdesk-serve &
for _ in $(seq 1 30); do curl -s -o /dev/null localhost:7681/ && break; sleep 0.2; done
code=$(curl -s -o /dev/null -w '%{http_code}' localhost:7681/)
[ "$code" = 401 ] || { echo "expected 401 without credential, got $code"; exit 1; }
curl -sf -u "$DEVDESK_CREDENTIAL" localhost:7681/ | grep -q 'name="generator" content="devdesk"'
curl -sf -u "$DEVDESK_CREDENTIAL" localhost:7681/token | jq -e '.token | length > 0'
DEVDESK_NO_SYSTEMD=1 devdesk attach --list | jq -e '.default == "main"'
echo "smoke test passed"
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: devdesk_${{ steps.version.outputs.version }}_amd64
path: out/*.deb
- name: Publish to Gitea apt registry
if: startsWith(github.ref, 'refs/tags/v')
env:
TOKEN: ${{ secrets.PACKAGE_TOKEN || secrets.NPM_PUBLISH_TOKEN }}
VERSION: ${{ steps.version.outputs.version }}
run: |
deb=$(ls out/devdesk_*.deb)
pool="$GITEA_URL/api/packages/$OWNER/debian/pool/bookworm/main"
upload() { curl -s -o resp.txt -w '%{http_code}' -H "Authorization: token $TOKEN" --upload-file "$deb" "$pool/upload"; }
code=$(upload)
if [ "$code" = 409 ]; then
echo "devdesk $VERSION already published; replacing it"
curl -sf -X DELETE -H "Authorization: token $TOKEN" "$pool/devdesk/$VERSION/amd64"
code=$(upload)
fi
[ "$code" = 201 ] || { echo "registry upload failed: HTTP $code"; cat resp.txt; exit 1; }
echo "published devdesk $VERSION to $GITEA_URL/$OWNER/-/packages/debian/devdesk"
- name: Create Gitea release
if: startsWith(github.ref, 'refs/tags/v')
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
TAG: ${{ github.ref_name }}
run: |
api="$GITEA_URL/api/v1/repos/$REPO"
auth=(-H "Authorization: token $TOKEN")
id=$(curl -sf "${auth[@]}" "$api/releases/tags/$TAG" | jq -r '.id // empty' || true)
if [ -z "$id" ]; then
body=$(jq -n --arg tag "$TAG" '{tag_name: $tag, name: ("devdesk " + $tag), draft: false, prerelease: false,
body: "Install: `apt update && apt install devdesk` (Gitea apt registry), or download the .deb below."}')
id=$(curl -sf "${auth[@]}" -H 'Content-Type: application/json' -d "$body" "$api/releases" | jq -r '.id')
fi
deb=$(ls out/devdesk_*.deb)
name=$(basename "$deb")
existing=$(curl -sf "${auth[@]}" "$api/releases/$id/assets" | jq -r --arg n "$name" '.[] | select(.name == $n) | .id')
[ -n "$existing" ] && curl -sf -X DELETE "${auth[@]}" "$api/releases/$id/assets/$existing"
curl -sf "${auth[@]}" -H 'Content-Type: application/octet-stream' --data-binary "@$deb" \
"$api/releases/$id/assets?name=$name" >/dev/null
echo "release $TAG: $GITEA_URL/$REPO/releases/tag/$TAG"
notify-failure:
name: Notify on failure
runs-on: ubuntu-latest
needs: [build]
if: failure() && github.event_name == 'push'
steps:
- name: Send Matrix alert
env:
MATRIX_WEBHOOK_URL: ${{ secrets.MATRIX_WEBHOOK_URL }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.ref_name }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
if [ -z "$MATRIX_WEBHOOK_URL" ] || [ "$MATRIX_WEBHOOK_URL" = "CONFIGURE_ME" ]; then exit 0; fi
curl -sf -X POST "$MATRIX_WEBHOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"text\":\"CI FAILED: ${REPO} @ ${BRANCH} — ${RUN_URL}\"}"
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
build/
debian/devdesk/
debian/.debhelper/
debian/debhelper-build-stamp
debian/files
debian/*.substvars
debian/*.debhelper
debian/*.debhelper.log
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jared Vititoe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+54
View File
@@ -0,0 +1,54 @@
# DevDesk build. debhelper drives this: `make` (all), `make test`, `make install DESTDIR=…`.
PREFIX ?= /usr
SYSCONFDIR ?= /etc
DESTDIR ?=
LIBDIR := $(PREFIX)/lib/devdesk
SHAREDIR := $(PREFIX)/share/devdesk
TTYD_ARCH ?= x86_64
SHELL_SCRIPTS := bin/devdesk $(wildcard libexec/devdesk-*) scripts/fetch-ttyd.sh debian/devdesk.postinst debian/devdesk.postrm
WEB_SOURCES := web/build.mjs web/index.template.html web/styles.css $(wildcard web/src/*.js) \
vendor/web_template/base.css debian/changelog
.PHONY: all lint test install clean sync-template
all: dist/index.html build/ttyd
node_modules/.package-lock.json: package.json package-lock.json
npm ci --no-audit --no-fund
dist/index.html: node_modules/.package-lock.json $(WEB_SOURCES)
node web/build.mjs
build/ttyd: vendor/ttyd/VERSION vendor/ttyd/SHA256SUMS scripts/fetch-ttyd.sh
scripts/fetch-ttyd.sh $@ $(TTYD_ARCH)
@touch $@
lint: node_modules/.package-lock.json
npx eslint --max-warnings 0 --ext .js,.mjs web/src web/build.mjs tests/protocol.test.mjs
shellcheck -x $(SHELL_SCRIPTS)
jq empty share/claude-settings.json
test: node_modules/.package-lock.json
node --test tests/
bats tests/
install: all
install -d $(DESTDIR)$(PREFIX)/bin $(DESTDIR)$(LIBDIR)/bin $(DESTDIR)$(SHAREDIR) $(DESTDIR)$(SYSCONFDIR)/devdesk
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)/
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
clean:
rm -rf dist build
# Refresh the vendored design system: make sync-template TEMPLATE=../web_template
sync-template:
@test -n "$(TEMPLATE)" || { echo "usage: make sync-template TEMPLATE=/path/to/web_template"; exit 2; }
cp $(TEMPLATE)/base.css vendor/web_template/base.css
@echo "Updated base.css — record the commit in vendor/web_template/UPSTREAM: $$(git -C $(TEMPLATE) rev-parse --short HEAD)"
+69
View File
@@ -0,0 +1,69 @@
# DevDesk
A persistent browser terminal for Claude Code, at **https://dev.lotusguild.org**.
- **Persistent sessions:** closing the tab, restarting the web service or upgrading the package never stops Claude.
- **Survives reboots:** after a reboot, every named session resumes its own conversation.
- **Styling:** uses the LotusGuild Terminal Design System ([web_template](https://code.lotusguild.org/LotusGuild/web_template)).
- **Packaging:** ships as a `.deb` from Gitea's apt registry.
```
Browser ─https─▶ NPM (wildcard cert, Authelia two_factor, injects ttyd credential)
─http─▶ ttyd :7681 [devdesk.service] ── /ws?arg=<name> → `devdesk attach <name>` (tmux client)
tmux server [devdesk-tmux.service] ── one session per project:
window "claude": devdesk-claude-run → claude --resume <recorded id>
window "shell"
```
## Why two units
The tmux server runs in its own unit, so every Claude process lives in **that** unit's cgroup.
- ttyd (`devdesk.service`) only starts tmux *clients* (`tmux -N` never starts a server). It can restart freely, and does so on package upgrades.
- `devdesk-tmux.service` is installed with `--no-stop-on-upgrade`, so apt never touches it.
- **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.
- **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>`.
## Keys
| Key | Action |
|---|---|
| Shift+Enter | Newline in Claude's prompt (sends Ctrl+J) |
| Esc | Interrupt Claude |
| Ctrl-b 0 / 1 | Claude window / shell window |
| Ctrl-b s | Switch session (the URL follows) |
| Ctrl-b d | Detach (the session keeps running) |
| 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.
## Configuration
- `/etc/devdesk/env` (conffile):
- port, bind address and projects root
- `CLAUDE_BIN` and `CLAUDE_ARGS`
- `DEVDESK_REMOTE_CONTROL=1`, which also starts each session with Claude Remote Control so it can be continued from the Claude app
- `/etc/devdesk/secret.env` is generated on install. It holds the ttyd credential that NPM must inject; see [docs/DEPLOY.md](docs/DEPLOY.md).
- `/etc/devdesk/tmux.local.conf` is optional and is sourced after the packaged `tmux.conf`.
## Build
Needs Node ≥ 18, `debhelper`, `bats`, `tmux`, `jq`, `shellcheck`:
```bash
make # dist/index.html (single inlined page) + build/ttyd (pinned, sha256-verified)
make lint test
dpkg-buildpackage -us -uc -b && lintian ../devdesk_*_amd64.deb
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.
## 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, GANDALF) with "Ask Claude" prompts, and file drop.
Executable
+160
View File
@@ -0,0 +1,160 @@
#!/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
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"
}
# 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
}
# Tab-separated top-level project directories: slug, dir.
projects_tsv() {
local d real root
root=$(realpath -e -- "$DEVDESK_PROJECTS_ROOT" 2>/dev/null) || return 0
for d in "$DEVDESK_PROJECTS_ROOT"/*/; do
[ -d "$d" ] || continue
d=${d%/}
case ${d##*/} in node_modules | _* | lost+found) continue ;; esac
# Only offer directories that attach would accept (no symlinks out of the root).
real=$(realpath -e -- "$d" 2>/dev/null) || continue
case "$real/" in "$root"/*) ;; *) continue ;; esac
printf '%s\t%s\n' "$(dd_slugify "${d##*/}")" "$d"
done
}
cmd_list() {
local reg='{"sessions":{}}'
[ -s "$DEVDESK_REGISTRY" ] && reg=$(cat "$DEVDESK_REGISTRY")
jq -cMn \
--argjson reg "$reg" \
--arg run "$(running_tsv)" \
--arg proj "$(projects_tsv)" \
--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
| ($live | map(.name)) as $liveNames
| {default: $dflt, root: $root,
sessions: (
($live | map(. + {claude_session_id: ($known[.name].claude_session_id // null)}))
+ ($known | to_entries
| map(select(.key as $k | $liveNames | index($k) | not))
| map({name: .key, dir: .value.dir, running: false, attached: 0,
activity: null, windows: 0,
claude_session_id: (.value.claude_session_id // null)}))
),
projects: ([{name: $dflt, dir: $root}]
+ [rows($proj)[] | {name: .[0], dir: .[1]}
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
}
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)
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
}
create_detached() {
local name=$1 dir=$2 args
dd_tmux has-session -t "=$name" 2>/dev/null && return 0
mapfile -d '' args < <(dd_new_session_args "$name" "$dir")
# Another client may win the race; "duplicate session" is fine.
dd_tmux new-session -d "${args[@]}" 2>/dev/null || dd_tmux has-session -t "=$name" 2>/dev/null ||
dd_die "could not create session '$name'"
dd_registry_set_dir "$name" "$dir"
}
cmd_new() {
[ $# -eq 1 ] || dd_die "usage: devdesk new NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
dd_ensure_server
local dir
dir=$(session_dir "$1")
create_detached "$1" "$dir"
}
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
cmd_list
return
fi
local name=$1 dir args
dd_valid_name "$name" || dd_die "invalid session name: $name"
dd_ensure_server
dir=$(session_dir "$name")
# Already inside a DevDesk pane: switch this client instead of nesting.
local outer=${TMUX:-}
if [ -n "$outer" ] && [ "${outer%%,*}" = "$DEVDESK_SOCKET" ]; then
create_detached "$name" "$dir"
exec tmux -N -S "$DEVDESK_SOCKET" switch-client -t "=$name"
fi
unset TMUX
if ! dd_tmux has-session -t "=$name" 2>/dev/null; then
dd_registry_set_dir "$name" "$dir"
fi
mapfile -d '' args < <(dd_new_session_args "$name" "$dir")
# -A attaches if the session exists, so two tabs racing never create a duplicate.
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 "$@" ;;
ls | list) cmd_ls ;;
--list) cmd_list ;;
end) cmd_end "$@" ;;
pick) exec "$DEVDESK_LIBEXEC/devdesk-picker" ;;
help | -h | --help) usage ;;
*)
usage >&2
exit 2
;;
esac
+7
View File
@@ -0,0 +1,7 @@
devdesk (0.1.0) bookworm; urgency=medium
* Initial release: persistent browser terminal for Claude Code
(ttyd + tmux) with LotusGuild TDS styling, multiple named sessions,
per-session conversation resume and Debian packaging.
-- Jared Vititoe <jjvititoe1@gmail.com> Tue, 06 Oct 2026 22:58:26 -0400
+24
View File
@@ -0,0 +1,24 @@
Source: devdesk
Section: admin
Priority: optional
Maintainer: Jared Vititoe <jjvititoe1@gmail.com>
# Node.js >= 18 with npm is also needed to build the web UI (provided by the CI image / nodesource).
Build-Depends: debhelper-compat (= 13), bats, ca-certificates, curl, jq, tmux (>= 3.2)
Standards-Version: 4.6.2
Homepage: https://code.lotusguild.org/LotusGuild/devdesk
Vcs-Browser: https://code.lotusguild.org/LotusGuild/devdesk
Vcs-Git: https://code.lotusguild.org/LotusGuild/devdesk.git
Rules-Requires-Root: no
Package: devdesk
Architecture: amd64
Depends: ${misc:Depends}, fzf, jq, tmux (>= 3.2)
Description: persistent browser terminal for Claude Code
DevDesk serves a LotusGuild-styled web terminal (ttyd with an xterm.js
front end) whose sessions live in a dedicated tmux server. Closing the
browser, restarting the web service or upgrading the package never stops
the Claude Code processes; after a reboot each named session resumes its
own conversation.
.
Authentication and TLS are expected to be handled by a reverse proxy
(Nginx Proxy Manager + Authelia), which also injects the ttyd credential.
+166
View File
@@ -0,0 +1,166 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: devdesk
Source: https://code.lotusguild.org/LotusGuild/devdesk
Comment: The binary package also contains third-party components:
the upstream ttyd static build (/usr/lib/devdesk/bin/ttyd), and xterm.js,
its addons and JetBrains Mono bundled into /usr/share/devdesk/index.html.
Files: *
Copyright: 2026 Jared Vititoe <jjvititoe1@gmail.com>
License: MIT
Files: vendor/web_template/*
Copyright: 2026 Jared Vititoe
License: MIT
Comment: LotusGuild web_template design system (base.css).
Files: vendor/ttyd/*
Copyright: 2016-2022 Shuanglei Tao <tsl0922@gmail.com>
License: MIT
Comment: ttyd 1.7.7 static build (ttyd.x86_64), fetched at build time and
verified against SHA256SUMS. The static binary also links:
libwebsockets (MIT, Copyright 2010-2020 Andy Green and contributors),
libuv (MIT, Copyright Joyent, Inc. and other Node contributors),
json-c (MIT, Copyright 2009-2012 Eric Haszlakiewicz, 2004-2005 Metaparadigm Pte Ltd),
musl libc (MIT, Copyright 2005-2020 Rich Felker et al.),
zlib (Zlib, Copyright 1995-2024 Jean-loup Gailly and Mark Adler),
mbedtls (Apache-2.0 OR GPL-2.0-or-later, Copyright The Mbed TLS Contributors).
See /usr/share/common-licenses/Apache-2.0 and /usr/share/common-licenses/GPL-2.
Files: web/*
Copyright: 2026 Jared Vititoe <jjvititoe1@gmail.com>
License: MIT
Comment: Bundled into index.html at build time:
xterm.js and @xterm addons (MIT, Copyright 2017-2019 The xterm.js authors,
2014-2016 SourceLair Private Company, 2012-2013 Christopher Jeffrey);
JetBrains Mono via @fontsource/jetbrains-mono (OFL-1.1,
Copyright 2020 The JetBrains Mono Project Authors).
License: MIT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
License: Zlib
This software is provided 'as-is', without any express or implied
warranty. In no event will the authors be held liable for any damages
arising from the use of this software.
.
Permission is granted to anyone to use this software for any purpose,
including commercial applications, and to alter it and redistribute it
freely, subject to the following restrictions:
.
1. The origin of this software must not be misrepresented; you must not
claim that you wrote the original software. If you use this software
in a product, an acknowledgment in the product documentation would be
appreciated but is not required.
2. Altered source versions must be plainly marked as such, and must not be
misrepresented as being the original software.
3. This notice may not be removed or altered from any source distribution.
License: OFL-1.1
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
.
.
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
.
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
.
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
.
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+29
View File
@@ -0,0 +1,29 @@
[Unit]
Description=DevDesk tmux server (persistent Claude Code sessions)
Documentation=https://code.lotusguild.org/LotusGuild/devdesk
Wants=network-online.target
After=network-online.target
# Deliberately an unrestricted root shell host: no ProtectSystem/ProtectHome/PrivateTmp.
[Service]
Type=simple
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
WorkingDirectory=-/root
RuntimeDirectory=devdesk
RuntimeDirectoryMode=0700
StateDirectory=devdesk
StateDirectoryMode=0700
ExecStart=/usr/bin/tmux -u -D -S /run/devdesk/tmux.sock -f /usr/share/devdesk/tmux.conf
ExecStartPost=/usr/lib/devdesk/devdesk-tmux-ready
# One OOM-killed build inside a session must not take every session down.
OOMPolicy=continue
KillMode=mixed
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
[Install]
WantedBy=multi-user.target
+3
View File
@@ -0,0 +1,3 @@
README.md
docs/DEPLOY.md
docs/npm-advanced.conf
+4
View File
@@ -0,0 +1,4 @@
# ttyd is the upstream static release (musl, bundled libwebsockets/libuv/json-c/zlib/mbedtls),
# shipped unmodified and verified against upstream SHA256SUMS. ttyd is not packaged for bookworm.
devdesk: statically-linked-binary [usr/lib/devdesk/bin/ttyd]
devdesk: embedded-library zlib [usr/lib/devdesk/bin/ttyd]
+1
View File
@@ -0,0 +1 @@
docs/devdesk.1
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
set -e
case "$1" in
configure)
# Credential ttyd requires; NPM injects it as an Authorization header.
if [ ! -s /etc/devdesk/secret.env ]; then
umask 077
secret=$(od -An -tx1 -N24 /dev/urandom | tr -d ' \n')
printf 'DEVDESK_CREDENTIAL=devdesk:%s\n' "$secret" >/etc/devdesk/secret.env
fi
chmod 600 /etc/devdesk/secret.env
# Apply tmux.conf changes to a running server without restarting it.
if [ -S /run/devdesk/tmux.sock ]; then
tmux -N -S /run/devdesk/tmux.sock source-file /usr/share/devdesk/tmux.conf >/dev/null 2>&1 || true
fi
;;
esac
#DEBHELPER#
exit 0
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -e
if [ "$1" = purge ]; then
rm -f /etc/devdesk/secret.env
rm -rf /var/lib/devdesk
fi
#DEBHELPER#
exit 0
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=DevDesk web terminal (ttyd)
Documentation=https://code.lotusguild.org/LotusGuild/devdesk
Wants=devdesk-tmux.service network-online.target
After=devdesk-tmux.service network-online.target
[Service]
Type=simple
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/secret.env
ExecStart=/usr/lib/devdesk/devdesk-serve
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
Vendored Executable
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/make -f
%:
dh $@
# Build runs `make`, which fetches the pinned ttyd release and verifies its sha256.
# The tmux server unit owns every Claude process: never stop or restart it on
# upgrade. The web unit (ttyd) is safe to restart and does so after upgrades.
override_dh_installsystemd:
dh_installsystemd --name=devdesk-tmux --no-stop-on-upgrade
dh_installsystemd devdesk.service
# ttyd is an upstream static binary; keep it byte-identical to its published checksum.
override_dh_strip:
dh_strip -Xbin/ttyd
override_dh_dwz:
dh_dwz -Xbin/ttyd
+1
View File
@@ -0,0 +1 @@
3.0 (native)
+51
View File
@@ -0,0 +1,51 @@
# Deploying DevDesk (CT 126 · dev.lotusguild.org)
## 1. Install the package
Add the Gitea apt repository once:
```bash
curl -fsSL https://code.lotusguild.org/api/packages/LotusGuild/debian/repository.key \
-o /etc/apt/keyrings/gitea-LotusGuild.asc
echo "deb [signed-by=/etc/apt/keyrings/gitea-LotusGuild.asc] https://code.lotusguild.org/api/packages/LotusGuild/debian bookworm main" \
> /etc/apt/sources.list.d/lotusguild.list
apt update && apt install devdesk
```
On install, `/etc/devdesk/secret.env` is generated, `devdesk-tmux.service` and `devdesk.service` are enabled and started, and ttyd listens on `:7681`. Requests without the credential get a **401**.
## 2. Nginx Proxy Manager (CT 139)
- **Proxy host:** `dev.lotusguild.org` → `http://10.10.10.58:7681`.
- Websockets Support **on**.
- SSL: the existing wildcard `*.lotusguild.org` certificate, with Force SSL.
- **Advanced tab:** paste [`npm-advanced.conf`](npm-advanced.conf), replacing `__DEVDESK_BASIC__` with the output of:
```bash
. /etc/devdesk/secret.env && printf '%s' "$DEVDESK_CREDENTIAL" | base64 -w0
```
- **Why the credential matters:** ttyd only checks the credential, so anything on the LAN that bypasses NPM is refused. If you rotate it, delete `secret.env`, run `dpkg-reconfigure devdesk`, update NPM, then `systemctl restart devdesk`.
## 3. Authelia (CT 167)
Add these to `access_control.rules` in `/etc/authelia/configuration.yml`, **above** any broader `*.lotusguild.org` rule:
```yaml
- domain: dev.lotusguild.org
policy: two_factor
subject: ['user:jared']
- domain: dev.lotusguild.org
policy: deny
```
Then run `authelia validate-config --config /etc/authelia/configuration.yml && systemctl restart authelia`.
## 4. DNS
- **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.
## 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) |
| Restart the web side (safe) | `systemctl restart devdesk` |
| **Stop every session** | `systemctl stop devdesk-tmux` |
| Logs | `journalctl -u devdesk -u devdesk-tmux` |
| UI-only update | replace `/usr/share/devdesk/index.html` (ttyd re-reads it per request) |
+60
View File
@@ -0,0 +1,60 @@
.TH DEVDESK 1 "October 2026" "devdesk" "User Commands"
.SH NAME
devdesk \- persistent Claude Code sessions in tmux, shared by the browser and SSH
.SH SYNOPSIS
.B devdesk attach
.RI [ NAME ]
.br
.B devdesk new
.I NAME
.br
.B devdesk ls
.br
.B devdesk pick
.br
.B devdesk end
.I NAME
.SH DESCRIPTION
.B devdesk
manages the named sessions served at https://dev.lotusguild.org. Each session is
a tmux session on the DevDesk server (\fI/run/devdesk/tmux.sock\fR, run by
\fBdevdesk-tmux.service\fR) with a \fIclaude\fR window and a \fIshell\fR window,
rooted in a project directory under \fBDEVDESK_PROJECTS_ROOT\fR.
.PP
Claude's conversation ID is recorded per session, so after a reboot the session
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.
.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.
.TP
.B ls
List running (\(bu) and dormant (\(ci) sessions.
.TP
.B pick
Interactive picker (fzf).
.TP
.B end \fINAME\fR
Stop the session and forget its recorded conversation.
.SH FILES
.TP
.I /etc/devdesk/env
Port, projects root, Claude binary and arguments, Remote Control switch.
.TP
.I /etc/devdesk/secret.env
ttyd credential; the reverse proxy must send it as HTTP basic auth.
.TP
.I /var/lib/devdesk/registry.json
Session to directory and Claude session ID mapping.
.SH SEE ALSO
.BR tmux (1),
.BR claude (1)
+35
View File
@@ -0,0 +1,35 @@
# Nginx Proxy Manager → Proxy Host dev.lotusguild.org → Advanced tab.
# Target: http://10.10.10.58:7681, Websockets Support ON, wildcard *.lotusguild.org cert, Force SSL.
#
# Replace __DEVDESK_BASIC__ with the base64 of the credential in /etc/devdesk/secret.env:
# . /etc/devdesk/secret.env && printf '%s' "$DEVDESK_CREDENTIAL" | base64 -w0
# ttyd rejects (401) every request that does not carry it, so only traffic that
# passed Authelia here can reach the terminal.
include /snippets/authelia-location.conf;
location / {
add_header Strict-Transport-Security $hsts_header always;
auth_request /authelia;
auth_request_set $user $upstream_http_remote_user;
auth_request_set $groups $upstream_http_remote_groups;
auth_request_set $name $upstream_http_remote_name;
auth_request_set $email $upstream_http_remote_email;
proxy_set_header Remote-User $user;
proxy_set_header Remote-Groups $groups;
proxy_set_header Remote-Name $name;
proxy_set_header Remote-Email $email;
error_page 401 =302 https://auth.lotusguild.org/?rd=$scheme://$http_host$request_uri;
proxy_set_header Authorization "Basic __DEVDESK_BASIC__";
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_http_version 1.1;
proxy_buffering off;
include /etc/nginx/conf.d/include/proxy.conf;
}
+20
View File
@@ -0,0 +1,20 @@
# DevDesk configuration. 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).
DEVDESK_PORT=7681
# Optional interface name or IP for ttyd to bind; empty = all interfaces.
DEVDESK_BIND=
# Sessions map to project directories under this root; the default session uses the root itself.
DEVDESK_PROJECTS_ROOT=/root/code
DEVDESK_DEFAULT_SESSION=main
# Claude Code binary and extra arguments for every session.
CLAUDE_BIN=/root/.local/bin/claude
CLAUDE_ARGS=
# 1 = also enable Remote Control so sessions can be continued from the Claude app.
DEVDESK_REMOTE_CONTROL=0
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
# Runs in each session's "claude" window. Resumes the conversation recorded for
# this session (see devdesk-session-hook), starts a fresh one if there is none,
# and offers a small menu whenever Claude exits so the window never just dies.
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}
SETTINGS="$DEVDESK_SHARE/claude-settings.json"
read -r -a EXTRA <<<"${CLAUDE_ARGS:-}"
ORANGE=$'\033[38;2;255;107;0m'
CYAN=$'\033[38;2;0;212;255m'
MUTED=$'\033[38;2;62;96;122m'
RESET=$'\033[0m'
common_args() {
printf '%s\0' --settings "$SETTINGS"
if [ "${DEVDESK_REMOTE_CONTROL:-0}" = "1" ]; then
printf '%s\0' --remote-control "devdesk-$NAME"
fi
if [ ${#EXTRA[@]} -gt 0 ]; then
printf '%s\0' "${EXTRA[@]}"
fi
}
run_new() {
local args
mapfile -d '' args < <(common_args)
"$CLAUDE_BIN" -n "$NAME" "${args[@]}"
}
run_resume() {
local id args started rc
id=$(dd_registry_get "$NAME" claude_session_id)
if [ -z "$id" ]; then
run_new
return
fi
mapfile -d '' args < <(common_args)
started=$SECONDS
"$CLAUDE_BIN" --resume "$id" "${args[@]}"
rc=$?
# A resume that fails straight away means the transcript is gone; start fresh.
if [ "$rc" -ne 0 ] && [ $((SECONDS - started)) -lt 10 ]; then
printf '%sCould not resume conversation %s — starting a new one.%s\n' "$MUTED" "$id" "$RESET"
sleep 1
run_new
return
fi
return "$rc"
}
menu() {
printf '\n%s◈ Claude exited%s %s(session %s, code %s)%s\n' "$ORANGE" "$RESET" "$MUTED" "$NAME" "$1" "$RESET"
printf ' %s[⏎]%s resume %s[n]%s new conversation %s[s]%s shell %s[x]%s end session\n' \
"$CYAN" "$RESET" "$CYAN" "$RESET" "$CYAN" "$RESET" "$CYAN" "$RESET"
}
printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\)
mode=resume
while :; do
if [ "$mode" = new ]; then run_new; else run_resume; fi
rc=$?
while :; do
menu "$rc"
IFS= read -rsn1 key || exit 0
case $key in
"") mode=resume; break ;;
n | N) mode=new; break ;;
s | S)
printf '%sShell in %s — exit to return to this menu.%s\n' "$MUTED" "$PWD" "$RESET"
"${SHELL:-/bin/bash}" -l
;;
x | X)
printf 'End session %s? This stops Claude and forgets the session. [y/N] ' "$NAME"
IFS= read -rsn1 confirm || exit 0
echo
if [ "$confirm" = y ] || [ "$confirm" = Y ]; then
dd_registry_remove "$NAME"
tmux kill-session -t "=$NAME" 2>/dev/null
exit 0
fi
;;
esac
done
done
+138
View File
@@ -0,0 +1,138 @@
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables ($n, $d, …)
# Shared helpers for the devdesk CLI and its libexec scripts. Sourced, not executed.
#
# Every path can be overridden from the environment so the test suite can run
# 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
: "${DEVDESK_PROJECTS_ROOT:=/root/code}"
: "${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_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$'
dd_die() {
printf 'devdesk: %s\n' "$*" >&2
exit 1
}
# tmux client bound to the DevDesk server. -N: never start a server from here,
# because a server started by a client would live in the caller's cgroup (e.g.
# ttyd's) and die with it.
dd_tmux() {
tmux -N -u -S "$DEVDESK_SOCKET" "$@"
}
dd_valid_name() {
[[ $1 =~ $DEVDESK_NAME_RE ]]
}
# Directory name -> session name: anything outside [A-Za-z0-9_-] becomes '-',
# leading separators are dropped, max 64 chars.
dd_slugify() {
local s=${1//[^A-Za-z0-9_-]/-}
while [[ $s == [-_]* ]]; do s=${s:1}; done
printf '%s' "${s:0:64}"
}
dd_server_up() {
[ -S "$DEVDESK_SOCKET" ] && dd_tmux start-server 2>/dev/null
}
dd_ensure_server() {
dd_server_up && return 0
if [ -z "${DEVDESK_NO_SYSTEMD:-}" ] && [ -d /run/systemd/system ]; then
# ExecStartPost on the unit blocks until the socket answers.
systemctl start "$DEVDESK_TMUX_UNIT" ||
dd_die "could not start $DEVDESK_TMUX_UNIT (see: journalctl -u $DEVDESK_TMUX_UNIT)"
dd_server_up && return 0
fi
dd_die "tmux server is not running on $DEVDESK_SOCKET (start $DEVDESK_TMUX_UNIT)"
}
# --- registry: $DEVDESK_REGISTRY = {"sessions": {"<name>": {dir, claude_session_id, ...}}}
dd_registry_get() {
[ -s "$DEVDESK_REGISTRY" ] || return 0
jq -Mr --arg n "$1" --arg f "$2" '.sessions[$n][$f] // empty' "$DEVDESK_REGISTRY" 2>/dev/null
}
# dd_registry_update JQ_FILTER [jq args...] — read-modify-write under flock.
dd_registry_update() {
local filter=$1
shift
mkdir -p "$DEVDESK_STATE_DIR"
(
flock -w 5 9 || exit 1
[ -s "$DEVDESK_REGISTRY" ] || printf '{"sessions":{}}\n' >"$DEVDESK_REGISTRY"
local tmp
tmp=$(mktemp "$DEVDESK_REGISTRY.XXXXXX") || exit 1
if jq "$@" "$filter" "$DEVDESK_REGISTRY" >"$tmp"; then
chmod 600 "$tmp"
mv -f "$tmp" "$DEVDESK_REGISTRY"
else
rm -f "$tmp"
exit 1
fi
) 9>"$DEVDESK_REGISTRY.lock"
}
dd_registry_set_dir() {
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, updated: $t})' \
--arg n "$1" --arg d "$2" --arg t "$(date -Is)"
}
dd_registry_set_claude() {
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {claude_session_id: $id, claude_source: $src, updated: $t})' \
--arg n "$1" --arg id "$2" --arg src "$3" --arg t "$(date -Is)"
}
dd_registry_remove() {
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
}
# 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
fi
fi
[ -n "$dir" ] || return 1
dir=$(realpath -e -- "$dir" 2>/dev/null) || return 1
root=$(realpath -e -- "$DEVDESK_PROJECTS_ROOT" 2>/dev/null) || return 1
case "$dir/" in
"$root"/*) printf '%s\n' "$dir" ;;
*) return 1 ;;
esac
}
# 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"
}
+64
View File
@@ -0,0 +1,64 @@
#!/bin/bash
# Interactive session picker (fzf). Runs as ttyd's child when no ?arg= is given,
# or via `devdesk pick` over SSH. Selecting an entry execs `devdesk attach NAME`.
set -uo pipefail
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
DEVDESK_BIN=${DEVDESK_BIN:-devdesk}
entries() {
# Output: NAME<TAB>display line (fzf shows and searches only the display line).
"$DEVDESK_BIN" --list | jq -Mr '
def ago: if . == null then "" else
((now - .) | if . < 3600 then "\(. / 60 | floor)m ago"
elif . < 86400 then "\(. / 3600 | floor)h ago"
else "\(. / 86400 | floor)d ago" end) end;
def pad($n): . + (" " * ([$n - length, 1] | max));
def short: if . == null then "" else sub("^" + ($ENV.HOME // "/root"); "~") end;
def row($mark; $color; $name; $dir; $note):
[$name, "\u001b[38;2;\($color)m\($mark)\u001b[0m \($name | pad(26))\u001b[38;2;62;96;122m\($dir | short | pad(34))\($note)\u001b[0m"];
(.sessions | map(.name)) as $names
| (.sessions | sort_by(.running | not)[]
| if .running then row("●"; "0;255;136"; .name; .dir; "\(.attached) attached · \(.activity | ago)")
else row("○"; "62;96;122"; .name; .dir; "dormant · resumes Claude") end),
(.projects[] | select(.name as $n | $names | index($n) | not)
| row("+"; "0;212;255"; .name; .dir; "new session"))
| @tsv'
}
if [ "${1:-}" = "--entries" ]; then
entries
exit 0
fi
# Tell the web UI (via the terminal title) that the picker is showing.
printf '\033]0;devdesk:picker|Pick a session\007'
while :; do
selection=$(entries | fzf --ansi --layout=reverse --no-multi --print-query \
--delimiter=$'\t' --with-nth=2 \
--prompt='devdesk ▸ ' --pointer='▶' --marker='◈' \
--header=$'Enter: open · type a new name to create it · Ctrl-R: refresh · Esc: quit\n' \
--bind="ctrl-r:reload($0 --entries)" \
--color='bg:#010304,bg+:#0d1520,fg:#c4d9ee,fg+:#ffffff,hl:#FF6B00,hl+:#FF8C2B' \
--color='prompt:#FF6B00,pointer:#00D4FF,marker:#00FF88,header:#3e607a,info:#3e607a,border:#0d1520')
rc=$?
# Esc / Ctrl-C: leave the picker (the web UI then offers to reopen it).
[ "$rc" -eq 130 ] && exit 0
query=$(sed -n '1p' <<<"$selection")
choice=$(sed -n '2p' <<<"$selection" | cut -f1)
if [ -z "$choice" ] && [ -n "$query" ]; then
if dd_valid_name "$query"; then
choice=$query
else
printf '\n Invalid session name: %s\n' "$query"
sleep 1.5
continue
fi
fi
if [ -n "$choice" ]; then
exec "$DEVDESK_BIN" attach "$choice"
fi
done
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# ExecStart for devdesk.service: builds the ttyd command line from
# /etc/devdesk/env and /etc/devdesk/secret.env (loaded by systemd).
set -euo pipefail
if [ -z "${DEVDESK_CREDENTIAL:-}" ]; then
echo "devdesk-serve: DEVDESK_CREDENTIAL is not set (expected in /etc/devdesk/secret.env)" >&2
exit 1
fi
args=(
--port "${DEVDESK_PORT:-7681}"
--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}"
--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
)
if [ -n "${DEVDESK_BIND:-}" ]; then
args+=(--interface "$DEVDESK_BIND")
fi
exec "${DEVDESK_TTYD:-/usr/lib/devdesk/bin/ttyd}" "${args[@]}" "${DEVDESK_BIN:-/usr/bin/devdesk}" attach
+19
View File
@@ -0,0 +1,19 @@
#!/bin/bash
# Claude Code SessionStart hook (wired in via claude-settings.json). Records the
# Claude session ID for the DevDesk session this Claude runs in, so that after a
# reboot devdesk-claude-run can `claude --resume` exactly this conversation.
# Fires on startup, resume, /clear and /compact. Must never block Claude.
# 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
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
exit 0
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# ExecStartPost for devdesk-tmux.service: wait until the tmux server answers on
# its socket, so `systemctl start devdesk-tmux` only returns once clients can attach.
SOCK=${DEVDESK_SOCKET:-/run/devdesk/tmux.sock}
for _ in $(seq 1 100); do
if [ -S "$SOCK" ] && tmux -N -S "$SOCK" start-server 2>/dev/null; then
exit 0
fi
sleep 0.1
done
echo "devdesk-tmux-ready: tmux server did not come up on $SOCK" >&2
exit 1
+1811
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
{
"name": "devdesk",
"private": true,
"description": "Persistent browser terminal for Claude Code (ttyd + tmux), LotusGuild TDS styling",
"license": "MIT",
"type": "module",
"engines": { "node": ">=18" },
"scripts": {
"build": "node web/build.mjs",
"lint": "eslint --ext .js,.mjs web/src web/build.mjs tests/protocol.test.mjs",
"test": "node --test tests/"
},
"devDependencies": {
"@fontsource/jetbrains-mono": "5.3.0",
"@xterm/addon-clipboard": "0.2.0",
"@xterm/addon-fit": "0.11.0",
"@xterm/addon-unicode11": "0.9.0",
"@xterm/addon-web-links": "0.12.0",
"@xterm/addon-webgl": "0.19.0",
"@xterm/xterm": "6.0.0",
"esbuild": "0.28.2",
"eslint": "8.57.1"
}
}
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# Download the pinned upstream ttyd static binary and verify it against the
# vendored SHA256SUMS. Usage: scripts/fetch-ttyd.sh OUTPUT [ARCH]
set -euo pipefail
out=${1:?usage: fetch-ttyd.sh OUTPUT [ARCH]}
arch=${2:-x86_64}
here=$(cd "$(dirname "$0")/.." && pwd)
version=$(cat "$here/vendor/ttyd/VERSION")
asset="ttyd.$arch"
expected=$(awk -v a="$asset" '$2 == a { print $1 }' "$here/vendor/ttyd/SHA256SUMS")
[ -n "$expected" ] || { echo "fetch-ttyd: no checksum for $asset" >&2; exit 1; }
if [ -f "$out" ] && echo "$expected $out" | sha256sum -c --status -; then
exit 0
fi
mkdir -p "$(dirname "$out")"
tmp=$(mktemp "$out.XXXXXX")
trap 'rm -f "$tmp"' EXIT
curl -fsSL --retry 3 -o "$tmp" "https://github.com/tsl0922/ttyd/releases/download/$version/$asset"
echo "$expected $tmp" | sha256sum -c --status - || { echo "fetch-ttyd: checksum mismatch for $asset $version" >&2; exit 1; }
chmod 0755 "$tmp"
mv -f "$tmp" "$out"
trap - EXIT
echo "fetch-ttyd: $asset $version verified"
+16
View File
@@ -0,0 +1,16 @@
{
"preferredNotifChannel": "terminal_bell",
"hooks": {
"SessionStart": [
{
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-session-hook",
"timeout": 10
}
]
}
]
}
}
+41
View File
@@ -0,0 +1,41 @@
# DevDesk tmux configuration, loaded by devdesk-tmux.service.
# Local overrides: /etc/devdesk/tmux.local.conf (sourced last).
# --- terminal behaviour -------------------------------------------------------
set -s escape-time 10 # Esc reaches Claude (interrupt) immediately
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 -g history-limit 50000
set -g mouse on # wheel scrolls history; Shift+drag selects natively
set -g allow-passthrough on
set -g renumber-windows on
# --- bells reach the browser (Claude's terminal_bell notifications) ----------
set -g bell-action any
set -g visual-bell off
set -g monitor-bell on
# --- titles: the web UI parses "devdesk:<session>|<pane title>" ---------------
set -g set-titles on
set -g set-titles-string "devdesk:#S|#T"
# --- every new session gets a spare shell next to Claude ---------------------
set-hook -g session-created "new-window -d -n shell"
# --- LotusGuild TDS colours ---------------------------------------------------
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 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"
set -g message-style "bg=#0d1520,fg=#00D4FF"
set -g mode-style "bg=#00D4FF,fg=#030508"
set -g pane-border-style "fg=#0d1520"
set -g pane-active-border-style "fg=#00D4FF"
source-file -q /etc/devdesk/tmux.local.conf
+162
View File
@@ -0,0 +1,162 @@
#!/usr/bin/env bats
# Exercises bin/devdesk and the libexec helpers against a throwaway tmux server.
REPO=$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)
setup() {
TMP=$(mktemp -d /tmp/devdesk-test.XXXXXX) # short path: unix socket limit
export DEVDESK_ENV=/dev/null
export DEVDESK_PROJECTS_ROOT="$TMP/projects"
export DEVDESK_STATE_DIR="$TMP/state"
export DEVDESK_SOCKET="$TMP/tmux.sock"
export DEVDESK_LIBEXEC="$REPO/libexec"
export DEVDESK_SHARE="$REPO/share"
export DEVDESK_NO_SYSTEMD=1
export FAKE_LOG="$TMP/claude-args"
export CLAUDE_BIN="$TMP/fake-claude"
export PATH="$REPO/bin:$PATH"
mkdir -p "$DEVDESK_PROJECTS_ROOT/alpha" "$DEVDESK_PROJECTS_ROOT/test code" "$TMP/outside" "$FAKE_LOG"
ln -s "$TMP/outside" "$DEVDESK_PROJECTS_ROOT/escape"
cat >"$CLAUDE_BIN" <<'EOF'
#!/bin/bash
printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION"
exec sleep 300
EOF
chmod +x "$CLAUDE_BIN"
}
teardown() {
tmux -S "$DEVDESK_SOCKET" kill-server 2>/dev/null || true
rm -rf "$TMP"
}
start_server() {
tmux -u -D -S "$DEVDESK_SOCKET" -f "$REPO/share/tmux.conf" &
"$REPO/libexec/devdesk-tmux-ready"
}
# Wait for the fake claude in SESSION to record its arguments.
claude_args() {
for _ in $(seq 1 50); do
[ -s "$FAKE_LOG/$1" ] && { tail -n1 "$FAKE_LOG/$1"; return 0; }
sleep 0.1
done
return 1
}
@test "rejects invalid session names" {
start_server
for bad in ../etc "a b" -x .hidden "$(printf 'x%.0s' {1..70})"; do
run devdesk attach "$bad"
[ "$status" -ne 0 ]
[[ $output == *"invalid session name"* ]]
done
run devdesk attach one two
[ "$status" -ne 0 ]
}
@test "fails clearly when the tmux server is down" {
run devdesk new alpha
[ "$status" -ne 0 ]
[[ $output == *"tmux server is not running"* ]]
}
@test "new session gets claude and shell windows in the project dir" {
start_server
run devdesk new alpha
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" list-windows -t =alpha -F '#{window_name}:#{pane_current_path}'
[ "${lines[0]}" = "claude:$DEVDESK_PROJECTS_ROOT/alpha" ]
[ "${lines[1]}" = "shell:$DEVDESK_PROJECTS_ROOT/alpha" ]
run claude_args alpha
[ "$output" = "-n alpha --settings $REPO/share/claude-settings.json" ]
run jq -r '.sessions.alpha.dir' "$DEVDESK_STATE_DIR/registry.json"
[ "$output" = "$DEVDESK_PROJECTS_ROOT/alpha" ]
}
@test "creating an existing session is a no-op" {
start_server
devdesk new alpha
run devdesk new alpha
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" list-sessions -F '#{session_name}'
[ "$output" = "alpha" ]
}
@test "default session maps to the projects root" {
start_server
devdesk new main
run tmux -S "$DEVDESK_SOCKET" display -p -t =main:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT" ]
}
@test "directories with spaces are reachable by slug" {
start_server
run devdesk new test-code
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" display -p -t =test-code:claude '#{pane_current_path}'
[ "$output" = "$DEVDESK_PROJECTS_ROOT/test code" ]
}
@test "symlinks may not escape the projects root" {
start_server
run devdesk new escape
[ "$status" -ne 0 ]
[[ $output == *"no project directory"* ]]
run devdesk new nonexistent
[ "$status" -ne 0 ]
}
@test "session hook records the Claude session and the next start resumes it" {
start_server
echo '{"session_id":"abc-123","source":"startup"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
run jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json"
[ "$output" = "abc-123" ]
devdesk new alpha
run claude_args alpha
[ "$output" = "--resume abc-123 --settings $REPO/share/claude-settings.json" ]
}
@test "session hook ignores sessions it does not own" {
echo '{"session_id":"zzz"}' | DEVDESK_SESSION= "$REPO/libexec/devdesk-session-hook"
echo '{"session_id":"zzz"}' | DEVDESK_SESSION='../x' "$REPO/libexec/devdesk-session-hook"
[ ! -e "$DEVDESK_STATE_DIR/registry.json" ]
}
@test "--list reports running, dormant and project entries" {
start_server
devdesk new alpha
echo '{"session_id":"old-1","source":"resume"}' | DEVDESK_SESSION=gone "$REPO/libexec/devdesk-session-hook"
run devdesk attach --list
[ "$status" -eq 0 ]
[ "$(jq -r '.default' <<<"$output")" = main ]
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .running' <<<"$output")" = true ]
[ "$(jq -r '.sessions[] | select(.name=="gone") | .running' <<<"$output")" = false ]
[ "$(jq -r '.sessions[] | select(.name=="gone") | .claude_session_id' <<<"$output")" = old-1 ]
[ "$(jq -r '[.projects[].name] | sort | join(",")' <<<"$output")" = "alpha,main,test-code" ]
}
@test "--list works with the server down" {
run devdesk attach --list
[ "$status" -eq 0 ]
[ "$(jq -r '.sessions | length' <<<"$output")" = 0 ]
}
@test "end kills the session and forgets it" {
start_server
devdesk new alpha
run devdesk end alpha
[ "$status" -eq 0 ]
run tmux -S "$DEVDESK_SOCKET" has-session -t =alpha
[ "$status" -ne 0 ]
run jq -r '.sessions | has("alpha")' "$DEVDESK_STATE_DIR/registry.json"
[ "$output" = false ]
}
@test "remote control flag is passed when enabled" {
start_server
tmux -S "$DEVDESK_SOCKET" set-environment -g DEVDESK_REMOTE_CONTROL 1
devdesk new alpha
run claude_args alpha
[ "$output" = "-n alpha --settings $REPO/share/claude-settings.json --remote-control devdesk-alpha" ]
}
+69
View File
@@ -0,0 +1,69 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
Client, FlowControl, decodeServer, encodeBinaryInput, encodeInit, encodeInput, encodeResize,
parseListOutput, parseTitle, sessionFromSearch, tokenUrl, wsUrl,
} from '../web/src/protocol.js';
const text = (bytes) => new TextDecoder().decode(bytes);
test('init message is bare JSON with the auth token and size', () => {
assert.deepEqual(JSON.parse(text(encodeInit('dG9r', 120, 40))), { AuthToken: 'dG9r', columns: 120, rows: 40 });
});
test('input frames are prefixed with "0" and UTF-8 encoded', () => {
assert.equal(text(encodeInput('héllo\n')), '0héllo\n');
assert.deepEqual([...encodeBinaryInput('\x1b\xff')], [0x30, 0x1b, 0xff]);
});
test('resize frames are "1" + JSON', () => {
assert.equal(text(encodeResize(80, 24)), '1{"columns":80,"rows":24}');
});
test('server frames split into command and payload', () => {
const frame = new TextEncoder().encode('0output').buffer;
const { type, payload } = decodeServer(frame);
assert.equal(type, '0');
assert.equal(text(payload), 'output');
});
test('URLs follow the page location, including a path prefix', () => {
const loc = { protocol: 'https:', host: 'dev.lotusguild.org', pathname: '/' };
assert.equal(wsUrl(loc, 'main'), 'wss://dev.lotusguild.org/ws?arg=main');
assert.equal(wsUrl(loc, null), 'wss://dev.lotusguild.org/ws');
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');
});
test('only valid session names are taken from the URL', () => {
assert.equal(sessionFromSearch('?arg=tinker_tickets'), 'tinker_tickets');
assert.equal(sessionFromSearch('?arg=../etc'), null);
assert.equal(sessionFromSearch('?arg=--list'), null);
assert.equal(sessionFromSearch(''), null);
});
test('tmux titles carry the session and the pane title', () => {
assert.deepEqual(parseTitle('devdesk:main|✳ Refactor | parser'), { session: 'main', pane: '✳ Refactor | parser' });
assert.deepEqual(parseTitle('devdesk:picker|Pick a session'), { session: 'picker', pane: 'Pick a session' });
assert.equal(parseTitle('bash'), null);
});
test('session list JSON survives pty line endings', () => {
const out = '\r\n{"default":"main","sessions":[],\r\n"projects":[]}\r\n';
assert.deepEqual(parseListOutput(out), { default: 'main', sessions: [], projects: [] });
const colored = '\x1b[1;39m{\x1b[0m\x1b[34;1m"default"\x1b[0m:\x1b[0;32m"main"\x1b[0m}';
assert.deepEqual(parseListOutput(colored), { default: 'main' });
assert.throws(() => parseListOutput('devdesk: tmux server is not running'));
});
test('flow control pauses after highWater pending writes and resumes below lowWater', () => {
const flow = new FlowControl({ limit: 10, highWater: 2, lowWater: 1 });
assert.deepEqual(flow.onData(5), { command: null, track: false });
assert.deepEqual(flow.onData(10), { command: null, track: true }); // pending 1
assert.deepEqual(flow.onData(11), { command: null, track: true }); // pending 2
assert.deepEqual(flow.onData(11), { command: Client.PAUSE, track: true }); // pending 3
assert.equal(flow.release(), null); // 2
assert.equal(flow.release(), null); // 1
assert.equal(flow.release(), Client.RESUME); // 0
});
+11
View File
@@ -0,0 +1,11 @@
b38acadd89d1d396a0f5649aa52c539edbad07f4bc7348b27b4f4b7219dd4165 ttyd.aarch64
05eac1223914f18c65898d72c8d14e76bbb5435f7762c6dc7f16f041994a8109 ttyd.arm
8240c8438b68d3b10b0e1a4e7c914d70fca6a7606b516f40bf40adfa1044d801 ttyd.armhf
93e112e19c9c0dcc717e98bdba0f43fc50f0c74ae1ba5b572772c507654ed19c ttyd.i686
69884b43ddbaea5a1792edc979890f78f85b3bd99725d9402e8210107e28c67d ttyd.mips
be6f8fc30268a345bf55c6ab8a79b03018bc1fad75457e81b68007a7c89c232e ttyd.mips64
4d3e612a182d1a2e2faeaa8ff4ce53a3b41e12e337e7b3832b1d3ce5f6d32738 ttyd.mips64el
8d1ab38deb47cd190d0efe387095f8f87f7ae378b0c2efcf4a94832c3a92bdb9 ttyd.mipsel
bd9b52d6eb0f12cf30902f7dcdeabbcb707a359b18a0203d05ed81d3890ecff8 ttyd.s390x
e33a27501b10b96981335bcba938b1145c7f52551a343e72160f00ab71832b37 ttyd.win32.exe
8a217c968aba172e0dbf3f34447218dc015bc4d5e59bf51db2f2cd12b7be4f55 ttyd.x86_64
+1
View File
@@ -0,0 +1 @@
1.7.7
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Jared Vititoe
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+11
View File
@@ -0,0 +1,11 @@
Source: https://code.lotusguild.org/LotusGuild/web_template
Commit: fca7acb (origin/main, 2026-10-03), base.css identical to bbec859
File: base.css only (base.js is deliberately not vendored, see below)
Refresh: make sync-template TEMPLATE=/path/to/web_template
Known upstream issues reviewed 2026-10-06 (web_template#1): none affect the
classes DevDesk uses (header, frame, section-header, btn, status, chip, badge,
dot, ws-status, progress, kv-grid, footer-hints).
base.js is not shipped because its global keydown handler swallows Escape
(Claude Code's interrupt key) and Ctrl+K, and every lt.toast plays a beep.
+5817
View File
File diff suppressed because it is too large Load Diff
+75
View File
@@ -0,0 +1,75 @@
// Builds dist/index.html: one self-contained page (ttyd --index serves a single
// file and nothing else), with CSS, JS, fonts and the favicon all inlined.
import { build, transform } from 'esbuild';
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const at = (...parts) => join(root, ...parts);
const read = (...parts) => readFile(at(...parts), 'utf8');
// Version: $DEVDESK_VERSION (CI) or the top entry of debian/changelog.
async function version() {
if (process.env.DEVDESK_VERSION) return process.env.DEVDESK_VERSION;
const changelog = await read('debian', 'changelog');
return /^devdesk \(([^)]+)\)/.exec(changelog)?.[1] ?? 'dev';
}
// JetBrains Mono (OFL-1.1) from @fontsource: keep the latin + latin-ext faces
// of the given weights and inline their woff2 files.
async function fontFaces(weights) {
const pkg = at('node_modules', '@fontsource', 'jetbrains-mono');
const faces = [];
for (const weight of weights) {
const css = await readFile(join(pkg, `${weight}.css`), 'utf8');
for (const block of css.match(/\/\* jetbrains-mono-latin(-ext)?-\d+-normal \*\/\s*@font-face\s*{[^}]*}/g) ?? []) {
const file = /url\(\.\/files\/([^)]+\.woff2)\)/.exec(block)[1];
const data = (await readFile(join(pkg, 'files', file))).toString('base64');
faces.push(block.replace(/src:[^;]*;/, `src: url(data:font/woff2;base64,${data}) format('woff2');`));
}
}
if (!faces.length) throw new Error('no JetBrains Mono faces found');
return faces.join('\n');
}
async function main() {
const ver = await version();
const js = await build({
entryPoints: [at('web', 'src', 'main.js')],
bundle: true,
minify: true,
format: 'iife',
target: ['es2020'],
write: false,
legalComments: 'eof',
define: { __DEVDESK_VERSION__: JSON.stringify(ver) },
});
const css = await transform([
await read('vendor', 'web_template', 'base.css'),
await read('node_modules', '@xterm', 'xterm', 'css', 'xterm.css'),
await fontFaces([400, 700]),
await read('web', 'styles.css'),
].join('\n'), { loader: 'css', minify: true, legalComments: 'eof' });
const { FAVICON: favicon } = await import('./src/icon.js');
const script = js.outputFiles[0].text.replace(/<\/script/gi, '<\\/script');
const style = css.code.replace(/<\/style/gi, '<\\/style');
const template = await read('web', 'index.template.html');
const html = template
.replace('__FAVICON__', () => favicon)
.replace('<!--STYLES-->', () => `<style>${style}</style>`)
.replace('<!--SCRIPT-->', () => `<script>${script}</script>`);
await mkdir(at('dist'), { recursive: true });
await writeFile(at('dist', 'index.html'), html);
console.log(`dist/index.html ${(html.length / 1024).toFixed(0)} KiB (devdesk ${ver})`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+137
View File
@@ -0,0 +1,137 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark">
<meta name="theme-color" content="#030508">
<meta name="generator" content="devdesk">
<title>DevDesk</title>
<link rel="icon" id="favicon" href="__FAVICON__">
<!--STYLES-->
</head>
<body class="dd-app">
<header class="lt-header dd-header">
<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>
<span class="dd-pane-title" id="pane-title"></span>
</div>
</div>
<div class="lt-header-right">
<span class="lt-ws-status" id="ws-status" data-state="connecting" role="status">
<span class="lt-dot"></span><span id="ws-label">connecting</span>
</span>
<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>
</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="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>
<div class="lt-section-body">
<p id="overlay-text" class="dd-overlay-text"></p>
<div class="lt-btn-group" id="overlay-actions"></div>
</div>
</div>
</div>
</section>
<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>
</div>
<div class="lt-section-body">
<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>
</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>
<div class="lt-section-body">
<div class="lt-kv-grid">
<span class="lt-kv-key">Repo</span><span class="lt-kv-val" data-field="repo">—</span>
<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">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">Grafana alerts and GANDALF status, 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>
</div>
</section>
</aside>
</main>
<footer class="lt-footer dd-footer">
<div class="lt-footer-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>
<span class="lt-footer-sep">│</span>
<span class="lt-footer-hint"><span class="lt-footer-key">^b 0/1</span>claude/shell</span>
<span class="lt-footer-sep">│</span>
<span class="lt-footer-hint"><span class="lt-footer-key">^b s</span>switch</span>
<span class="lt-footer-sep">│</span>
<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>
</div>
<span class="dd-muted" id="version">devdesk</span>
</footer>
<!--SCRIPT-->
</body>
</html>
+135
View File
@@ -0,0 +1,135 @@
import {
Server, LIST_ARG, decodeServer, encodeBinaryInput, encodeCommand, encodeInit,
encodeInput, encodeResize, parseListOutput, tokenUrl, wsUrl, FlowControl,
} from './protocol.js';
export class AuthExpiredError extends Error {}
export class UnauthorizedError extends Error {}
// GET /token. Through NPM this also exercises the Authelia gate, so it doubles
// as the session probe: a redirect means the SSO session expired.
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.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}`);
const body = await res.json();
return body.token || '';
}
// One WebSocket to ttyd, i.e. one `devdesk attach <arg>` process on the server.
export class TtydConnection {
constructor({ arg, size, onOpen, onOutput, onClose, onPreferences }) {
Object.assign(this, { arg, size, onOpen, onOutput, onClose, onPreferences });
this.flow = new FlowControl();
this.socket = null;
this.disposed = false;
}
async open() {
const token = await fetchToken();
if (this.disposed) return;
const socket = new WebSocket(wsUrl(window.location, this.arg), ['tty']);
socket.binaryType = 'arraybuffer';
this.socket = socket;
socket.onopen = () => {
const { cols, rows } = this.size();
socket.send(encodeInit(token, cols, rows));
this.onOpen?.();
};
socket.onmessage = (event) => {
const { type, payload } = decodeServer(event.data);
if (type === Server.OUTPUT) this.onOutput?.(payload);
else if (type === Server.SET_PREFERENCES) this.onPreferences?.(payload);
// SET_WINDOW_TITLE only carries ttyd's "<command> (<host>)"; real titles come via OSC.
};
socket.onclose = (event) => {
if (this.socket === socket) this.socket = null;
if (!this.disposed) this.onClose?.(event.code, event.reason);
};
}
get isOpen() {
return this.socket?.readyState === WebSocket.OPEN;
}
send(frame) {
if (this.isOpen) this.socket.send(frame);
}
sendInput(text) {
this.send(encodeInput(text));
}
sendBinary(binary) {
this.send(encodeBinaryInput(binary));
}
resize(cols, rows) {
this.send(encodeResize(cols, rows));
}
// Called with each output chunk; returns a write-callback when tracking is needed.
flowWrite(length) {
const { command, track } = this.flow.onData(length);
if (command) this.send(encodeCommand(command));
if (!track) return undefined;
return () => {
const resume = this.flow.release();
if (resume) this.send(encodeCommand(resume));
};
}
dispose() {
this.disposed = true;
if (this.socket) {
this.socket.onclose = null;
this.socket.close(1000);
this.socket = null;
}
}
}
// Ask the server for sessions + projects: a short-lived `devdesk attach --list`.
export async function fetchSessionList(timeoutMs = 8000) {
const token = await fetchToken();
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl(window.location, LIST_ARG), ['tty']);
socket.binaryType = 'arraybuffer';
const chunks = [];
const timer = setTimeout(() => {
socket.close();
reject(new Error('session list timed out'));
}, timeoutMs);
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => {
const { type, payload } = decodeServer(event.data);
if (type === Server.OUTPUT) chunks.push(payload.slice());
};
socket.onerror = () => {};
socket.onclose = () => {
clearTimeout(timer);
try {
const text = new TextDecoder().decode(concat(chunks));
resolve(parseListOutput(text));
} catch (err) {
reject(err);
}
};
});
}
function concat(chunks) {
const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.length;
}
return out;
}
+12
View File
@@ -0,0 +1,12 @@
// The DevDesk mark: an orange TDS diamond; the badge variant flags unread bells.
const svg = (badge) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="10" fill="#030508"/>
<path d="M32 6 58 32 32 58 6 32Z" fill="none" stroke="#FF6B00" stroke-width="5"/>
<path d="M32 20 44 32 32 44 20 32Z" fill="#00D4FF"/>
${badge ? '<circle cx="50" cy="14" r="11" fill="#00FF88" stroke="#030508" stroke-width="3"/>' : ''}
</svg>`;
const dataUri = (text) => `data:image/svg+xml,${encodeURIComponent(text)}`;
export const FAVICON = dataUri(svg(false));
export const FAVICON_BADGE = dataUri(svg(true));
+366
View File
@@ -0,0 +1,366 @@
/* 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 { bindAskClaude } from './widgets.js';
import * as notify from './notify.js';
const $ = (id) => document.getElementById(id);
const store = {
get(key, fallback) {
try {
const value = localStorage.getItem(`devdesk.${key}`);
return value === null ? fallback : value;
} catch {
return fallback;
}
},
set(key, value) {
try {
localStorage.setItem(`devdesk.${key}`, String(value));
} catch {
// Private mode: preferences just won't persist.
}
},
};
const ui = {
body: document.body,
wsStatus: $('ws-status'),
wsLabel: $('ws-label'),
sso: $('sso-badge'),
sessionChip: $('session-chip'),
paneTitle: $('pane-title'),
overlay: $('overlay'),
overlayTitle: $('overlay-title'),
overlayText: $('overlay-text'),
overlayActions: $('overlay-actions'),
notifyButton: $('notify-toggle'),
sidebarButton: $('sidebar-toggle'),
};
const state = {
session: sessionFromSearch(window.location.search), // null = picker
conn: null,
attempts: 0,
retryTimer: null,
openedAt: 0,
lastActivity: Date.now(),
};
let term;
let fit;
let sessionsPanel;
// ---------------------------------------------------------------- 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';
}
function showOverlay({ title, text, actions = [], tone = 'info' }) {
ui.overlay.hidden = false;
ui.overlay.dataset.tone = tone;
ui.overlayTitle.textContent = title;
ui.overlayText.textContent = text;
ui.overlayActions.replaceChildren(...actions.map(({ label, run, primary }) => {
const button = document.createElement('button');
button.type = 'button';
button.className = `lt-btn lt-btn-sm${primary ? ' lt-btn-primary' : ''}`;
button.textContent = label;
button.addEventListener('click', run);
return button;
}));
}
function hideOverlay() {
ui.overlay.hidden = true;
}
function updateUrl(session) {
const url = new URL(window.location.href);
if (session) url.searchParams.set('arg', session);
else url.searchParams.delete('arg');
if (url.href !== window.location.href) window.history.replaceState(null, '', url);
}
// ---------------------------------------------------------------- connection
function connect(session) {
clearTimeout(state.retryTimer);
state.conn?.dispose();
state.session = session;
updateUrl(session);
sessionsPanel?.setCurrent(session);
ui.sessionChip.textContent = session || 'picker';
hideOverlay();
setWsState('connecting', 'connecting');
state.openedAt = 0;
term.reset();
const conn = new TtydConnection({
arg: 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);
fit.fit();
term.focus();
},
onOutput: (bytes) => term.write(bytes, conn.flowWrite(bytes.length)),
onClose: (code) => onClosed(code),
});
state.conn = conn;
conn.open().catch(onFailure);
}
const pickerAction = { label: 'Sessions…', run: () => connect(null) };
function onClosed(code) {
setWsState('disconnected', 'offline');
// 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
// printed in the terminal), so don't retry forever.
const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000;
if (failedFast) {
showOverlay({
title: 'Session did not start',
text: `Could not open "${state.session || 'picker'}". The reason is shown in the terminal behind this message.`,
actions: [{ label: 'Retry', run: () => connect(state.session) }, pickerAction],
tone: 'warn',
});
return;
}
// 1000: the attach process exited normally (detached, session ended, picker
// closed). Anything else is the network or ttyd going away: reconnect.
if (code === 1000) {
const actions = state.session
? [{ label: `Reattach ${state.session}`, primary: true, run: () => connect(state.session) }, pickerAction]
: [{ label: 'Open picker', primary: true, run: () => connect(null) }];
showOverlay({
title: 'Detached',
text: 'The terminal closed. Any session you left keeps running on the server.',
actions,
});
return;
}
scheduleReconnect();
}
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() }],
tone: 'warn',
});
return;
}
if (error instanceof UnauthorizedError) {
setWsState('disconnected', 'denied');
showOverlay({
title: 'Not authorized',
text: 'ttyd rejected the request. Open DevDesk through https://dev.lotusguild.org, where the proxy adds the credential.',
actions: [{ label: 'Retry', run: () => connect(state.session) }],
tone: 'danger',
});
return;
}
scheduleReconnect();
}
function scheduleReconnect() {
clearTimeout(state.retryTimer);
state.attempts += 1;
const delay = Math.min(30000, 1000 * 1.6 ** (state.attempts - 1)) * (0.8 + Math.random() * 0.4);
const seconds = Math.ceil(delay / 1000);
setWsState('connecting', `retry ${seconds}s`);
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],
});
state.retryTimer = setTimeout(() => connect(state.session), delay);
}
function retrySoon() {
if (state.retryTimer && !state.conn?.isOpen) connect(state.session);
}
// ---------------------------------------------------------------- titles & bell
let titleFrame = 0;
let pendingTitle = '';
function applyTitle() {
titleFrame = 0;
const parsed = parseTitle(pendingTitle);
if (!parsed) {
ui.paneTitle.textContent = pendingTitle;
return;
}
if (parsed.session === 'picker') {
ui.sessionChip.textContent = 'picker';
ui.paneTitle.textContent = 'choose a session';
notify.setBaseTitle('DevDesk · picker');
return;
}
if (parsed.session && parsed.session !== state.session) {
// Chosen in the picker or switched inside tmux: follow it.
state.session = parsed.session;
updateUrl(parsed.session);
rememberSession(parsed.session);
sessionsPanel.setCurrent(parsed.session);
}
ui.sessionChip.textContent = parsed.session;
ui.paneTitle.textContent = parsed.pane;
notify.setBaseTitle(`${parsed.session} · DevDesk`);
}
// ---------------------------------------------------------------- chrome controls
function setFontSize(size) {
const clamped = Math.min(24, Math.max(10, size));
term.options.fontSize = clamped;
store.set('fontSize', clamped);
fit.fit();
}
function renderNotifyButton() {
const labels = { granted: 'Alerts on', denied: 'Alerts blocked', default: 'Alerts off', unsupported: 'No alerts' };
const current = notify.notificationsState();
ui.notifyButton.textContent = labels[current];
ui.notifyButton.dataset.state = current;
}
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();
}
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();
renderNotifyButton();
});
renderNotifyButton();
$('fullscreen-toggle').addEventListener('click', async () => {
if (document.fullscreenElement) {
await document.exitFullscreen();
return;
}
await document.documentElement.requestFullscreen();
// 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();
});
// Ctrl+W closes the tab before the terminal sees it; ask first.
window.addEventListener('beforeunload', (event) => {
if (state.conn?.isOpen && state.session) {
event.preventDefault();
event.returnValue = '';
}
});
for (const type of ['keydown', 'pointerdown']) {
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 () => {
if (Date.now() - state.lastActivity > 10 * 60 * 1000) return;
try {
await fetchToken();
setSso('ok');
} catch (error) {
if (error instanceof AuthExpiredError) setSso('expired');
}
}, 3 * 60 * 1000);
window.addEventListener('online', retrySoon);
document.addEventListener('visibilitychange', () => {
if (!document.hidden) retrySoon();
});
setInterval(() => {
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) sessionsPanel.refresh();
}, 30 * 1000);
}
// ---------------------------------------------------------------- boot
async function boot() {
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
await loadFonts(fontSize);
({ term, fit } = createTerminal($('terminal'), {
fontSize,
onShiftEnter: () => state.conn?.sendInput('\n'),
}));
term.onData((data) => state.conn?.sendInput(data));
term.onBinary((data) => state.conn?.sendBinary(data));
term.onResize(({ cols, rows }) => state.conn?.resize(cols, rows));
term.onTitleChange((title) => {
pendingTitle = title;
if (!titleFrame) titleFrame = requestAnimationFrame(applyTitle);
});
term.onBell(() => notify.ring(state.session, ui.paneTitle.textContent));
let fitFrame = 0;
new ResizeObserver(() => {
if (!fitFrame) {
fitFrame = requestAnimationFrame(() => {
fitFrame = 0;
fit.fit();
});
}
}).observe($('terminal'));
fit.fit();
sessionsPanel = new SessionsPanel({
list: $('sessions-list'),
select: $('project-select'),
openButton: $('project-open'),
refreshButton: $('sessions-refresh'),
pickerButton: $('open-picker'),
status: $('sessions-status'),
onOpen: (name) => connect(name),
onPicker: () => connect(null),
});
bindAskClaude($('sidebar'), (text) => {
state.conn?.sendInput(text);
term.focus();
});
wireChrome();
connect(state.session);
}
boot();
+60
View File
@@ -0,0 +1,60 @@
// Bell → attention: desktop notification, favicon badge and a title prefix while
// the tab is in the background. Claude rings the bell (preferredNotifChannel:
// terminal_bell) when it finishes a turn or needs input.
import { FAVICON, FAVICON_BADGE } from './icon.js';
let unread = 0;
let baseTitle = document.title;
function attentive() {
return !document.hidden && document.hasFocus();
}
function render() {
document.title = unread ? `(${unread}) ${baseTitle}` : baseTitle;
const link = document.getElementById('favicon');
if (link) link.href = unread ? FAVICON_BADGE : FAVICON;
}
export function setBaseTitle(title) {
baseTitle = title;
render();
}
export function notificationsState() {
if (!('Notification' in window)) return 'unsupported';
return Notification.permission; // granted | denied | default
}
export async function requestNotifications() {
if (notificationsState() !== 'default') return notificationsState();
return Notification.requestPermission();
}
export function ring(session, detail) {
if (attentive()) return;
unread += 1;
render();
if (notificationsState() === 'granted') {
const note = new Notification(`Claude · ${session || 'devdesk'}`, {
body: detail || 'Claude needs your attention',
tag: `devdesk-${session}`,
renotify: true,
icon: FAVICON,
});
note.onclick = () => {
window.focus();
note.close();
};
}
}
function clear() {
if (!unread || !attentive()) return;
unread = 0;
render();
}
window.addEventListener('focus', clear);
document.addEventListener('visibilitychange', clear);
+106
View File
@@ -0,0 +1,106 @@
// ttyd 1.7.x WebSocket protocol (see ttyd src/server.h and src/protocol.c).
//
// Frames are binary. The first message is a bare JSON object; every later
// message starts with a one-character command.
export const Client = Object.freeze({ INPUT: '0', RESIZE: '1', PAUSE: '2', RESUME: '3' });
export const Server = Object.freeze({ OUTPUT: '0', SET_WINDOW_TITLE: '1', SET_PREFERENCES: '2' });
export const SESSION_RE = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
export const LIST_ARG = '--list';
const encoder = new TextEncoder();
export function encodeInit(token, columns, rows) {
return encoder.encode(JSON.stringify({ AuthToken: token, columns, rows }));
}
export function encodeInput(text) {
const body = encoder.encode(text);
const frame = new Uint8Array(body.length + 1);
frame[0] = Client.INPUT.charCodeAt(0);
frame.set(body, 1);
return frame;
}
// xterm.js onBinary delivers a "binary string" (one char per byte).
export function encodeBinaryInput(binary) {
const frame = new Uint8Array(binary.length + 1);
frame[0] = Client.INPUT.charCodeAt(0);
for (let i = 0; i < binary.length; i++) frame[i + 1] = binary.charCodeAt(i) & 0xff;
return frame;
}
export function encodeResize(columns, rows) {
return encoder.encode(Client.RESIZE + JSON.stringify({ columns, rows }));
}
export function encodeCommand(command) {
return encoder.encode(command);
}
export function decodeServer(data) {
const bytes = new Uint8Array(data);
return { type: String.fromCharCode(bytes[0]), payload: bytes.subarray(1) };
}
// URLs are relative to the page so DevDesk also works under a path prefix.
function basePath(location) {
return location.pathname.replace(/[^/]*$/, '');
}
export function tokenUrl(location) {
return `${location.protocol}//${location.host}${basePath(location)}token`;
}
export function wsUrl(location, arg) {
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
const query = arg ? `?arg=${encodeURIComponent(arg)}` : '';
return `${scheme}//${location.host}${basePath(location)}ws${query}`;
}
export function sessionFromSearch(search) {
const value = new URLSearchParams(search).get('arg');
return value && SESSION_RE.test(value) ? value : null;
}
// tmux set-titles-string is "devdesk:#S|#T"; the picker prints "devdesk:picker|…".
export function parseTitle(title) {
const match = /^devdesk:([^|]*)\|([\s\S]*)$/.exec(title || '');
if (!match) return null;
return { session: match[1], pane: match[2] };
}
// 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 start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start < 0 || end < start) throw new Error('no session list in output');
return JSON.parse(text.slice(start, end + 1));
}
// Same back-pressure scheme as ttyd's own client: pause the pty when xterm.js
// falls behind on rendering, resume once it catches up.
export class FlowControl {
constructor({ limit = 100000, highWater = 10, lowWater = 4 } = {}) {
Object.assign(this, { limit, highWater, lowWater, written: 0, pending: 0 });
}
// Returns { command, track }: command is PAUSE when the writer should stop;
// track says whether to pass a callback to term.write (and call release()).
onData(length) {
this.written += length;
if (this.written <= this.limit) return { command: null, track: false };
this.written = 0;
this.pending += 1;
return { command: this.pending > this.highWater ? Client.PAUSE : null, track: true };
}
release() {
this.pending = Math.max(this.pending - 1, 0);
return this.pending < this.lowWater ? Client.RESUME : null;
}
}
+118
View File
@@ -0,0 +1,118 @@
// Sessions panel: server state (via `devdesk attach --list`) plus recently used
// sessions remembered in this browser.
import { fetchSessionList } from './connection.js';
const RECENTS_KEY = 'devdesk.recents';
const MAX_RECENTS = 12;
function readRecents() {
try {
const value = JSON.parse(localStorage.getItem(RECENTS_KEY) || '[]');
return Array.isArray(value) ? value : [];
} catch {
return [];
}
}
export function rememberSession(name) {
if (!name) return;
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) {
if (!epochSeconds) return '';
const s = Math.max(0, Date.now() / 1000 - epochSeconds);
if (s < 90) return 'now';
if (s < 3600) return `${Math.floor(s / 60)}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
return `${Math.floor(s / 86400)}d`;
}
function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value);
}
node.append(...children.filter((c) => c !== null && c !== undefined));
return node;
}
export class SessionsPanel {
constructor({ list, select, openButton, refreshButton, pickerButton, status, onOpen, onPicker }) {
Object.assign(this, { list, select, status, onOpen });
this.current = null;
this.data = null;
refreshButton.addEventListener('click', () => this.refresh());
pickerButton.addEventListener('click', () => onPicker());
openButton.addEventListener('click', () => {
if (select.value) onOpen(select.value);
});
}
setCurrent(name) {
this.current = name;
if (this.data) this.render();
}
async refresh() {
this.status.textContent = 'refreshing…';
try {
this.data = await fetchSessionList();
this.status.textContent = '';
} catch (err) {
this.status.textContent = `list unavailable: ${err.message}`;
}
this.render();
}
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`,
}, '↗')));
}
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) },
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.'));
}
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));
}
}
+102
View File
@@ -0,0 +1,102 @@
import { Terminal } from '@xterm/xterm';
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';
// LotusGuild TDS tokens (web_template base.css :root) mapped onto the ANSI palette.
export const THEME = Object.freeze({
background: '#010304',
foreground: '#c4d9ee',
cursor: '#FF6B00',
cursorAccent: '#010304',
selectionBackground: 'rgba(0, 212, 255, 0.30)',
selectionInactiveBackground: 'rgba(0, 212, 255, 0.15)',
black: '#0d1520',
red: '#FF2D55',
green: '#00FF88',
yellow: '#FFB300',
blue: '#3A8DFF',
magenta: '#BF5FFF',
cyan: '#00D4FF',
white: '#c4d9ee',
brightBlack: '#3e607a',
brightRed: '#FF5C7A',
brightGreen: '#33FFAA',
brightYellow: '#FFD700',
brightBlue: '#6AA8FF',
brightMagenta: '#D08CFF',
brightCyan: '#33DFFF',
brightWhite: '#FFFFFF',
});
export const FONT_FAMILY = '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace';
export async function loadFonts(size) {
if (!document.fonts?.load) return;
try {
await Promise.all([
document.fonts.load(`${size}px "JetBrains Mono"`),
document.fonts.load(`bold ${size}px "JetBrains Mono"`),
]);
} catch {
// Fall back to whatever monospace font is available.
}
}
export function createTerminal(container, { fontSize, onShiftEnter }) {
const term = new Terminal({
fontFamily: FONT_FAMILY,
fontSize,
lineHeight: 1.12,
cursorBlink: true,
scrollback: 2000, // tmux keeps the real history
allowProposedApi: true, // unicode11
macOptionIsMeta: true,
drawBoldTextInBrightColors: false,
theme: THEME,
});
const fit = new FitAddon();
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 WebLinksAddon((event, uri) => {
event.preventDefault();
window.open(uri, '_blank', 'noopener');
}));
term.attachCustomKeyEventHandler((event) => {
// Shift+Enter: newline in Claude's prompt (Ctrl+J), not submit.
if (event.key === 'Enter' && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {
if (event.type === 'keydown') onShiftEnter();
return false;
}
// Ctrl+Shift+C / Ctrl+Shift+V: browser clipboard, like desktop terminals.
if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && !event.altKey) {
if (event.code === 'KeyC' && term.hasSelection()) {
navigator.clipboard?.writeText(term.getSelection()).catch(() => {});
return false;
}
if (event.code === 'KeyV') {
navigator.clipboard?.readText().then((text) => text && term.paste(text)).catch(() => {});
return false;
}
}
return true;
});
term.open(container);
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose());
term.loadAddon(webgl);
} catch {
// DOM renderer fallback.
}
return { term, fit };
}
+12
View File
@@ -0,0 +1,12 @@
// 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.
export function bindAskClaude(root, send) {
root.addEventListener('click', (event) => {
const button = event.target.closest('[data-ask-claude]');
if (!button || button.disabled) return;
send(button.dataset.askClaude);
});
}
+331
View File
@@ -0,0 +1,331 @@
/* DevDesk layer on top of web_template base.css. App classes use the dd- prefix;
everything visual comes from the TDS tokens defined in base.css :root. */
:root {
--dd-footer-height: 30px;
--dd-sidebar-width: 320px;
--dd-gap: 8px;
}
html,
body.dd-app {
height: 100%;
overflow: hidden;
}
body.dd-app {
min-height: 0;
}
/* The CRT scanline + vignette overlays sit at z 9999 over the whole page. Keep
them off the terminal (readability) and give the chrome its own scanlines. */
body.dd-app::before,
body.dd-app::after {
display: none;
}
.dd-sidebar,
.dd-footer {
background-image: repeating-linear-gradient(
0deg, rgba(0, 0, 0, 0.07) 0, rgba(0, 0, 0, 0.07) 1px, transparent 1px, transparent 3px);
}
/* ---- header ---------------------------------------------------------------- */
.dd-header {
padding: 0 var(--space-md);
gap: var(--space-md);
}
.dd-header .lt-header-left {
min-width: 0;
gap: var(--space-lg);
}
.dd-session {
display: flex;
align-items: center;
gap: var(--space-sm);
min-width: 0;
}
.dd-pane-title {
color: var(--text-secondary);
font-size: 0.72rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 38vw;
}
.dd-sso {
cursor: default;
background: transparent;
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);
cursor: pointer;
}
.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); }
#sidebar-toggle[aria-expanded="true"] {
color: var(--accent-orange);
border-color: var(--accent-orange-border);
}
/* ---- layout ---------------------------------------------------------------- */
.dd-main {
position: fixed;
top: var(--header-height);
bottom: var(--dd-footer-height);
left: 0;
right: 0;
display: flex;
gap: var(--dd-gap);
padding: var(--dd-gap);
}
.dd-terminal-frame {
flex: 1 1 auto;
min-width: 0;
margin: 0;
display: flex;
background: var(--bg-terminal);
padding: 6px 6px 10px 10px;
}
.dd-terminal {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
}
.dd-terminal .xterm {
height: 100%;
}
.dd-terminal .xterm-viewport {
background-color: var(--bg-terminal) !important;
}
.dd-sidebar {
display: none;
flex: 0 0 var(--dd-sidebar-width);
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: thin;
scrollbar-color: var(--bg-tertiary) transparent;
}
body.dd-sidebar-open .dd-sidebar {
display: block;
}
.dd-widget {
margin-bottom: var(--dd-gap);
}
.dd-widget .lt-section-header {
justify-content: flex-start;
}
.dd-soon {
margin-left: auto;
color: var(--text-muted);
letter-spacing: 0.12em;
}
.dd-header-action {
margin-left: auto;
background: none;
border: 0;
color: var(--accent-cyan);
font-size: 0.85rem;
line-height: 1;
padding: 0 2px;
}
.dd-header-action:hover { color: var(--accent-orange); text-shadow: var(--glow-orange); }
.dd-muted {
color: var(--text-muted);
font-size: 0.68rem;
}
.dd-stub-note {
margin-top: var(--space-sm);
}
/* ---- sessions panel ---------------------------------------------------------- */
.dd-session-list {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: var(--space-sm);
}
.dd-session-item {
display: flex;
align-items: stretch;
border: 1px solid transparent;
}
.dd-session-item.is-current {
border-color: var(--accent-orange-border);
background: var(--accent-orange-dim);
}
.dd-session-open {
flex: 1 1 auto;
min-width: 0;
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
column-gap: var(--space-sm);
align-items: center;
padding: 4px 6px;
background: none;
border: 0;
color: var(--text-primary);
text-align: left;
}
.dd-session-open:hover { background: var(--accent-cyan-dim); }
.dd-session-open .lt-dot { grid-row: 1 / span 2; }
.dd-session-name {
font-size: 0.78rem;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dd-session-meta {
font-size: 0.62rem;
color: var(--text-muted);
}
.dd-session-item.is-recent .dd-session-name { color: var(--text-secondary); font-weight: 400; }
.dd-session-tab {
display: flex;
align-items: center;
padding: 0 8px;
color: var(--text-muted);
}
.dd-session-tab:hover { color: var(--accent-orange); }
.dd-empty {
color: var(--text-muted);
font-size: 0.72rem;
padding: 4px 6px;
}
.dd-new-session {
display: flex;
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-widget-foot {
display: flex;
align-items: center;
gap: var(--space-sm);
}
/* ---- stub widgets ------------------------------------------------------------ */
.dd-alert {
display: flex;
align-items: center;
gap: var(--space-sm);
font-size: 0.72rem;
color: var(--text-secondary);
}
.dd-alert-text { flex: 1 1 auto; }
.dd-context-grid { margin-top: var(--space-sm); }
[data-state="stub"] .lt-section-body { opacity: 0.75; }
/* ---- overlay ----------------------------------------------------------------- */
.dd-overlay {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
align-items: center;
justify-content: center;
background: rgba(1, 3, 4, 0.72);
backdrop-filter: blur(2px);
}
.dd-overlay[hidden] { display: none; }
.dd-overlay-card {
width: min(480px, 92%);
margin: 0;
box-shadow: var(--box-glow-cyan);
}
.dd-overlay-text {
margin-bottom: var(--space-md);
color: var(--text-secondary);
}
.dd-overlay[data-tone="warn"] .lt-section-header { color: var(--accent-amber); text-shadow: none; }
.dd-overlay[data-tone="danger"] .lt-section-header { color: var(--accent-red); text-shadow: var(--glow-red); }
/* ---- footer ------------------------------------------------------------------ */
.dd-footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
height: var(--dd-footer-height);
margin: 0;
padding: 0 var(--space-md);
flex-wrap: nowrap;
overflow: hidden;
}
.dd-footer .lt-footer-hints {
flex-wrap: nowrap;
overflow: hidden;
}
.dd-footer .lt-footer-key { margin-right: 4px; }
/* ---- small screens ----------------------------------------------------------- */
@media (max-width: 1099px) {
.dd-sidebar {
position: absolute;
top: var(--dd-gap);
right: var(--dd-gap);
bottom: var(--dd-gap);
width: min(var(--dd-sidebar-width), calc(100vw - 2 * var(--dd-gap)));
z-index: 20;
background-color: var(--bg-secondary);
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.6);
padding: var(--dd-gap);
}
.dd-pane-title { display: none; }
}
@media (max-width: 719px) {
:root { --dd-gap: 4px; }
.lt-brand-subtitle,
#font-down,
#font-up,
#fullscreen-toggle,
.dd-sso { display: none; }
.dd-header .lt-header-left { gap: var(--space-sm); }
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 5),
.dd-footer .lt-footer-sep:nth-child(n + 4) { display: none; }
}