feat: DevDesk v0.1.0, a persistent browser terminal for Claude Code
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:
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)"
|
||||
@@ -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
@@ -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
|
||||
Vendored
+7
@@ -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
|
||||
Vendored
+24
@@ -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.
|
||||
Vendored
+166
@@ -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.
|
||||
Vendored
+29
@@ -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
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
README.md
|
||||
docs/DEPLOY.md
|
||||
docs/npm-advanced.conf
|
||||
Vendored
+4
@@ -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]
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
docs/devdesk.1
|
||||
Vendored
+22
@@ -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
|
||||
Vendored
+11
@@ -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
|
||||
Vendored
+19
@@ -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
|
||||
+19
@@ -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
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
3.0 (native)
|
||||
@@ -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) |
|
||||
@@ -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)
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
Executable
+90
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
Executable
+64
@@ -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
|
||||
Executable
+25
@@ -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
|
||||
Executable
+19
@@ -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
|
||||
Executable
+12
@@ -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
|
||||
Generated
+1811
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
Executable
+26
@@ -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"
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"preferredNotifChannel": "terminal_bell",
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "/usr/lib/devdesk/devdesk-session-hook",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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" ]
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
Vendored
+11
@@ -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
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
1.7.7
|
||||
Vendored
+21
@@ -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.
|
||||
Vendored
+11
@@ -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.
|
||||
Vendored
+5817
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user