Compare commits

...
33 Commits
Author SHA1 Message Date
jaredandClaude Opus 5.5 20134c1e58 feat: v0.12.0 Settings window, per-session Remote Control, delete conversations
build-deb / Lint, test, package (push) Successful in 4m59s
build-deb / Deploy to CT 126 (push) Successful in 14s
build-deb / Notify on failure (push) Skipped
- Settings (⋯ → Settings…, palette): theme, terminal text size, interface
  size, notifications, Remote Control & privacy. The ⋯ menu shrinks.
- Remote Control per session: registry choice > Settings default >
  DEVDESK_REMOTE_CONTROL. Switching an idle session restarts its Claude
  on the same conversation; busy sessions change at the next start.
  rc_active comes from the running process; RC tag on sessions.
- --delete-convo: remove a conversation's local transcript and subagent
  files; refused while in use. Delete… in Search past conversations.
Closes #61

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-11-03 21:23:29 -05:00
jaredandClaude Opus 5.5 65114165e7 feat: v0.11.0 VS Code-like explorer, markdown preview, search in files
build-deb / Lint, test, package (push) Successful in 2m29s
build-deb / Deploy to CT 126 (push) Successful in 7s
build-deb / Notify on failure (push) Skipped
- Explorer: context menus, multi-select, inline rename/new, drag to move
  or copy, desktop drag-and-drop uploads (folders too), cut/copy/paste,
  duplicate, download, delete to a 7-day trash with Undo. Server side in
  libexec/devdesk-fsops.sh with bats tests. Closes #54
- Markdown preview in a shadow root: headings with anchors and outline,
  highlighted code with copy, callouts, task lists, front matter, links
  that open files, live split view. Editor status bar, multi-cursor,
  closeBrackets, Ctrl+G, tab menus and reordering. Closes #55
- Search in files (Ctrl+Shift+F) via git grep / grep. Closes #56
- Fix: nested $(...) path checks now fail hard (set -e does not reach
  them; an escape created /x in testing); newest folder listing wins;
  sessionAction returns the reply JSON.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-22 21:25:43 -04:00
jaredandClaude Opus 5.5 e60f1ad066 fix: v0.10.4 session list scrolls on its own
build-deb / Lint, test, package (push) Successful in 3m13s
build-deb / Deploy to CT 126 (push) Successful in 13s
build-deb / Notify on failure (push) Skipped
No cap on sessions existed, but only the whole Panels column scrolled, so
many sessions pushed Ops Alerts and Repo/CI far down. The list now has its
own scroll area (46vh), keeps its position across polls and brings the
current session into view on switch. Closes #53

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-22 20:53:34 -04:00
jaredandClaude Opus 5.5 ab7e127f70 fix: v0.10.3 no tmux right-click menus; close split panes
build-deb / Deploy to CT 126 (push) Successful in 19s
build-deb / Lint, test, package (push) Successful in 3m9s
build-deb / Notify on failure (push) Skipped
Right-clicking a pane opened tmux's menu, whose Split entries left a stray
pane next to Claude (and whose Kill entries could end it). Unbind the
right-click menus; add --unsplit and a palette entry that closes extra
panes and keeps Claude's. Closes #52

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-13 01:48:04 -04:00
jaredandClaude Opus 5.5 24d71131e8 fix: v0.10.2 drag-select stays highlighted and copies
build-deb / Lint, test, package (push) Successful in 2m50s
build-deb / Deploy to CT 126 (push) Successful in 10s
build-deb / Notify on failure (push) Skipped
A left drag in the pane is turned into xterm's own selection (as Shift+drag
does) instead of tmux copy-mode, which dropped the highlight on release.
Copy on release with a toast; Ctrl+C copies when something is selected.
Wheel scroll and status-line clicks still reach tmux. Closes #51

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-12 16:35:26 -04:00
jaredandClaude Opus 5.5 3a0b099f02 fix: v0.10.1 usage ping uses tab presence; CI-safe test
build-deb / Deploy to CT 126 (push) Successful in 12s
build-deb / Lint, test, package (push) Successful in 3m7s
build-deb / Notify on failure (push) Skipped
The usage ping now treats "away" like the needs-you notifier: no DevDesk
tab in view for ~2 minutes. The test sets a presence marker instead of
attaching a tmux client, which never attached in CI. 0.10.0 was never
published.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-12 01:30:18 -04:00
jaredandClaude Opus 5.5 a2edec966a feat: v0.10.0 quieter Matrix, worktrees, usage pace, repo diffs, copy reply, turn timer, compact
build-deb / Notify on failure (push) Successful in 4s
build-deb / Lint, test, package (push) Failing after 2m22s
build-deb / Deploy to CT 126 (push) Skipped
- Matrix "needs you": ignore Claude's idle reminder (no red, no post); post
  only when no tab is in view, after 60 s, once per session per 10 min, and
  say what is asked. Closes #50
- devdesk new NAME PROJECT --worktree: own checkout on devdesk/NAME,
  cleaned up on end when clean/merged. Closes #44
- Usage meters: reset time, pace warning; Matrix ping past 90% when away.
  Closes #45
- Repo panel lists changed files; click opens the diff. Closes #46
- Copy Claude's last reply (--last-reply). Closes #47
- Turn timer in chip, title and cards. Closes #48
- Compact button at 80% context when idle. Closes #49

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WHRLCCdXYm41Qx5kNhehaP
2026-10-11 23:28:11 -04:00
jaredandClaude Opus 5.5 bf8821f3f9 feat: v0.9.5 context in tokens as well as percent
build-deb / Lint, test, package (push) Successful in 2m17s
build-deb / Deploy to CT 126 (push) Successful in 8s
build-deb / Notify on failure (push) Skipped
Claude's footer reads "ctx 34% 346k/1M"; the Sessions panel and the
session switcher use the same label via ctxLabel(). Closes #43

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

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

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

Closes #39

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

Refs #12

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

Closes #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 01:01:51 -04:00
jaredandClaude Opus 5.5 73e77aca73 feat: v0.9.0 conversation search, plan usage meters, quiet phone at the desk
build-deb / Lint, test, package (push) Successful in 3m6s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- devdesk attach --search (grep + jq over ~/.claude/projects, six files at
  a time) and --resume-convo NAME ID [fork]; devdesk-claude-run passes
  --fork-session once. Search dialog with Resume / Fork / Open.
- statusLine snapshots rate_limits and pr; footer/menu meters with a 90 %
  warning; PR chip on session rows.
- CLAUDE_CLIENT_PRESENCE_FILE per tmux session id; --presence on|off with a
  self-expiring marker; the page heartbeats it while visible.

Closes #24
Closes #25
Closes #26

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 21:45:26 -04:00
jaredandClaude Opus 5.5 f82c13612d feat: v0.8.2 clickable paths, alert sessions, ops guard and trail
build-deb / Lint, test, package (push) Successful in 2m45s
build-deb / Deploy to CT 126 (push) Successful in 5s
build-deb / Notify on failure (push) Skipped
- Terminal link provider: printed paths that are files of the session open
  in Files (at the line); the file list is preloaded so the first hover works.
- Alerts: "In a new session" opens alert-<name> and types the prompt once
  Claude is up.
- devdesk-guard PreToolUse(Bash) hook: permissionDecision "ask" for
  destructive commands (verified to prompt in auto mode); DEVDESK_GUARD and
  guard.local.
- State hook logs Bash commands and derives host tags (ssh targets, CT/VM
  ids); --list carries tags, --peek the last commands; UI shows both.

Closes #21
Closes #18
Closes #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:37:46 -04:00
jaredandClaude Opus 5.5 a92d10ace3 feat: v0.8.1 answer sessions from the inbox: allow/deny, peek & reply
build-deb / Lint, test, package (push) Successful in 2m4s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- PermissionRequest hook: the state file records tool, detail and
  description of a pending permission; Notification keeps it.
- devdesk attach --answer (presses 1/Esc only if the same wait is on and the
  prompt is visible), --peek (last exchange from the transcript), --reply
  (bracketed paste + Enter, streamed like uploads).
- Inbox: Allow/Deny/Open for permission waits; Peek & reply for other waits
  and finished sessions. Matrix messages include the command.

Closes #22
Closes #23

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:22:37 -04:00
jaredandClaude Opus 5.5 66321f4748 feat: v0.8.0 edit and save files with conflict detection
build-deb / Lint, test, package (push) Successful in 1m57s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- devdesk attach --fs-write SESSION PATH BYTES BASESHA|new: streamed like
  uploads, temp file in the same folder, sha re-check (conflict reply instead
  of overwriting), mode/owner kept, atomic mv; new files with folders, never
  outside the session folder or through a symlink out of it.
- Editor: editable CodeMirror for UTF-8 text, dirty tracking, Ctrl+S,
  Save/Revert, conflict dialog with a disk-vs-yours diff, changed-on-disk
  banner, drafts in localStorage (flushed on pagehide), close/leave guards,
  New file.
- Upload streaming factored into streamIn(), shared by uploads and saves.

Closes #20

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 20:10:45 -04:00
jaredandClaude Opus 5.5 927920c162 fix: old links never re-create ended sessions
build-deb / Lint, test, package (push) Successful in 1m56s
build-deb / Deploy to CT 126 (push) Successful in 24s
build-deb / Notify on failure (push) Skipped
Inbox entries, the switcher, palette switches, the attention chip and Matrix
links (?arg=NAME&existing=1) attach with --existing; an ended session gets an
explanation and an explicit "Start again". Inbox entries for ended sessions
can't resurrect them; entries expire after 7 days.

Closes #38

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 19:57:52 -04:00
jaredandClaude Opus 5.5 dee1843986 fix: go to file in big non-git folders; linear reply handling
build-deb / Lint, test, package (push) Successful in 3m0s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- find/git producers piped into head tolerate SIGPIPE, so hitting a cap no
  longer fails the command (and lets ttyd truncate its reply).
- fs-find outside git skips build/cache folders and caps at 10000.
- runCommand checks completion on the stream's tail instead of re-decoding
  the whole reply on every frame.

Refs #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:50:50 -04:00
jaredandClaude Opus 5.5 e345bf4640 feat: files pane like VS Code: tree, tabs, go to file
build-deb / Lint, test, package (push) Successful in 2m28s
build-deb / Deploy to CT 126 (push) Successful in 3s
build-deb / Notify on failure (push) Skipped
- Explorer tree with inline expand/collapse and keyboard navigation, git
  colours, change dots, dimmed gitignored entries (fs-list now flags them),
  file-type badges, resizable explorer.
- Editor tabs: preview tab semantics, instant switching with scroll kept,
  in-place reload when Claude changes an open file.
- Go to file: devdesk attach --fs-find (git ls-files or capped find) and a
  fuzzy-ranked palette (CommandPalette gained custom search/async lists).
- Per-session memory of folders and tabs; empty state with recents.

Closes #37

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:40:47 -04:00
jaredandClaude Opus 5.5 73f8525db2 feat: v0.7.0 files pane: browse, preview, diff, Claude's edits
build-deb / Lint, test, package (push) Successful in 2m38s
build-deb / Deploy to CT 126 (push) Successful in 5s
build-deb / Notify on failure (push) Skipped
- devdesk attach --fs-list/--fs-read/--git-status/--git-show/--asset
  (libexec/devdesk-files.sh), confined to the session folder.
- The state hook records files Claude edits (Edit/Write/MultiEdit/
  NotebookEdit) for the Changes tab's "C" badge; rename/end carry it.
- Files pane: folders, CodeMirror read-only view, sanitized markdown preview
  in a sandboxed iframe with inlined relative images, images, Changes tab with
  line counts and diff against HEAD, "→ Claude" @path, resizable split.
- The editor bundle ships as /usr/share/devdesk/assets/editor.js, is fetched
  over ttyd on first use, verified against the page's SHA-256 and cached in
  IndexedDB.
- Fix: the state hook's field split collapsed empty fields.

Closes #19

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 01:16:37 -04:00
jaredandClaude Opus 5.5 390ce61f5b feat: inbox bell for waiting sessions, alerts and missed events
build-deb / Lint, test, package (push) Successful in 3m7s
build-deb / Deploy to CT 126 (push) Successful in 2s
build-deb / Notify on failure (push) Skipped
Closes #36

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:51:01 -04:00
jaredandClaude Opus 5.5 6e109aec36 test: keep script's stdin open in the linger test (CI has no stdin)
build-deb / Lint, test, package (push) Successful in 2m10s
build-deb / Deploy to CT 126 (push) Successful in 2s
build-deb / Notify on failure (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:36:50 -04:00
jaredandClaude Opus 5.5 05057f695d feat: v0.6.0 light mode, command palette, dialogs, toasts
build-deb / Lint, test, package (push) Failing after 1m59s
build-deb / Deploy to CT 126 (push) Skipped
build-deb / Notify on failure (push) Successful in 5s
- Light mode with the house ☀/◐ toggle and lt_theme preference; light
  terminal palette; Claude runs with dark-ansi so its colours follow the
  palette; tmux and the statusLine use ANSI colours.
- Command palette (⌕, Ctrl+Shift+P) with session, terminal, view, link and
  help commands.
- .lt-modal dialogs for new/rename/end and a keyboard shortcuts sheet.
- .lt-toast results for actions, uploads, copy and CI.
- ttyd dropped the reply of fast one-shot commands; replies now wait for the
  browser to hang up, and the client hangs up as soon as it has one.
- Sessions panel re-fetches after an action instead of reusing a stale poll.

Closes #31
Closes #32
Closes #33
Closes #34
Closes #35

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 13:09:32 -04:00
jaredandClaude Opus 5.5 ae8b5d843f fix: scope hooks to DevDesk's tmux and reload env per Claude launch (0.2.1)
build-deb / Lint, test, package (push) Successful in 2m6s
build-deb / Notify on failure (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 12:54:28 -04:00
jaredandClaude Opus 5.5 527cb1fda2 feat: free-form sessions, rename/end from the panel, live Claude Context (0.2.0)
build-deb / Lint, test, package (push) Successful in 2m0s
build-deb / Notify on failure (push) Skipped
- Sessions no longer need a project folder: any name works and starts in
  DEVDESK_DEFAULT_DIR unless it matches a project; `devdesk new NAME [PROJECT]`.
- Rename (✎) and end/forget (×) sessions from the Sessions panel, plus
  `devdesk rename`. The browser runs them as `devdesk attach --new|--rename|--end`
  through ttyd. Renames keep the running Claude and its recorded conversation:
  the hook, the run-wrapper and the statusline ask tmux for the current name.
- Claude Context panel is live: devdesk-statusline snapshots model,
  context-window use, cost and edits per session, which --list reports;
  Claude's footer gets a one-line summary. The panel now sits under Sessions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 03:03:09 -04:00
58 changed files with 11778 additions and 437 deletions
+1 -1
View File
@@ -8,6 +8,6 @@
"no-undef": "error",
"no-empty": "warn",
"semi": ["error", "always"],
"eqeqeq": "error"
"eqeqeq": ["error", "always", { "null": "ignore" }]
}
}
+28 -1
View File
@@ -149,10 +149,37 @@ jobs:
"$api/releases/$id/assets?name=$name" >/dev/null
echo "release $TAG: $GITEA_URL/$REPO/releases/tag/$TAG"
deploy:
name: Deploy to CT 126
runs-on: ubuntu-latest
needs: [build]
if: startsWith(github.ref, 'refs/tags/v')
steps:
# CT 126's webhook listener runs /usr/lib/devdesk/devdesk-deploy, which
# waits for this version in the apt registry and upgrades the package.
# The hook answers when the install is done (HTTP 500 if it failed), so
# this job's result is the deploy's result.
- name: Trigger devdesk-deploy webhook
env:
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
GIT_REF: ${{ github.ref }}
run: |
PAYLOAD="{\"ref\":\"${GIT_REF}\"}"
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
code=$(curl -sS --connect-timeout 10 --max-time 900 -o /tmp/deploy.out -w '%{http_code}' \
-X POST \
-H "Content-Type: application/json" \
-H "X-Gitea-Signature: ${SIG}" \
-d "$PAYLOAD" \
"http://10.10.10.58:9000/hooks/devdesk-deploy") || code=000
cat /tmp/deploy.out 2>/dev/null || true
echo "webhook: HTTP $code"
[ "$code" = 200 ]
notify-failure:
name: Notify on failure
runs-on: ubuntu-latest
needs: [build]
needs: [build, deploy]
if: failure() && github.event_name == 'push'
steps:
- name: Send Matrix alert
+8 -3
View File
@@ -8,7 +8,7 @@ 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) \
WEB_SOURCES := web/build.mjs web/index.template.html web/styles.css $(wildcard web/src/*.js) $(wildcard web/editor/*.js) \
vendor/web_template/base.css debian/changelog
.PHONY: all lint test install clean sync-template
@@ -26,7 +26,7 @@ build/ttyd: vendor/ttyd/VERSION vendor/ttyd/SHA256SUMS scripts/fetch-ttyd.sh
@touch $@
lint: node_modules/.package-lock.json
npx eslint --max-warnings 0 --ext .js,.mjs web/src web/build.mjs tests/protocol.test.mjs
npx eslint --max-warnings 0 --ext .js,.mjs web/src web/editor web/build.mjs tests/protocol.test.mjs
shellcheck -x $(SHELL_SCRIPTS)
jq empty share/claude-settings.json
@@ -39,9 +39,14 @@ install: all
install -m 0755 bin/devdesk $(DESTDIR)$(PREFIX)/bin/devdesk
install -m 0644 libexec/devdesk-lib.sh $(DESTDIR)$(LIBDIR)/devdesk-lib.sh
install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \
libexec/devdesk-tmux-ready libexec/devdesk-serve $(DESTDIR)$(LIBDIR)/
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix libexec/devdesk-guard $(DESTDIR)$(LIBDIR)/
install -m 0644 libexec/devdesk-files.sh libexec/devdesk-fsops.sh libexec/devdesk-inbox.sh libexec/devdesk-history.sh libexec/devdesk-ops.sh \
$(DESTDIR)$(LIBDIR)/
install -m 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd
install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
install -d $(DESTDIR)$(SHAREDIR)/assets
install -m 0644 dist/assets/editor.js $(DESTDIR)$(SHAREDIR)/assets/editor.js
install -m 0644 etc/env $(DESTDIR)$(SYSCONFDIR)/devdesk/env
clean:
+64 -12
View File
@@ -22,12 +22,56 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
- **Only `systemctl stop devdesk-tmux` (or removing the package) ends the sessions.**
## Sessions
- **How the session is chosen:**
- `https://dev.lotusguild.org/?arg=<name>` opens session `<name>` and creates it on demand in `$DEVDESK_PROJECTS_ROOT/<name>`. `main` uses the root itself, and folders with spaces are reached by slug (`test code` → `test-code`).
- With no `?arg=`, an fzf picker opens, listing running (●), dormant (○) and new (+) sessions.
- **Any name works.** Sessions don't have to be repo work:
- A name matching a project folder (`tinker_tickets`, or `test-code` for `test code`) starts there.
- Anything else (`pbs-upgrade`, `ceph-check`) starts in `$DEVDESK_DEFAULT_DIR`, which is `/root/code` by default.
- `main` is the projects root.
- **Opening one:**
- `https://dev.lotusguild.org/?arg=<name>` opens or creates that session.
- With no `?arg=`, DevDesk reattaches the session you used last in this browser (or the most recently active one). The fzf picker, listing running (●), dormant (○) and new (+) sessions, is under the session chip's menu.
- **Session chip** (header): click to switch sessions; its dot shows Claude's state.
- **+ New** in the Sessions panel takes any name plus an optional project folder.
- **Own git worktree:** for a git project, tick **own git worktree** (or `devdesk new NAME PROJECT --worktree`). The session gets its own checkout in `$ROOT/.worktrees/PROJECT--NAME` on branch `devdesk/NAME`, so two Claudes on one repo never edit the same files. Ending it removes the worktree only if nothing is uncommitted, and the branch only if it's merged.
- **Rename / end:** the ✎ and × buttons on each session in the panel. Renaming keeps the session, its running Claude and its recorded conversation. Ending stops Claude; the conversation stays in Claude's history.
- **Windows:** each session has a `claude` window (0) and a `shell` window (1).
- **Conversation tracking:** a Claude `SessionStart` hook records each session's conversation ID in `/var/lib/devdesk/registry.json`. After a reboot the session resumes *that* conversation, even when other Claude instances, such as code-server, use the same directory.
- **SSH:** `devdesk attach <name>`, `devdesk ls`, `devdesk pick`, `devdesk end <name>`.
- **Session status:** Claude hooks (`devdesk-state-hook`) mark each session **working**, **waiting on you** (with Claude's message) or **idle**. Claude's idle reminder a minute after a turn ends doesn't count as waiting. While Claude works, the session chip and the tab title show how long the turn has run (`⏳ 3m`).
- **Session buttons:** ⧉ copies Claude's whole last reply as Markdown. ⇲ **Compact** appears when the context is 80 % full and Claude is idle, and sends `/compact`.
- The sidebar shows it with context % and a context bar; the session chip's dot shows the current session's state.
- A "… needs you" chip jumps to other sessions that are waiting, and a desktop notification fires when one starts waiting.
- The **🔔 inbox** lists what needs you (waiting sessions, firing Grafana alerts) and what happened while you were away (a background session finished or started waiting, CI passed or failed); its badge counts them.
- **Answer from the inbox:** a session waiting on a permission shows the exact command/file and **Allow / Deny** (the key is pressed only if that prompt is still on screen); other waits and finished sessions offer **Peek & reply**: the last exchange and a reply box.
- **Matrix "Claude needs you":** when a session has waited on a real request (a permission prompt or a question) for `DEVDESK_MATRIX_DELAY` seconds (60) and no DevDesk tab has been in view for about 2 minutes, `devdesk-notify-matrix` posts what Claude asks ("alpha: Claude wants to run Bash (Restart nginx)", the command, a link). The post goes to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off). Each session posts at most once per `DEVDESK_MATRIX_QUIET` seconds (600).
- **File & image drop:** drag files onto the terminal or paste a screenshot.
- They're uploaded through ttyd to `/var/lib/devdesk/uploads/<session>/` (25 MB max, pruned after 7 days), and the path is pasted into Claude.
- For images, Claude attaches them itself.
- **Ops Alerts panel:** firing and pending Grafana alert rules, each with a link to the rule and the Proxmox guest or node it's about, e.g. `→ CT 110 draupnir · monitor-02 · stopped`.
- **Ask Claude** starts Claude in a new session (`alert-<name>`) with the investigation already sent. The prompt has the rule, its query, summary and labels, and where it runs: the CT/VM id, the node it's on right now, and the `ssh <node> pct exec <id> --` line. Clicking it again just opens that session.
- Guests are matched by the alert's `instance` IP (or host name), else by name in the job or alert ("by name"). Aggregate rules with no labels (`count(up == 0)`) get a hint to run the inner query instead.
- **`devdesk guests [ID|IP|NAME]`** lists Proxmox CTs/VMs with the node each runs on now, status, HA state and IPs. Claude is told to use it before acting, because HA guests move. Stopped guests keep the IP they had when last seen running.
- Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`. For the guest lookup, set `DEVDESK_PVE_NODES` to a few Proxmox nodes reachable by root SSH key. VM IPs need the QEMU guest agent.
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. The first changed files are listed; click one for its diff against HEAD. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log.
- **Files** (Ctrl+Shift+E), laid out like VS Code:
- **Explorer tree:** git colours, gitignored entries dimmed.
- **Mouse:** right-click menus (new file/folder, rename, delete, cut/copy/paste, duplicate, copy path, download, @Claude), Ctrl/Shift+click to select several, drag to move (Ctrl+drag copies). Drop files or whole folders from the desktop to upload them (up to 25 MB each), or drag a file onto the terminal to type `@path`.
- **Keyboard:** F2 renames in place, Delete trashes, Ctrl+C/X/V copy and paste files.
- **Safety:** delete goes to a 7-day trash with **Undo**; nothing is overwritten unless you choose Replace.
- **Editor:** tabs (a click opens a preview tab, a double-click keeps it; right-click for close variants; drag to reorder), **Go to file** (Ctrl+P in the pane), and **Search in files** (Ctrl+Shift+F: match case, whole word, regex). A status bar shows line/column, cursors, indentation, line endings and language. Multiple cursors (Alt+click), column select (Alt+drag), auto-closing brackets, Ctrl+G to go to a line.
- **Markdown preview:** GitHub-style headings with anchors and an outline, syntax-coloured code with Copy, callouts, task lists, front matter, images, and links that open files in DevDesk. **Split** shows the source and a live preview side by side.
- **Changes view:** uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt.
- **Remembered and contained:** tabs and open folders are remembered per session, and paths can't leave the session folder.
- **Editing:** text files are editable; ● marks unsaved tabs; Ctrl+S (or Save) writes atomically and keeps the file's mode and owner. If the file changed on disk since you opened it (Claude edited it), saving asks: Compare (a diff of disk vs yours), Overwrite or Cancel; a change on disk while you edit shows a banner instead of replacing your text. Unsaved edits are kept as drafts in the browser, so a reload or a session switch doesn't lose them. + creates a file (folders as needed). The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
- **Paths are links:** a file path Claude prints (`web/src/files.js`, `notes.md:12`) opens in Files when clicked, at that line if given.
- **Ops guard:** `devdesk-guard` (a PreToolUse hook) makes Claude ask before destructive commands (`pct destroy`, `zfs destroy`, Ceph pool/OSD removal, `rm -r /…`, `git push --force`, `reboot`…), even in auto mode. `DEVDESK_GUARD=0` turns it off; `/etc/devdesk/guard.local` adds rules (`regex<TAB>reason`). Bash commands are logged per session, and sessions are tagged with the hosts they touch (ssh targets, CT/VM ids).
- **Search past conversations** (⋯ menu, palette): full text over every Claude conversation on the host; **Resume** it in a new session or **Fork** it.
- **Plan usage:** the 5-hour and weekly windows as meters in the footer, with when each resets (`↻ 14:30`). If the pace so far would use a window up before it resets, the meter shows `⚠ full ~13:50` instead. A toast warns at 90 %, and Matrix gets one message per window when you're away (`DEVDESK_USAGE_WARN`). Sessions also get a **PR** chip when their branch has a pull request.
- **Settings** (⋯ → Settings…): theme, terminal text size and interface size (per browser), desktop notifications, and **Remote Control per session**. With Remote Control on, a session also shows in the Claude app and at claude.ai/code, and Anthropic stores its transcript to sync devices (30 days, or 5 years if "Help improve Claude" is on). It's **off for new sessions** unless you change the default. Each running session has its own switch, which restarts that Claude on the same conversation when it's idle. Sessions that sync show an **RC** tag.
- **Delete a conversation:** Search past conversations → **Delete…** removes its local transcript (`~/.claude/projects`) for good; a copy synced by Remote Control is deleted at claude.ai/code.
- **Quiet phone at the desk:** while a tab shows a session, Remote Control holds back phone pushes for it (client-presence marker, expires ~2 min after you leave).
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use, plan usage and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar and Claude's own footer shows a one-line summary. On a Pro/Max plan that summary shows 5-hour plan usage; the dollar figure Claude reports there is an API-price estimate, not a bill, so it appears only for API-key billing.
- **Light mode:** the terminal switches palettes with the page. DevDesk starts Claude with its `dark-ansi` theme so Claude draws with the terminal's 16 colours, and the light palette maps bright white to ink; tmux and the status line use ANSI colours too. A session picks up Claude's theme the next time Claude starts in it (`/exit`, then ⏎ resumes the same conversation).
- **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer.
- **SSH:** `devdesk attach <name>`, `devdesk new <name> [project] [--worktree]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`, `devdesk repo <name>`, `devdesk alerts`.
## Keys
| Key | Action |
@@ -37,16 +81,25 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
| 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) |
| Drag | Selects text; it stays highlighted and is copied when you let go. Double-click selects a word, triple-click a line. Clicks on the tmux status line still go to tmux. |
| Ctrl+C | Copies the selection when there is one; otherwise interrupts as usual |
| Ctrl+Shift+F | Search in the session's files |
| F2 / Delete / Ctrl+C·X·V (explorer) | Rename, move to trash, copy/cut/paste files |
| Palette → Close split panes | Removes extra tmux panes (a stray `^b %` split) and keeps Claude. tmux's right-click menus are off. |
| Ctrl+Shift+C / V | Copy selection / paste |
| Ctrl+Shift+P | Command palette (also the ⌕ button) |
| Ctrl+Shift+E | Files pane (Esc: back, then close) |
Use the **Full** button for fullscreen. In Chromium it also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
The **☀/◐** button switches light and dark mode (saved per browser, shared with the other web_template apps' `lt_theme` preference). Text size, desktop notifications (with a mute), the keyboard shortcuts sheet and fullscreen are in the **⋯** menu; everything is also in the command palette. In Chromium, fullscreen also captures Ctrl+W/Ctrl+T, which browsers otherwise keep for themselves.
## Configuration
- `/etc/devdesk/env` (conffile):
- port, bind address and projects root
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_GUARD=0`.
- `/etc/devdesk/env` holds the packaged defaults; don't edit it. Its keys:
- port, bind address, projects root and `DEVDESK_DEFAULT_DIR`
- `DEVDESK_GITEA_URL` / `DEVDESK_GITEA_ALIASES` for the Repo/CI panel. Public repos need no token; for private ones put a `read:repository` token in `/etc/devdesk/gitea-token`
- `CLAUDE_BIN` and `CLAUDE_ARGS`
- `DEVDESK_REMOTE_CONTROL=1`, which also starts each session with Claude Remote Control so it can be continued from the Claude app
- `DEVDESK_REMOTE_CONTROL=1`: the Remote Control default for new sessions until one is chosen in Settings (which then wins; each session can also choose its own)
- `DEVDESK_PUBLIC_URL`, `DEVDESK_MATRIX_WEBHOOK_FILE`, `DEVDESK_MATRIX_DELAY`, `DEVDESK_MATRIX_QUIET` and `DEVDESK_USAGE_WARN` for the Matrix notifications
- `/etc/devdesk/secret.env` is generated on install. It holds the ttyd credential that NPM must inject; see [docs/DEPLOY.md](docs/DEPLOY.md).
- `/etc/devdesk/tmux.local.conf` is optional and is sourced after the packaged `tmux.conf`.
@@ -62,8 +115,7 @@ apt install ../devdesk_*_amd64.deb
CI (`.gitea/workflows/build-deb.yml`):
- Every push lints, tests, builds and smoke-tests the package and uploads it as an artifact.
- A `v*` tag also publishes to `https://code.lotusguild.org/api/packages/LotusGuild/debian` and creates a release.
- A `v*` tag also publishes to `https://code.lotusguild.org/api/packages/LotusGuild/debian`, creates a release, and auto-deploys to CT 126. A signed webhook runs `devdesk-deploy`, which upgrades the package; see [docs/DEPLOY.md](docs/DEPLOY.md).
## Roadmap
- **v1.1:** `devdesk-api` sidecar for the Repo/CI and Claude Context panels, Matrix notifications when you're away, and auto-deploy.
- **v2:** Ops Alerts (Grafana) with "Ask Claude" prompts, and file drop.
Tracked in [Gitea issues](https://code.lotusguild.org/LotusGuild/devdesk/issues) by milestone: v0.6.0 file viewer, v0.7.0 editor + inbox, backlog.
+431 -41
View File
@@ -1,24 +1,46 @@
#!/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 attach [NAME] attach to session NAME, creating it if needed; no NAME opens the picker
# devdesk new NAME [PROJECT] [--worktree]
# create session NAME (detached) in PROJECT's folder, or the default
# folder; --worktree gives it its own git worktree on branch devdesk/NAME
# devdesk rename OLD NEW rename a session (running or dormant)
# devdesk end NAME stop session NAME and forget it
# devdesk repo NAME git + Gitea CI status of the session's folder (JSON)
# devdesk alerts firing/pending Grafana alerts (JSON)
# devdesk guests [ID|IP|NAME] Proxmox CTs/VMs: the node each runs on now, state, IPs
# devdesk ls list sessions
# devdesk end NAME end session NAME and forget it
# devdesk pick interactive picker
#
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --investigate SESSION UID |
# --upload SESSION FILE BYTES | --new NAME [PROJECT] [--worktree] | --rename OLD NEW | --end NAME |
# --last-reply NAME | --unsplit NAME | --fs-* SESSION … | --existing NAME`
# through ttyd; those print JSON.
set -euo pipefail
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
# shellcheck source=libexec/devdesk-files.sh
. "$DEVDESK_LIBEXEC/devdesk-files.sh"
# shellcheck source=libexec/devdesk-inbox.sh
. "$DEVDESK_LIBEXEC/devdesk-inbox.sh"
# shellcheck source=libexec/devdesk-history.sh
. "$DEVDESK_LIBEXEC/devdesk-history.sh"
# shellcheck source=libexec/devdesk-ops.sh
. "$DEVDESK_LIBEXEC/devdesk-ops.sh"
# shellcheck source=libexec/devdesk-fsops.sh
. "$DEVDESK_LIBEXEC/devdesk-fsops.sh"
usage() {
sed -n '2,9s/^# \{0,1\}//p' "$0"
sed -n '2,13s/^# \{0,1\}//p' "$0"
}
# Tab-separated running sessions: name, dir, attached clients, last activity, windows.
running_tsv() {
dd_server_up || return 0
dd_tmux list-sessions -F '#{session_name} #{session_path} #{session_attached} #{session_activity} #{session_windows}' 2>/dev/null || true
# Path last: it may contain anything (tabs included) and is re-joined below.
dd_tmux list-sessions -F '#{session_name} #{session_attached} #{session_activity} #{session_windows} #{session_path}' 2>/dev/null || true
}
# Tab-separated top-level project directories: slug, dir.
@@ -32,10 +54,22 @@ projects_tsv() {
# 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"
printf '%s\t%s\t%s\n' "$(dd_slugify "${d##*/}")" "$([ -e "$d/.git" ] && echo 1 || echo 0)" "$d"
done
}
# {"<session>": {...}} merged from the per-session JSON files in DIR
# (status-line snapshots, hook states).
per_session_json() {
local dir=$1 f
if [ -d "$dir" ]; then
for f in "$dir"/*.json; do
[ -f "$f" ] || continue
jq -cM --arg n "$(basename "$f" .json)" '{($n): .}' "$f" 2>/dev/null || true
done
fi | jq -cMs 'add // {}'
}
cmd_list() {
local reg='{"sessions":{}}'
[ -s "$DEVDESK_REGISTRY" ] && reg=$(cat "$DEVDESK_REGISTRY")
@@ -43,46 +77,49 @@ cmd_list() {
--argjson reg "$reg" \
--arg run "$(running_tsv)" \
--arg proj "$(projects_tsv)" \
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
--argjson st "$(per_session_json "$DEVDESK_STATUS_DIR")" \
--argjson tags "$(per_session_json "$DEVDESK_STATE_DIR/tags")" \
--arg rclive "$(dd_rc_live)" \
--argjson rcdef "$(dd_rc_default)" \
--arg dflt "$DEVDESK_DEFAULT_SESSION" \
--arg root "$DEVDESK_PROJECTS_ROOT" '
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
($reg.sessions // {}) as $known
| [rows($run)[] | {name: .[0], dir: .[1], running: true,
attached: (.[2] | tonumber), activity: (.[3] | tonumber),
windows: (.[4] | tonumber)}] as $live
| [rows($run)[] | {name: .[0], dir: (.[4:] | join("\t")), running: true,
attached: (.[1] | tonumber? // 0), activity: (.[2] | tonumber? // 0),
windows: (.[3] | tonumber? // 0)}] as $live
| ($live | map(.name)) as $liveNames
| {default: $dflt, root: $root,
sessions: (
($live | map(. + {claude_session_id: ($known[.name].claude_session_id // null)}))
($live | map(. + {claude_session_id: ($known[.name].claude_session_id // null),
worktree: ($known[.name].worktree // 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)}))
),
claude_session_id: (.value.claude_session_id // null),
worktree: (.value.worktree // null)}))
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // null), tags: ($tags[.name].hosts // []),
remote_control: (($known[.name].remote_control) as $own | if $own == null then $rcdef else $own end),
rc_active: (.name as $n | $rclive | split("\n") | any(.[]; . == $n))}),
remote_control_default: $rcdef,
projects: ([{name: $dflt, dir: $root}]
+ [rows($proj)[] | {name: .[0], dir: .[1]}
+ [rows($proj)[] | {name: .[0], git: (.[1] == "1"), dir: (.[2:] | join("\t"))}
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
}
cmd_ls() {
cmd_list | jq -Mr '
.sessions | if length == 0 then "no sessions" else
(["", "SESSION", "CLIENTS", "DIRECTORY"] | @tsv),
(.[] | [(if .running then "●" else "○" end), .name, (.attached | tostring), .dir] | @tsv)
(["", "SESSION", "CLIENTS", "CONTEXT", "DIRECTORY"] | @tsv),
(.[] | [(if .running then "●" else "○" end), .name, (.attached | tostring),
(if .context.used_pct then "\(.context.used_pct | floor)%" else "-" end), .dir] | @tsv)
end' | column -t -s $'\t'
}
# Prints the project directory for NAME, or "" if NAME already runs and has none we can resolve.
session_dir() {
local name=$1 dir
if dir=$(dd_resolve_dir "$name"); then
printf '%s\n' "$dir"
elif dd_tmux has-session -t "=$name" 2>/dev/null; then
printf '%s\n' "$DEVDESK_PROJECTS_ROOT"
else
dd_die "no project directory for '$name' under $DEVDESK_PROJECTS_ROOT"
fi
session_exists() {
{ dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } || [ -n "$(dd_registry_get "$1" dir)" ]
}
create_detached() {
@@ -96,27 +133,385 @@ create_detached() {
}
cmd_new() {
[ $# -eq 1 ] || dd_die "usage: devdesk new NAME"
local worktree=0 a args=()
for a in "$@"; do
if [ "$a" = --worktree ]; then worktree=1; else args+=("$a"); fi
done
set -- "${args[@]}"
{ [ $# -ge 1 ] && [ $# -le 2 ]; } || dd_die "usage: devdesk new NAME [PROJECT] [--worktree]"
dd_valid_name "$1" || dd_die "invalid session name: $1"
dd_ensure_server
local dir
dir=$(session_dir "$1")
if [ $# -eq 2 ]; then
dd_valid_name "$2" || dd_die "invalid project name: $2"
dir=$(dd_project_dir "$2") || dd_die "no project '$2' under $DEVDESK_PROJECTS_ROOT"
! session_exists "$1" || dd_die "a session named '$1' already exists"
else
[ "$worktree" = 0 ] || dd_die "--worktree needs a project"
dir=$(dd_resolve_dir "$1")
fi
[ "$worktree" = 0 ] || dir=$(dd_worktree_add "$1" "$2" "$dir")
dd_ensure_server
create_detached "$1" "$dir"
}
# A git worktree of PROJECT for session NAME (devdesk#44), so two Claudes on one
# repository never edit the same checkout. It lives in $ROOT/.worktrees (hidden
# from the project list) on branch devdesk/NAME, made from the project's HEAD or
# reused if that branch exists. Prints the folder the session starts in.
dd_worktree_add() {
local name=$1 project=$2 dir=$3 top prefix wt branch="devdesk/$1" g
top=$(git -C "$dir" rev-parse --show-toplevel 2>/dev/null) || dd_die "'$project' is not a git repository"
prefix=$(git -C "$dir" rev-parse --show-prefix)
wt="$DEVDESK_PROJECTS_ROOT/.worktrees/$project--$name"
[ ! -e "$wt" ] || dd_die "$wt already exists"
mkdir -p "$DEVDESK_PROJECTS_ROOT/.worktrees"
g=(git -C "$top")
if "${g[@]}" show-ref -q --verify "refs/heads/$branch"; then
"${g[@]}" worktree add -q "$wt" "$branch" >&2 || dd_die "git worktree add failed"
else
"${g[@]}" worktree add -q -b "$branch" "$wt" HEAD >&2 || dd_die "git worktree add failed"
fi
wt=$(realpath -e -- "$wt") || dd_die "git worktree add failed"
# shellcheck disable=SC2016 # jq variables
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {worktree: {path: $w, repo: $r, branch: $b}})' \
--arg n "$name" --arg w "$wt" --arg r "$top" --arg b "$branch"
printf '%s\n' "$wt${prefix:+/${prefix%/}}"
}
# Ending a worktree session removes the worktree when nothing in it is uncommitted,
# and its branch only when that is merged (git branch -d). Otherwise both stay.
dd_worktree_end() {
local wt=$1 repo=$2 branch=$3
[ -d "$wt" ] || return 0
if [ -z "$(git -C "$wt" status --porcelain 2>/dev/null)" ]; then
git -C "$repo" worktree remove "$wt" 2>/dev/null || return 0
git -C "$repo" branch -d "$branch" >/dev/null 2>&1 || true
fi
}
# --unsplit NAME (devdesk#52): close the extra panes a split left in the session's
# windows. Each window keeps one pane: Claude's in the claude window, else the
# first. Prints {ok, closed}.
cmd_unsplit() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --unsplit NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
{ dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } || dd_die "session '$1' isn't running"
local win keep pane closed=0
while read -r win; do
keep=$(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id} #{pane_start_command}' | awk '/devdesk-claude-run/ { print $1; exit }')
[ -n "$keep" ] || keep=$(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id}' | head -n 1)
while read -r pane; do
[ "$pane" = "$keep" ] && continue
dd_tmux kill-pane -t "$pane" && closed=$((closed + 1))
done < <(dd_tmux list-panes -t "=$1:$win" -F '#{pane_id}')
done < <(dd_tmux list-windows -t "=$1" -F '#{window_index}')
jq -cMn --argjson c "$closed" '{ok: true, closed: $c}'
}
# Sessions whose running Claude has Remote Control on, read from the processes
# themselves (devdesk#61), one name per line.
dd_rc_live() {
local session window pid child
dd_server_up || return 0
while IFS=$'\t' read -r session window pid; do
[ "$window" = claude ] || continue
for child in $(pgrep -P "$pid" 2>/dev/null); do
if tr '\0' '\n' <"/proc/$child/cmdline" 2>/dev/null | grep -qx -- --remote-control; then
printf '%s\n' "$session"
break
fi
done
done < <(dd_tmux list-panes -a -F $'#{session_name}\t#{window_name}\t#{pane_pid}' 2>/dev/null)
}
# The Claude process of a session's claude window, if any.
dd_claude_pid() {
local pid
pid=$(dd_tmux list-panes -t "=$1:claude" -F '#{pane_pid}' 2>/dev/null | head -n1)
[ -n "$pid" ] && pgrep -P "$pid" 2>/dev/null | head -n1
}
# --remote-control NAME on|off (devdesk#61): the session's own choice. A running,
# idle Claude restarts on the same conversation right away; one that is working
# or waiting gets it at its next start. Prints {ok, remote_control, applied}.
cmd_remote_control() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --remote-control NAME on|off"
local name=$1 value applied=saved pid state
dd_valid_name "$name" || dd_die "invalid session name: $name"
case $2 in on) value=true ;; off) value=false ;; *) dd_die "on or off" ;; esac
{ session_exists "$name"; } || dd_die "no session named '$name'"
# shellcheck disable=SC2016 # jq variables
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {remote_control: ($v == "true")})' --arg n "$name" --arg v "$value"
if dd_server_up && dd_tmux has-session -t "=$name" 2>/dev/null && pid=$(dd_claude_pid "$name") && [ -n "$pid" ]; then
state=$(jq -rM '.state // empty' "$DEVDESK_STATUS_DIR/$name.json" 2>/dev/null) || state="" # no state yet: idle
if [ "$state" = working ] || [ "$state" = waiting ]; then
applied=next-start
else
mkdir -p "$DEVDESK_STATE_DIR/restart"
touch "$DEVDESK_STATE_DIR/restart/$name"
kill -TERM "$pid" 2>/dev/null || true
applied=now
fi
fi
jq -cMn --argjson v "$value" --arg a "$applied" '{ok: true, remote_control: $v, applied: $a}'
}
# Remote Control default for new sessions: Settings, else DEVDESK_REMOTE_CONTROL. true/false.
dd_rc_default() {
local def
def=$(jq -rM '.remote_control_default | if . == null then empty else tostring end' "$DEVDESK_STATE_DIR/settings.json" 2>/dev/null)
if [ -n "$def" ]; then echo "$def"; elif [ "${DEVDESK_REMOTE_CONTROL:-0}" = 1 ]; then echo true; else echo false; fi
}
# --settings [remote-control-default on|off] (devdesk#61): DevDesk-wide settings
# kept in $DEVDESK_STATE_DIR/settings.json; prints them.
cmd_settings() {
local file="$DEVDESK_STATE_DIR/settings.json" tmp
if [ $# -eq 2 ]; then
[ "$1" = remote-control-default ] || dd_die "unknown setting: $1"
case $2 in on | off) ;; *) dd_die "on or off" ;; esac
tmp=$(mktemp "$DEVDESK_STATE_DIR/.settings.XXXXXX")
jq -cM --argjson v "$([ "$2" = on ] && echo true || echo false)" '. + {remote_control_default: $v}' "$file" 2>/dev/null >"$tmp" ||
jq -cMn --argjson v "$([ "$2" = on ] && echo true || echo false)" '{remote_control_default: $v}' >"$tmp"
mv -f "$tmp" "$file"
elif [ $# -ne 0 ]; then
dd_die "usage: devdesk attach --settings [remote-control-default on|off]"
fi
jq -cMn --argjson d "$(dd_rc_default)" '{ok: true, remote_control_default: $d}'
}
cmd_rename() {
[ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW"
local old=$1 new=$2 running=0
dd_valid_name "$old" || dd_die "invalid session name: $old"
dd_valid_name "$new" || dd_die "invalid session name: $new"
[ "$old" != "$new" ] || return 0
if dd_server_up && dd_tmux has-session -t "=$old" 2>/dev/null; then running=1; fi
[ "$running" = 1 ] || [ -n "$(dd_registry_get "$old" dir)" ] || dd_die "no session named '$old'"
! session_exists "$new" || dd_die "a session named '$new' already exists"
dd_registry_locked rename_locked "$old" "$new" "$running" || dd_die "could not rename '$old'"
}
# tmux and the registry change together, under the registry lock (see
# devdesk-session-hook).
rename_locked() {
if [ "$3" = 1 ]; then
dd_tmux rename-session -t "=$1" "$2" || return 1
dd_tmux set-environment -t "=$2" DEVDESK_SESSION "$2"
fi
dd_registry_rename "$1" "$2"
}
cmd_end() {
[ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
local wt
wt=$(jq -rM --arg n "$1" '.sessions[$n].worktree // empty | [.path, .repo, .branch] | @tsv' "$DEVDESK_REGISTRY" 2>/dev/null) || wt=""
# Forget first: killing the session SIGHUPs us when run from inside it.
dd_registry_remove "$1"
if [ -n "$wt" ]; then
IFS=$'\t' read -r -a wt <<<"$wt"
dd_worktree_end "${wt[@]}"
fi
rm -rf "${DEVDESK_STATE_DIR:?}/uploads/$1"
if dd_server_up; then
dd_tmux kill-session -t "=$1" 2>/dev/null || true
fi
}
# GET $DEVDESK_GITEA_URL/api/v1<path>; token optional (private repos only).
gitea_get() {
# The token goes in via a curl config on stdin, never on the command line (ps).
{
if [ -r "$DEVDESK_GITEA_TOKEN_FILE" ]; then
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token
printf 'header = "Authorization: token %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GITEA_TOKEN_FILE")"
fi
} | curl -fsS --max-time 4 -K - "$DEVDESK_GITEA_URL/api/v1$1" 2>/dev/null
}
# owner/repo when REMOTE points at our Gitea (https or ssh, any configured host), else nothing.
gitea_slug() {
local remote=$1 host hosts
hosts="${DEVDESK_GITEA_URL#*://} $DEVDESK_GITEA_ALIASES"
for host in $hosts; do
host=${host%%/*}
host=${host%%:*}
if [[ $remote =~ [@/]${host//./\.}(:[0-9]+)?[:/]+([^/]+/[^/]+)$ ]]; then
printf '%s\n' "${BASH_REMATCH[2]%.git}"
return
fi
done
}
cmd_repo() {
[ $# -eq 1 ] || dd_die "usage: devdesk repo NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
local dir g branch sha="" dirty changes ahead=null behind=null upstream=false remote slug="" ci=null prs=null
dir=$(dd_resolve_dir "$1")
# --no-optional-locks: never take .git/index.lock (Claude's own git would collide).
# No safe.directory override: repos owned by other users stay untrusted.
g=(git --no-optional-locks -C "$dir")
if ! "${g[@]}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
jq -cMn --arg s "$1" --arg dir "$dir" '{session: $s, dir: $dir, git: false}'
return
fi
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
sha=$("${g[@]}" rev-parse -q --verify HEAD 2>/dev/null || true)
dirty=$("${g[@]}" status --porcelain 2>/dev/null | wc -l)
# The first changed files, for the panel to open as diffs (devdesk#46).
changes='[]'
[ "$dirty" = 0 ] || changes=$(cmd_git_status "$1" 2>/dev/null | jq -cM '.files[:8] // []' 2>/dev/null) || changes='[]'
if "${g[@]}" rev-parse -q --verify '@{u}' >/dev/null 2>&1; then
upstream=true
read -r behind ahead < <("${g[@]}" rev-list --left-right --count '@{u}...HEAD')
fi
remote=$("${g[@]}" remote get-url origin 2>/dev/null | sed -E 's#^([a-z+]+://)[^/@]*@#\1#' || true)
[ -n "$remote" ] && slug=$(gitea_slug "$remote")
if [ -n "$slug" ] && [ -n "$sha" ]; then
ci=$(gitea_get "/repos/$slug/commits/$sha/status" |
jq -cM '{state, total: .total_count,
url: (.statuses[0]?.target_url | if . then sub("/jobs/[0-9]+$"; "") else null end),
jobs: [.statuses[]? | {name: .context, status}]}' 2>/dev/null) || ci=null
prs=$(gitea_get "/repos/$slug/pulls?state=open&limit=50" | jq -M 'length' 2>/dev/null) || prs=null
fi
jq -cMn --arg s "$1" --arg dir "$dir" --arg branch "$branch" --arg sha "${sha:0:7}" \
--argjson dirty "$dirty" --argjson changes "${changes:-[]}" --argjson ahead "$ahead" --argjson behind "$behind" \
--argjson upstream "$upstream" --arg remote "$remote" --arg slug "$slug" \
--arg web "$DEVDESK_GITEA_URL" --argjson ci "${ci:-null}" --argjson prs "${prs:-null}" '
{session: $s, dir: $dir, git: true, branch: $branch, sha: $sha, dirty: $dirty, changes: $changes,
ahead: $ahead, behind: $behind, upstream: $upstream, remote: $remote,
repo: (if $slug == "" then null else $slug end),
url: (if $slug == "" then null else "\($web)/\($slug)" end),
ci: (if $ci == null then null else $ci + {url: (if $ci.url then $web + $ci.url else null end)} end),
prs: $prs}'
}
# Receive a file for SESSION over the terminal: the browser waits for the ready
# line, then sends exactly ceil(BYTES/3)*4 bytes of base64 as input.
cmd_upload() {
[ $# -eq 3 ] || dd_die "usage: devdesk attach --upload SESSION FILENAME BYTES"
local session=$1 name size=$3 dir dest b64len actual
dd_valid_name "$session" || dd_die "invalid session name: $session"
{ [[ $size =~ ^[0-9]+$ ]] && [ "$size" -gt 0 ] && [ "$size" -le "$DEVDESK_UPLOAD_MAX" ]; } ||
dd_die "file is empty or larger than $((DEVDESK_UPLOAD_MAX / 1048576)) MB"
name=$(dd_safe_filename "$2")
dir="$DEVDESK_STATE_DIR/uploads/$session"
mkdir -p "$dir"
find "$DEVDESK_STATE_DIR/uploads" -type f -mtime +7 -delete 2>/dev/null || true
# Unique even for same-second uploads of the same name.
dest=$(mktemp --tmpdir="$dir" --suffix="-$name" "$(date +%Y%m%d-%H%M%S)-XXXX")
# A dropped connection SIGHUPs us mid-transfer: never leave a partial file.
trap 'rm -f "$dest"' HUP INT TERM
# shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose
b64len=$(((size + 2) / 3 * 4))
stty raw -echo 2>/dev/null || true
printf 'DEVDESK-UPLOAD-READY\r\n'
head -c "$b64len" | base64 -d >"$dest" 2>/dev/null || true
stty sane 2>/dev/null || true
actual=$(stat -c %s "$dest" 2>/dev/null || echo 0)
if [ "$actual" != "$size" ]; then
rm -f "$dest"
dd_die "upload incomplete ($actual of $size bytes)"
fi
trap - HUP INT TERM
chmod 0644 "$dest"
jq -cMn --arg p "$dest" '{ok: true, path: $p}'
}
ok_json() {
printf '{"ok":true}\n'
}
cmd_attach() {
if [ $# -eq 0 ]; then
exec "$DEVDESK_LIBEXEC/devdesk-picker"
fi
[ $# -eq 1 ] || dd_die "attach takes exactly one session name"
if [ "$1" = "--list" ]; then
case $1 in
--list)
[ $# -eq 1 ] || dd_die "--list takes no arguments"
cmd_list
dd_linger
return
;;
--repo)
shift
cmd_repo "$@"
dd_linger
return
;;
--alerts)
cmd_alerts
dd_linger
return
;;
--investigate)
shift
cmd_investigate "$@"
dd_linger
return
;;
--upload)
shift
cmd_upload "$@"
dd_linger
return
;;
--fs-list | --fs-find | --fs-read | --fs-write | --git-status | --git-show | --asset | \
--fs-mkdir | --fs-move | --fs-copy | --fs-delete | --fs-restore | --fs-put | --fs-download | --fs-grep)
local action=${1#--}
shift
"cmd_${action//-/_}" "$@"
dd_linger
return
;;
--peek | --search | --last-reply | --unsplit | --remote-control | --settings | --delete-convo)
local action=${1#--}
shift
"cmd_${action//-/_}" "$@"
dd_linger
return
;;
--resume-convo | --presence)
local action=${1#--}
shift
"cmd_${action//-/_}" "$@"
ok_json
dd_linger
return
;;
--answer | --reply)
local action=${1#--}
shift
"cmd_$action" "$@"
ok_json
dd_linger
return
;;
--new | --rename | --end)
local action=${1#--}
shift
"cmd_$action" "$@"
ok_json
dd_linger
return
;;
--existing)
# Reconnects: never re-create a session that was ended or renamed meanwhile.
shift
[ $# -eq 1 ] || dd_die "attach --existing takes exactly one session name"
dd_valid_name "$1" || dd_die "invalid session name: $1"
if ! { dd_server_up && dd_tmux has-session -t "=$1" 2>/dev/null; } && [ -z "$(dd_registry_get "$1" dir)" ]; then
dd_die "session '$1' no longer exists (it was ended or renamed)"
fi
;;
-*) dd_die "unknown option: $1" ;;
esac
[ $# -eq 1 ] || dd_die "attach takes exactly one session name"
local name=$1 dir args
dd_valid_name "$name" || dd_die "invalid session name: $name"
dd_ensure_server
dir=$(session_dir "$name")
dir=$(dd_resolve_dir "$name")
# Already inside a DevDesk pane: switch this client instead of nesting.
local outer=${TMUX:-}
@@ -134,20 +529,15 @@ cmd_attach() {
exec tmux -N -u -S "$DEVDESK_SOCKET" new-session -A "${args[@]}"
}
cmd_end() {
[ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
dd_valid_name "$1" || dd_die "invalid session name: $1"
if dd_server_up; then
dd_tmux kill-session -t "=$1" 2>/dev/null || true
fi
dd_registry_remove "$1"
}
cmd=${1:-help}
[ $# -gt 0 ] && shift
case $cmd in
attach | a) cmd_attach "$@" ;;
new) cmd_new "$@" ;;
rename | mv) cmd_rename "$@" ;;
repo) cmd_repo "$@" ;;
alerts) cmd_alerts ;;
guests) cmd_guests "$@" ;;
ls | list) cmd_ls ;;
--list) cmd_list ;;
end) cmd_end "$@" ;;
+495
View File
@@ -1,3 +1,498 @@
devdesk (0.12.0) bookworm; urgency=medium
* Settings window, from the ⋯ menu or the palette (#61):
- Theme, terminal text size and interface size (per browser).
- Desktop notifications.
- Remote Control and privacy.
The ⋯ menu shrinks to five entries.
* Remote Control per session (#61):
- A default for new sessions and a switch for each running session.
- Switching an idle session restarts its Claude on the same
conversation; a busy one changes at its next start.
- Sessions that sync show an RC tag, read from the running process.
- Server side: --remote-control and --settings.
* Delete a conversation from Search past conversations (#61): its local
transcript and subagent files, for good (--delete-convo). Refused
while a running session uses it.
-- Jared Vititoe <jjvititoe1@gmail.com> Tue, 03 Nov 2026 21:25:00 -0500
devdesk (0.11.0) bookworm; urgency=medium
* Explorer works like VS Code's (#54):
- Right-click menus for files, folders and empty space;
Ctrl/Shift+click selects several.
- Inline rename (F2) and inline new file/folder.
- Drag to move, Ctrl+drag to copy.
- Drop files or whole folders from the desktop to upload them, asking
Replace / Skip / Keep both on a clash.
- Cut/copy/paste files, duplicate, download, copy path, @Claude.
- Delete moves to a 7-day trash with Undo; nothing is overwritten
unless asked.
- Server side: new --fs-mkdir/-move/-copy/-delete/-restore/-put/
-download.
* Markdown preview rewritten (#55):
- GitHub-style headings with anchors and an outline.
- Highlighted code with Copy, callouts, task lists, front matter.
- Relative links open files in DevDesk.
- Split view with a live preview.
* Editor (#55): status bar (line/column, cursors, indentation, line
endings, language), multiple cursors, column select, auto-closing
brackets, Ctrl+G, and tab menus with drag-to-reorder.
* Search in files, Ctrl+Shift+F (#56): match case, whole word and regex,
via git grep or grep.
* Fixes:
- A nested path check could escape the session folder: set -e does not
reach into $(...).
- The tree could show a stale listing.
- Session actions now return the server's reply, so "Close split
panes" reports correctly.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 22 Oct 2026 21:30:00 -0400
devdesk (0.10.4) bookworm; urgency=medium
* The Sessions panel's list scrolls on its own (#53). There is no cap on
sessions; with many of them, Ops Alerts and Repo/CI stay in reach below
the list. The current session is scrolled into view when you switch.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 22 Oct 2026 20:53:00 -0400
devdesk (0.10.3) bookworm; urgency=medium
* No tmux right-click menus (#52). A stray click on one split a pane, or
could kill Claude's window. "Close split panes (keeps Claude)" in the
command palette, backed by `--unsplit`, removes a split that happened
anyway.
-- Jared Vititoe <jjvititoe1@gmail.com> Tue, 13 Oct 2026 09:00:00 -0400
devdesk (0.10.2) bookworm; urgency=medium
* Selecting text in the terminal works like a normal terminal (#51). A
drag is a browser selection that stays highlighted and is copied when
you let go, with a short toast. Ctrl+C copies the selection and
interrupts only when nothing is selected. The wheel still scrolls tmux
history, and the tmux status line still takes clicks.
-- Jared Vititoe <jjvititoe1@gmail.com> Mon, 12 Oct 2026 16:36:00 -0400
devdesk (0.10.1) bookworm; urgency=medium
* Everything in 0.10.0, which was never published: its CI test for the
usage ping depended on attaching a tmux client.
* The usage ping counts you as away the same way "Claude needs you" does:
no DevDesk tab in view for ~2 minutes. A forgotten background tab no
longer silences it.
-- Jared Vititoe <jjvititoe1@gmail.com> Mon, 12 Oct 2026 01:45:00 -0400
devdesk (0.10.0) bookworm; urgency=medium
* Matrix "Claude needs you" is quieter and says what is asked (#50):
- Claude's idle reminder after a turn no longer counts as a wait: no red
session, no inbox entry and no Matrix post.
- Posts only when no DevDesk tab has been in view for ~2 minutes, after
60 s, and at most once per session per 10 minutes
(DEVDESK_MATRIX_QUIET).
- The message names the tool, its description and the command.
* Own git worktree per session (#44): `devdesk new NAME PROJECT
--worktree` or a checkbox in the New session form. It uses branch
devdesk/NAME in $ROOT/.worktrees. Ending the session removes the
worktree if it is clean, and the branch if it is merged.
* Plan meters show when each window resets. If the pace so far would use
a window up before then, they warn instead. Matrix gets one message per
5-hour window past DEVDESK_USAGE_WARN (90%) when no tab is in view (#45).
* Repo panel lists the changed files; a click opens the diff (#46).
* Copy Claude's last reply as Markdown: a session button and a palette
entry, backed by `--last-reply` (#47).
* Turn timer in the session chip, the tab title and session cards (#48).
* Compact button when the context is 80% full and Claude is idle (#49).
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 23:30:00 -0400
devdesk (0.9.5) bookworm; urgency=medium
* Context use is shown in tokens as well as percent (#43): Claude's
footer shows "ctx 34% 346k/1M", and the Sessions panel and session
switcher show "ctx 34% · 346k/1M".
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 23:07:00 -0400
devdesk (0.9.4) bookworm; urgency=medium
* The inbox badge no longer only grows (#42). Opening the bell marks its
events seen, leaving just what is live (sessions waiting on you,
firing alerts). Viewing a session reads the events it reported while
you were away.
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 22:46:00 -0400
devdesk (0.9.3) bookworm; urgency=medium
* Claude's footer shows 5-hour plan use instead of a dollar figure on a
Pro/Max subscription (#40). Claude Code's cost is an API-price
estimate, not a bill; it is still shown for API-key billing. The
Sessions tooltip follows the same rule.
* The bottom row is larger and easier to read (#41): 40px tall, larger
hints, and 5h/week usage meters in bold monospace with wider bars.
Narrow windows drop the least-used hints instead of clipping the
meters.
* Tests clear inherited DevDesk variables, so they pass when run from
inside a DevDesk session.
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 22:08:00 -0400
devdesk (0.9.2) bookworm; urgency=medium
* Ops Alerts → Ask Claude starts an investigation (#39):
- It opens a new session (alert-<name>) and starts Claude with the
prompt already sent, instead of typing it into the current session.
Clicking again opens the running session.
- The server writes the prompt from Grafana: rule, query, summary,
details, labels, and where the alert runs. That is the Proxmox CT/VM
id, the node it is on right now, its state and HA state, and the
`ssh <node> pct exec <id> --` line.
- Each alert card shows that guest or node.
* `devdesk guests [ID|IP|NAME]`: Proxmox guests with their current node,
status, HA state and IPs, read over SSH from DEVDESK_PVE_NODES. CT IPs
come from lxc-info, VM IPs from the guest agent. Cached in
/var/lib/devdesk/guests.json; stopped guests keep their last-seen IPs.
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 11:23:53 -0400
devdesk (0.9.1) bookworm; urgency=medium
* Robustness (#12):
- A resume that fails (an early Ctrl+C, a network error) no longer
replaces the session's recorded conversation with a new one: Claude
starts fresh only when the conversation's file is really gone;
otherwise the menu offers to try again.
- The SessionStart hook reads the session name under the registry lock
that rename holds, so renaming during /clear or a restart can't leave
the conversation id under the old name.
- Env files are parsed the way systemd parses them instead of being
sourced: values are literal (no $ expansion or command substitution),
and bad lines are reported and skipped.
- Deploys wait for the dpkg lock (120 s), log an explicit "deploy
FAILED" line, and the webhook answers with the result, so CI's deploy
job fails (and alerts) when the install fails. The deploy log is
rotated monthly.
- postinst removes the duplicate tmux terminal-features entries left by
upgrades before 0.3.1.
- Temp files of hooks killed mid-write are swept.
- NPM now sends X-Frame-Options DENY and CSP frame-ancestors 'none' for
dev.lotusguild.org (proxy config, not part of the package).
-- Jared Vititoe <jjvititoe1@gmail.com> Sun, 11 Oct 2026 01:01:33 -0400
devdesk (0.9.0) bookworm; urgency=medium
* Search past conversations (#24): every Claude conversation on this host
(DevDesk and code-server), by what you asked, what Claude said, the
commands it ran and their output (⋯ menu or the palette). Results show
the title, folder, dates and highlighted snippets; Resume opens the
conversation in a new session in its folder, Fork continues it as a new
conversation (claude --fork-session) and leaves the original as it is.
A conversation already open in a running session offers "Open" instead,
and resuming it twice is refused.
* Plan usage and PRs (#25): the 5-hour and weekly plan windows from
Claude's statusLine, as meters in the footer (⋯ menu on phones), with a
warning at 90 % of the 5-hour window; a PR chip (with review state) on
sessions whose branch has a pull request. (The statusLine has no prompt
cache expiry, so there is no cache timer.)
* Phone pushes held back while you're at the desk (#26): while a DevDesk
tab shows a session, its Claude sees a client-presence marker
(CLAUDE_CLIENT_PRESENCE_FILE) and Remote Control holds back pushes; the
marker expires about 2 minutes after the tab stops showing it. Applies to
sessions whose Claude started on this version (/exit, then ⏎).
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 21:44:07 -0400
devdesk (0.8.2) bookworm; urgency=medium
* Click file paths in the terminal (#21): a path Claude prints
(web/src/files.js, ./notes.md:12, /root/code/x/y.py:3) is a link when it
is a file of the session; it opens in Files, at that line if given.
* Alerts: "In a new session" (#18) opens (or reuses) alert-<name> in the
default folder and types the investigation prompt there once Claude is
up, without sending it.
* Ops trail and guard (#27):
- devdesk-guard, a PreToolUse hook for Bash, makes Claude ask before
commands that destroy things (pct/qm destroy, zfs/zpool destroy, Ceph
pool/OSD removal, rbd rm, wipefs/mkfs/dd to /dev, rm -r of / or a
top-level folder, git push --force, DROP TABLE, stopping devdesk-tmux,
reboot/poweroff…), also in auto mode. It never blocks or allows on its
own. DEVDESK_GUARD=0 turns it off; /etc/devdesk/guard.local adds rules
(regex<TAB>reason).
- Every Bash command is logged per session (last 500); sessions are
tagged with the hosts they work on (ssh targets, CT/VM ids), shown in
the session list, the switcher and the palette; Peek lists the last
commands.
* Sessions pick up the new hooks when Claude next starts (/exit, then ⏎).
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 20:36:32 -0400
devdesk (0.8.1) bookworm; urgency=medium
* Answer sessions from the 🔔 inbox without switching to them:
- Allow / Deny (#22): a PermissionRequest hook records what Claude wants
to run (tool, command/file/URL, Claude's description); the inbox shows
it with Allow, Deny and Open. The key is pressed in that session's
Claude window only if the same wait is still on and the permission
prompt is on screen, so a stale button never types into Claude.
- Peek & reply (#23): the last thing you asked and Claude's latest answer
(from the session's transcript), and a reply box; the reply is pasted
as one bracketed paste and sent. Available for sessions that wait for
input and from "finished" entries.
- Matrix "needs you" messages include the command.
* New attach commands: --answer SESSION SINCE allow|deny, --peek SESSION,
--reply SESSION BYTES (text streamed like uploads).
* Sessions pick up the new PermissionRequest hook when Claude next starts
(/exit, then ⏎).
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 20:22:37 -0400
devdesk (0.8.0) bookworm; urgency=medium
* Edit and save files in the Files pane (#20):
- Text files open editable; ● marks unsaved tabs; Ctrl+S or Save writes
through `devdesk attach --fs-write`: a temp file in the same folder,
mode and owner kept, swapped in with mv.
- Conflict detection: the save carries the sha256 of the version you
opened; if Claude (or anyone) changed the file since, nothing is written
and you choose Compare (diff of disk vs yours), Overwrite or Cancel.
- A file that changes on disk while you edit it shows a banner (Compare,
Reload, Keep mine) instead of replacing your text; clean files still
reload in place.
- Unsaved edits are mirrored to the browser as drafts (flushed when the
page closes), so a reload, a crash or a session switch doesn't lose
them; a draft for a file that changed since offers Compare/Restore/
Discard.
- Closing a tab with unsaved changes asks Save / Don't save / Cancel;
leaving the page warns.
- + New file (missing folders are created; never outside the session
folder, and never through a symlink out of it).
- Markdown previews show unsaved Source edits. Latin-1 files stay
read-only.
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 20:09:49 -0400
devdesk (0.7.3) bookworm; urgency=medium
* Fix (#38): links that mean "go to that session" no longer re-create one
that was ended. The 🔔 inbox, the session switcher, palette switches, the
"needs you" chip and Matrix links (now ?arg=NAME&existing=1) open sessions
existing-only; an ended one shows "NAME has ended" with an explicit
"Start NAME again". Inbox entries for ended sessions are marked as such,
and entries older than 7 days are dropped.
-- Jared Vititoe <jjvititoe1@gmail.com> Sat, 10 Oct 2026 19:57:52 -0400
devdesk (0.7.2) bookworm; urgency=medium
* Fix: Go to file failed in big non-git folders such as /root/code: the
file walk was cut off at its cap with SIGPIPE, which failed the command
and truncated its reply. Producers piped into a cap now tolerate it (also
for very large folders and git status). Outside git, Go to file skips
build/cache folders and stops at 10,000 files.
* Large side-channel replies no longer cost quadratic time in the browser.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 01:50:50 -0400
devdesk (0.7.1) bookworm; urgency=medium
* Files pane reworked like VS Code (#37):
- Explorer tree beside the editor: folders expand in place (also with
the keyboard: arrows, Enter), the open file is highlighted and can be
revealed, git colours on names, change dots on folders, gitignored
entries dimmed, file-type badges. A drag handle sizes the explorer.
- Tabs: a click opens a preview tab (italic) that the next click
replaces; double-click or Enter keeps it. Switching tabs is instant and
keeps the scroll position. Files Claude changes reload in place.
- Go to file (Ctrl+P in the pane, the ⌕ button, the palette): fuzzy
search over git ls-files, or a capped find outside git.
- Explorer and Changes are two views of the left column; diffs open as
tabs.
- Expanded folders, open tabs and the active tab are remembered per
session; an empty editor offers recent files and the change count.
- Narrow panes and phones: the explorer slides over the editor.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 01:40:47 -0400
devdesk (0.7.0) bookworm; urgency=medium
* Files pane (#19): a Files button (Ctrl+Shift+E) next to Panels opens the
session's folder beside the terminal (a drag-resizable split on wide
screens, an overlay on narrow ones and phones).
- Browse folders; read files with syntax highlighting (CodeMirror,
read-only, Ctrl+F search, line wrap toggle), markdown rendered as a
sanitized preview with its relative images, images shown inline.
- Changes tab: uncommitted git changes with line counts and a "C" badge
on files Claude edited (recorded by the state hook from Edit/Write);
diff against HEAD for any changed file. Non-git sessions list the files
Claude edited there.
- "→ Claude" types @path into the prompt; Copy path.
- After Claude's turn the list refreshes, and an open file that changed
on disk offers a reload.
- Paths stay inside the session folder (no absolute paths, no symlink
escapes). Read-only for now; editing is v0.8.0.
- The editor (~600 KB) is not in the page: it ships in
/usr/share/devdesk/assets, is fetched over ttyd the first time Files
opens, checked against the page's SHA-256 and cached in the browser.
* Fix: the state hook mis-read hook fields when Claude's message was empty.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 01:16:26 -0400
devdesk (0.6.1) bookworm; urgency=medium
* Inbox bell (#36): sessions waiting on you, firing alerts, and what
happened while you were away (a background session finished or started
waiting, CI passed or failed). Click to jump there; shared by all tabs.
Replaces the count on the Panels button.
* Phone header: theme in the ⋯ menu, Panels as an icon, so the bell fits.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 00:50:56 -0400
devdesk (0.6.0) bookworm; urgency=medium
* Light mode, like the other web_template apps (#31): the ☀/◐ button in the
header, shared lt_theme preference (OS setting until you choose), applied
before first paint and synced across tabs. The terminal has a light
palette; Claude runs with its dark-ansi theme so its colours come from
that palette, and the light palette maps bright white to ink, so Claude's
text, scrollback included, stays readable in both modes. tmux and the
statusLine use ANSI colours so they follow the theme too. Sessions pick
up Claude's theme the next time Claude starts in them.
* Command palette (#32): ⌕ button or Ctrl+Shift+P. Switch, create, rename
and end sessions, Claude/Shell window, scroll back, detach, theme, panels,
text size, fullscreen, notifications, copy link, open the repo / PRs / CI
run, show alerts, keyboard help.
* Dialogs instead of browser prompt/confirm for rename, end and new
session (name check inline, project folder picker), and a keyboard
shortcuts sheet in the ⋯ menu, the palette and the footer (#33).
* Toasts for results: created, renamed, ended, uploads, link copied, CI
finished, errors (#34).
* Fix: renames, ends and new sessions often reported "no response" although
they worked; ttyd dropped the reply of commands that exit right after
printing. Replies now wait for the browser to hang up (#35).
* Fix: the Sessions panel showed stale names right after a rename or end.
-- Jared Vititoe <jjvititoe1@gmail.com> Thu, 08 Oct 2026 00:32:11 -0400
devdesk (0.5.1) bookworm; urgency=medium
* A session created by opening its URL shows up in the Sessions panel and
the switcher right away instead of at the next poll.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 23:42:01 -0400
devdesk (0.5.0) bookworm; urgency=medium
* Security (from the 2026-10-07 review):
- ttyd :7681 is firewalled to NPM in CT 126 and its credential was
rotated (#1).
- "Ask Claude" strips control characters from Grafana text and pastes it
as bracketed paste, so alert text can never run a command (#2).
- OSC 52 clipboard is write-only: terminal output can no longer read the
browser clipboard; tmux allow-passthrough is off (#7).
- Gitea/Grafana tokens reach curl on stdin, never on its command line;
remote URLs lose any userinfo (#11).
* Fixes:
- Ending the session you are in no longer leaves a ghost entry (#3).
- Reconnecting after a drop never re-creates a session that was ended or
renamed meanwhile (`attach --existing`) (#4).
- Repo panel uses git --no-optional-locks and drops the safe.directory
override (#5); uploads get unique names and are cleaned up on hangup
(#6); uploads are size-checked first and streamed in slices with an
idle timeout (#9); paths with tabs no longer break the session list
(#10).
- ttyd allows 32 clients; side-channel calls run two at a time and one
tab polls for all open tabs (#8).
* Header: the SSO chip is gone ("Sign in again" appears only when the
session expires); the session chip shows Claude's state as a dot and
opens a session switcher; LIVE is a dot; text size, notifications (with
mute) and fullscreen move into a ⋯ menu; the Panels button shows a badge
for waiting sessions and alerts (#13, #16).
* A bare URL reattaches your last session instead of opening the picker
(#16).
* Notifications when *other* sessions start waiting, and alerts are polled
with the panels closed (#15).
* Sidebar: the Claude Context panel is folded into a context bar in each
session row; Repo/CI collapses for non-git folders and offers "Ask Claude
to fix CI"; the new-session form hides behind "+ New"; more legible
labels (#17).
* Phone: key bar (Esc, Tab, ⇧Tab, ^C, arrows, ⏎, Claude/Shell), drawer
with backdrop, layout that follows the on-screen keyboard, DOM renderer
on touch devices, no clock in the tmux status line (#14).
* Matrix "Claude needs you": a wait that lasts 30 s with nobody attached
posts one message with a link to the Devdesk hookshot webhook (#28).
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 23:34:48 -0400
devdesk (0.4.0) bookworm; urgency=medium
* Session status at a glance: Claude hooks record whether each session is
working, waiting on you (with Claude's message) or idle. The sidebar shows
it with context %, the header shows the current session's state, and a
pulsing "needs you" chip jumps to sessions waiting for input.
* File & image drop: drag files onto the terminal or paste a screenshot.
They're uploaded through ttyd to /var/lib/devdesk/uploads/<session>/ (25 MB
max, pruned after 7 days) and the path is pasted into Claude, which
attaches images itself.
* Ops Alerts panel is live: firing/pending Grafana alert rules with
severity, instances, summary and a link, plus an "Ask Claude" button that
types an investigation prompt into the current session.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 19:24:15 -0400
devdesk (0.3.1) bookworm; urgency=medium
* Auto-deploy: tagged releases trigger CT 126's webhook listener (house HMAC
pattern), which runs /usr/lib/devdesk/devdesk-deploy to upgrade the
package from the apt registry. Running sessions are untouched.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 13:20:58 -0400
devdesk (0.3.0) bookworm; urgency=medium
* Repo / CI panel is live: branch, ahead/behind, uncommitted changes, the
Gitea CI status of HEAD (linked to the run) and open PRs for the current
session's folder, via `devdesk attach --repo` over the ttyd channel. No
sidecar needed; public repos need no token (optional
/etc/devdesk/gitea-token for private ones). A notification fires when CI
for HEAD finishes while the tab is in the background.
* Remote Control verified with resume (same claude.ai session link).
* Local settings now go in /etc/devdesk/env.local (read after the packaged
env, never shipped), so upgrades no longer stop at a conffile prompt.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 12:57:04 -0400
devdesk (0.2.1) bookworm; urgency=medium
* Session hook and status line only record for panes on DevDesk's own
tmux server (Claude run with these settings elsewhere no longer writes
into DevDesk's state).
* The Claude wrapper re-reads /etc/devdesk/env before every Claude launch,
so Remote Control and CLAUDE_ARGS changes apply on the next restart.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 12:54:06 -0400
devdesk (0.2.0) bookworm; urgency=medium
* Sessions no longer need a project folder: any name works and starts in
DEVDESK_DEFAULT_DIR unless it matches a project; new NAME [PROJECT].
* Rename and end sessions from the Sessions panel (and devdesk rename).
Renames keep the running Claude and its recorded conversation.
* Claude Context panel is live: a statusLine command snapshots model,
context-window use, cost and edits per session; Claude's footer shows a
one-line summary.
* Drop GANDALF from the Ops Alerts panel.
* CI: bash shell, download retries, Node 22 test invocation,
build-essential, smoke-test pipe fix.
-- Jared Vititoe <jjvititoe1@gmail.com> Wed, 07 Oct 2026 03:01:21 -0400
devdesk (0.1.0) bookworm; urgency=medium
* Initial release: persistent browser terminal for Claude Code
+5
View File
@@ -35,6 +35,11 @@ Comment: Bundled into index.html at build time:
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).
Bundled into assets/editor.js (the file viewer) at build time:
CodeMirror 6 and @lezer/highlight (MIT, Copyright 2018-2021 Marijn Haverbeke
and others); marked (MIT, Copyright 2018+ MarkedJS, 2011-2018 Christopher
Jeffrey); DOMPurify (Apache-2.0 or MPL-2.0 at the recipient's choice, here
Apache-2.0, Copyright 2025 Dr.-Ing. Mario Heiderich, Cure53).
License: MIT
Permission is hereby granted, free of charge, to any person obtaining a copy
+1
View File
@@ -11,6 +11,7 @@ User=root
Environment=HOME=/root USER=root LOGNAME=root SHELL=/bin/bash LANG=C.UTF-8
Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
EnvironmentFile=-/etc/devdesk/env
EnvironmentFile=-/etc/devdesk/env.local
WorkingDirectory=-/root
RuntimeDirectory=devdesk
RuntimeDirectoryMode=0700
+8
View File
@@ -0,0 +1,8 @@
/var/log/devdesk-deploy.log {
monthly
rotate 6
compress
delaycompress
missingok
notifempty
}
+7
View File
@@ -13,6 +13,13 @@ configure)
# Apply tmux.conf changes to a running server without restarting it.
if [ -S /run/devdesk/tmux.sock ]; then
tmux -N -S /run/devdesk/tmux.sock source-file /usr/share/devdesk/tmux.conf >/dev/null 2>&1 || true
# Before 0.3.1 every upgrade appended another copy of DevDesk's
# terminal-features entry; tmux.conf now sets index 90. Drop the copies.
tmux -N -S /run/devdesk/tmux.sock show -s terminal-features 2>/dev/null |
awk '$2 == "xterm-256color:RGB" && $1 != "terminal-features[90]" { print $1 }' |
while read -r opt; do
tmux -N -S /run/devdesk/tmux.sock set -su "$opt" >/dev/null 2>&1 || true
done
fi
;;
esac
+1 -1
View File
@@ -2,7 +2,7 @@
set -e
if [ "$1" = purge ]; then
rm -f /etc/devdesk/secret.env
rm -f /etc/devdesk/secret.env /etc/devdesk/env.local
rm -rf /var/lib/devdesk
fi
+1
View File
@@ -10,6 +10,7 @@ User=root
Environment=HOME=/root LANG=C.UTF-8
Environment=PATH=/root/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
EnvironmentFile=/etc/devdesk/env
EnvironmentFile=-/etc/devdesk/env.local
EnvironmentFile=/etc/devdesk/secret.env
ExecStart=/usr/lib/devdesk/devdesk-serve
Restart=always
+50 -3
View File
@@ -36,15 +36,62 @@ Then run `authelia validate-config --config /etc/authelia/configuration.yml && s
- **Cloudflare:** add a CNAME `dev` → apex, DNS-only (grey cloud).
- **Pi-hole:** add `"10.10.10.27 dev.lotusguild.org"` to `dns.hosts` in `pve-infra/containers/163-pihole/etc/pihole/pihole.toml`. A push auto-deploys it, and LAN clients then reach NPM directly instead of hairpinning.
## 5. Optional hardening
Enable the Proxmox firewall on CT 126 (`firewall=1` on net0) and allow tcp/7681 only from 10.10.10.27. The same rule suits code-server on :8680.
## 5. Auto-deploy on release
A `v*` tag makes CI publish the package, then send a signed webhook (the house HMAC pattern) to CT 126's existing `webhook` listener on :9000. The listener runs `/usr/lib/devdesk/devdesk-deploy <ref>`, which waits for that version in the apt registry and upgrades only `devdesk` with `--force-confold`. Running Claude sessions are untouched. The log is `/var/log/devdesk-deploy.log` (rotated monthly).
The hook answers when the deploy has finished, with the script's last line, and with HTTP 500 if it failed. CI's **Deploy to CT 126** job therefore fails when the install fails, and the failure alert goes to Matrix.
**Setup, done once:**
1. Generate a secret.
2. Add this hook to `/etc/webhook/hooks.json`, next to `126-config-deploy`. The file is local and untracked because it holds secrets.
```json
{
"id": "devdesk-deploy",
"execute-command": "/usr/lib/devdesk/devdesk-deploy",
"command-working-directory": "/",
"pass-arguments-to-command": [{"source": "payload", "name": "ref"}],
"include-command-output-in-response": true,
"include-command-output-in-response-on-error": true,
"trigger-rule": {"and": [
{"match": {"type": "payload-hash-sha256", "secret": "<WEBHOOK_SECRET>",
"parameter": {"source": "header", "name": "X-Gitea-Signature"}}},
{"match": {"type": "regex", "regex": "^refs/tags/v[0-9]+\\.[0-9]+\\.[0-9]+$",
"parameter": {"source": "payload", "name": "ref"}}}
]}
}
```
3. Run `systemctl restart webhook`.
4. Store the same secret as the devdesk repo's Actions secret `WEBHOOK_SECRET`.
## 6. Firewall (required)
The ttyd credential is the only thing between the LAN and a root shell, so only NPM may reach :7681. CT 126 ships this in pve-infra (`containers/126-codingenvironment/etc/nftables.conf`, installed by its `deploy.sh`):
```
table inet devdesk {
chain input {
type filter hook input priority filter - 10; policy accept;
tcp dport 7681 ip saddr { 127.0.0.1, 10.10.10.27 } accept
tcp dport 7681 ip6 saddr ::1 accept
tcp dport 7681 counter reject with tcp reset
}
}
```
Check from another LAN host: `curl -m3 http://10.10.10.58:7681/` must fail with "connection refused". To rotate the credential: `rm /etc/devdesk/secret.env && dpkg-reconfigure devdesk`, put the new `Authorization` value in NPM proxy host 58's Advanced tab, then `systemctl restart devdesk`.
## 7. Matrix "Claude needs you" (optional)
1. Deploy a hookshot transform named `Devdesk` (`hookshot/devdesk.js` in the matrix repo; pushing it to `main` auto-deploys it on LXC 151). Look up the hook UUID as described in that repo's README.
2. `umask 077; echo https://matrix.lotusguild.org/webhook/<uuid> > /etc/devdesk/matrix-webhook`.
3. Nothing to restart: the next wait that lasts `DEVDESK_MATRIX_DELAY` seconds with nobody attached posts a message.
## Operations
| Task | Command |
|---|---|
| Sessions | `devdesk ls` |
| Attach over SSH | `devdesk attach <name>` (or `devdesk pick`) |
| End a session | `devdesk end <name>` (or `[x]` in the Claude window's exit menu) |
| New session (optionally in a project) | `devdesk new <name> [project]`, or the Sessions panel form |
| Rename a session | `devdesk rename <old> <new>`, or ✎ in the Sessions panel |
| End a session | `devdesk end <name>`, × in the Sessions panel, or `[x]` in the Claude window's exit menu |
| Restart the web side (safe) | `systemctl restart devdesk` |
| **Stop every session** | `systemctl stop devdesk-tmux` |
| Logs | `journalctl -u devdesk -u devdesk-tmux` |
+14 -6
View File
@@ -7,6 +7,10 @@ devdesk \- persistent Claude Code sessions in tmux, shared by the browser and SS
.br
.B devdesk new
.I NAME
.RI [ PROJECT ]
.br
.B devdesk rename
.I OLD NEW
.br
.B devdesk ls
.br
@@ -26,16 +30,20 @@ resumes its own conversation.
.SH COMMANDS
.TP
.B attach \fR[\fINAME\fR]
Attach to session \fINAME\fR, creating it if needed. The default session
(\fBmain\fR) uses the projects root; other names map to the project directory of
that name or slug (\(lqtest code\(rq becomes \fBtest-code\fR). Without \fINAME\fR,
opens the picker.
Attach to session \fINAME\fR, creating it if needed. Names matching a project
folder (or its slug: \(lqtest code\(rq becomes \fBtest-code\fR) start there; any
other name starts in \fBDEVDESK_DEFAULT_DIR\fR. Without \fINAME\fR, opens the picker.
.TP
.B attach --list
Print sessions and projects as JSON (used by the web UI).
.TP
.B new \fINAME\fR
Create session \fINAME\fR detached, if it does not exist.
.B new \fINAME\fR [\fIPROJECT\fR]
Create session \fINAME\fR detached, in \fIPROJECT\fR's folder if given, otherwise
in the folder its name maps to (a project of that name, or \fBDEVDESK_DEFAULT_DIR\fR).
.TP
.B rename \fIOLD NEW\fR
Rename a running or dormant session; Claude keeps running and its recorded
conversation moves with it.
.TP
.B ls
List running (\(bu) and dormant (\(ci) sessions.
+5
View File
@@ -9,7 +9,12 @@
include /snippets/authelia-location.conf;
location / {
# Headers go here, not at server level: a location with its own add_header
# (HSTS) doesn't inherit the server's.
add_header Strict-Transport-Security $hsts_header always;
# No other site may frame the terminal (clickjacking).
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "frame-ancestors 'none'" always;
auth_request /authelia;
auth_request_set $user $upstream_http_remote_user;
+51 -3
View File
@@ -1,5 +1,9 @@
# DevDesk configuration. Read by systemd (EnvironmentFile=) and by the devdesk
# scripts. Plain KEY=value lines only: systemd does not support inline comments.
# DevDesk defaults, shipped by the package. Don't edit: put your changes in
# /etc/devdesk/env.local (same KEY=value format, read after this file), so
# package upgrades never prompt about this file.
# Read by systemd (EnvironmentFile=) and by the devdesk scripts, the same way:
# plain KEY=value lines, values taken literally (no $VAR, no $(…)), optionally
# in quotes. No inline comments. Other lines are ignored with a warning.
# The ttyd credential lives in /etc/devdesk/secret.env (generated on install).
# Port ttyd listens on (NPM proxies dev.lotusguild.org here).
@@ -12,9 +16,53 @@ DEVDESK_BIND=
DEVDESK_PROJECTS_ROOT=/root/code
DEVDESK_DEFAULT_SESSION=main
# Where sessions that are not a project folder start (e.g. ops sessions); empty = projects root.
DEVDESK_DEFAULT_DIR=
# Gitea for the Repo/CI panel. Public repos need no token; for private ones put a
# read:repository token in /etc/devdesk/gitea-token (mode 600).
DEVDESK_GITEA_URL=https://code.lotusguild.org
DEVDESK_GITEA_ALIASES=10.10.10.63
# Grafana for the Ops Alerts panel (firing/pending alert rules). A Viewer
# service-account token goes in DEVDESK_GRAFANA_TOKEN_FILE (mode 600).
DEVDESK_GRAFANA_URL=http://10.10.10.49:3000
DEVDESK_GRAFANA_PUBLIC_URL=https://dashboard.lotusguild.org
DEVDESK_GRAFANA_TOKEN_FILE=/etc/devdesk/grafana-token
# Proxmox nodes to ask where guests run (`devdesk guests`, and the CT/VM and node
# shown on each alert and given to Claude by "Ask Claude"). Space-separated SSH
# hosts; the first that answers gives the cluster view, then every online node is
# asked for its containers' IPs, by node name. Needs root SSH keys to the nodes.
# Empty = off.
DEVDESK_PVE_NODES=
# Claude Code binary and extra arguments for every session.
CLAUDE_BIN=/root/.local/bin/claude
CLAUDE_ARGS=
# 1 = also enable Remote Control so sessions can be continued from the Claude app.
# Where DevDesk is reached in a browser; used for links in Matrix notifications.
DEVDESK_PUBLIC_URL=https://dev.lotusguild.org
# "Claude needs you" in Matrix when a session waits and no tab or SSH client is
# attached: put a hookshot webhook URL in this file (mode 600). No file = off.
# Only real requests (a permission prompt, a question), never Claude's idle
# reminder, and never while a DevDesk tab is in view.
# DEVDESK_MATRIX_DELAY: seconds the wait must last before posting.
# DEVDESK_MATRIX_QUIET: at most one message per session in this many seconds.
DEVDESK_MATRIX_WEBHOOK_FILE=/etc/devdesk/matrix-webhook
DEVDESK_MATRIX_DELAY=60
DEVDESK_MATRIX_QUIET=600
# DEVDESK_USAGE_WARN: post to the same room once per 5-hour window when plan usage
# passes this percent while no DevDesk tab is in view (devdesk#45).
DEVDESK_USAGE_WARN=90
# 0 turns off the guard that makes Claude ask before destructive commands
# (pct/zfs/ceph destroy, rm -r /…, force pushes, reboots). Extra rules:
# /etc/devdesk/guard.local, one "regex<TAB>reason" per line.
DEVDESK_GUARD=1
# Remote Control default for new sessions (1 = on: continue them from the Claude app;
# Anthropic then stores the transcript to sync devices). Settings in the web UI
# overrides this, and each session can choose its own (devdesk#61).
DEVDESK_REMOTE_CONTROL=0
+56 -14
View File
@@ -7,10 +7,18 @@ set -uo pipefail
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
NAME=${DEVDESK_SESSION:?DEVDESK_SESSION is not set}
CLAUDE_BIN=${CLAUDE_BIN:-claude}
NAME=$(dd_current_session)
[ -n "$NAME" ] || dd_die "not running inside a DevDesk session"
SETTINGS="$DEVDESK_SHARE/claude-settings.json"
# Re-read /etc/devdesk/env before every Claude launch, so changes (Remote
# Control, CLAUDE_ARGS, …) apply on the next restart of Claude in a session.
reload_config() {
dd_load_env
CLAUDE_BIN=${CLAUDE_BIN:-claude}
read -r -a EXTRA <<<"${CLAUDE_ARGS:-}"
}
ORANGE=$'\033[38;2;255;107;0m'
CYAN=$'\033[38;2;0;212;255m'
@@ -19,7 +27,8 @@ RESET=$'\033[0m'
common_args() {
printf '%s\0' --settings "$SETTINGS"
if [ "${DEVDESK_REMOTE_CONTROL:-0}" = "1" ]; then
# Per session since devdesk#61 (Settings → Remote Control).
if [ "$(dd_rc_wanted "$NAME")" = 1 ]; then
printf '%s\0' --remote-control "devdesk-$NAME"
fi
if [ ${#EXTRA[@]} -gt 0 ]; then
@@ -28,30 +37,43 @@ common_args() {
}
run_new() {
local args
local args prompt=() file="$DEVDESK_STATE_DIR/prompts/$NAME.txt"
mapfile -d '' args < <(common_args)
"$CLAUDE_BIN" -n "$NAME" "${args[@]}"
# A first prompt left by `devdesk attach --investigate` (Ops Alerts → Ask
# Claude, devdesk#39): sent once, right after the session name so options
# in CLAUDE_ARGS can't swallow it.
if [ -s "$file" ]; then
prompt=("$(cat "$file")")
rm -f "$file"
fi
"$CLAUDE_BIN" -n "$NAME" "${prompt[@]}" "${args[@]}"
}
run_resume() {
local id args started rc
local id args fork=()
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"
# Start fresh only when the conversation's file is really gone. Any other
# failure (an early Ctrl+C, a network error) leaves the recorded id alone
# and ends at the menu, where ⏎ tries again (devdesk#12).
if [ -z "$(dd_convo_file "$id")" ]; then
printf "%sConversation %s is no longer in Claude's history — starting a new one.%s\n" "$MUTED" "$id" "$RESET"
sleep 1
run_new
return
fi
return "$rc"
mapfile -d '' args < <(common_args)
# "Fork" from conversation search (devdesk#24): continue as a new
# conversation, once; the SessionStart hook then records the new id.
if [ "$(dd_registry_get "$NAME" fork)" = true ]; then
fork=(--fork-session)
# shellcheck disable=SC2016 # $n is a jq variable
dd_registry_update 'del(.sessions[$n].fork)' --arg n "$NAME" 2>/dev/null || true
fi
"$CLAUDE_BIN" --resume "$id" "${fork[@]}" "${args[@]}"
}
menu() {
@@ -61,10 +83,30 @@ menu() {
}
printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\)
# While a DevDesk tab shows this session, this marker exists and Claude tells
# Remote Control a client is present, which holds back phone pushes (devdesk#26).
# Keyed by tmux's session id, which survives renames.
sid=$(dd_tmux display -p -t "${TMUX_PANE:-}" '#{session_id}' 2>/dev/null)
if [ -n "$sid" ]; then
export CLAUDE_CLIENT_PRESENCE_FILE="$DEVDESK_PRESENCE_DIR/${sid#\$}"
fi
mode=resume
while :; do
NAME=$(dd_current_session) # follow renames
reload_config
if [ "$mode" = new ]; then run_new; else run_resume; fi
rc=$?
NAME=$(dd_current_session)
# Settings changed Remote Control: Claude was stopped to restart on the same
# conversation with the new setting (devdesk#61), no menu.
restart="$DEVDESK_STATE_DIR/restart/$NAME"
if [ -e "$restart" ]; then
rm -f "$restart"
printf '\n%s◈ Restarting Claude with Remote Control %s…%s\n' "$MUTED" "$([ "$(dd_rc_wanted "$NAME")" = 1 ] && echo on || echo off)" "$RESET"
mode=resume
continue
fi
while :; do
menu "$rc"
IFS= read -rsn1 key || exit 0
+77
View File
@@ -0,0 +1,77 @@
#!/bin/bash
# Upgrade DevDesk from the LotusGuild apt registry. Run by the host's webhook
# listener (hook "devdesk-deploy") when CI publishes a v* tag; the tag's ref is
# passed as $1. Safe for running sessions: package upgrades restart only
# devdesk.service (ttyd), never devdesk-tmux.service where Claude lives.
#
# The hook runs this synchronously and returns its output, with HTTP 500 when
# it fails, so CI's "Deploy to CT 126" job shows the real result (devdesk#12).
set -euo pipefail
LOG=${DEVDESK_DEPLOY_LOG:-/var/log/devdesk-deploy.log}
SOURCES=${DEVDESK_APT_SOURCES:-sources.list.d/lotusguild.list}
log() {
printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$LOG"
}
# The outcome goes to the log and to stdout (the webhook response).
say() {
log "$*"
printf '%s\n' "$*"
}
fail() {
say "deploy FAILED: $*"
exit 1
}
on_error() {
local rc=$?
say "deploy FAILED: line $1 exited $rc (details in $LOG)"
exit "$rc"
}
trap 'on_error $LINENO' ERR
# Wait for dpkg/apt rather than fail when unattended-upgrades or a manual apt
# run holds the lock.
APT_OPTS=(-o DPkg::Lock::Timeout=120 -o Dpkg::Use-Pty=0)
exec 9>/run/devdesk-deploy.lock
flock -w 900 9 || fail "another deploy is still running"
want=${1:-}
want=${want#refs/tags/}
want=${want#v}
before=$(dpkg-query -W -f='${Version}' devdesk 2>/dev/null || echo none)
log "=== deploy: installed $before, release ${want:-latest}"
refresh() {
apt-get update -q "${APT_OPTS[@]}" -o Dir::Etc::sourcelist="$SOURCES" -o Dir::Etc::sourceparts=- \
-o APT::Get::List-Cleanup=0 >>"$LOG" 2>&1
}
# The registry can lag the release by a few seconds: wait for the tagged version.
candidate=""
for _ in $(seq 1 18); do
refresh || true
candidate=$(apt-cache policy devdesk | awk '/Candidate:/ {print $2}')
if [ -z "$want" ] || [ "$candidate" = "$want" ]; then
break
fi
sleep 10
done
if [ -n "$want" ] && [ "$candidate" != "$want" ]; then
fail "the registry still offers ${candidate:-nothing}, not $want"
fi
if [ "$candidate" = "$before" ]; then
say "already at $before; nothing to do"
exit 0
fi
DEBIAN_FRONTEND=noninteractive apt-get install -y -q --only-upgrade "${APT_OPTS[@]}" \
-o Dpkg::Options::=--force-confold devdesk </dev/null >>"$LOG" 2>&1
after=$(dpkg-query -W -f='${Version}' devdesk)
if [ -n "$want" ] && [ "$after" != "$want" ]; then
fail "installed $after, not $want"
fi
say "devdesk $before -> $after"
+274
View File
@@ -0,0 +1,274 @@
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
# File viewer commands (devdesk#19), sourced by bin/devdesk. Every path is
# relative to the session's folder and must stay inside it: no absolute paths,
# no newlines, no symlink escapes. Reached through `devdesk attach --fs-*`.
: "${DEVDESK_FS_MAX:=2097152}" # bytes the viewer opens
: "${DEVDESK_FS_LIST_MAX:=2000}" # entries per directory listing
: "${DEVDESK_ASSETS:=$DEVDESK_SHARE/assets}"
# Producers piped into `head` get SIGPIPE once the cap is reached; under
# pipefail that would fail the command and cut its reply short, so they are
# wrapped as `{ producer || true; }`.
# Blob replies: a JSON header line, then base64 between markers (no body for
# binary files the viewer can't show). The client waits for the END marker.
DD_BLOB_BEGIN=DEVDESK-BLOB-BEGIN
DD_BLOB_END=DEVDESK-BLOB-END
dd_session_root() {
dd_valid_name "$1" || dd_die "invalid session name: $1"
realpath -e -- "$(dd_resolve_dir "$1")" 2>/dev/null || dd_die "the folder of session '$1' is missing"
}
# dd_fs_path ROOT REL -> the real path of REL inside ROOT, or die.
dd_fs_path() {
local root=$1 rel=${2:-.} p
case $rel in
/* | *$'\n'* | *$'\r'*) dd_die "bad path: $rel" ;;
esac
[ -n "$rel" ] || rel=.
p=$(realpath -e -- "$root/$rel" 2>/dev/null) || dd_die "no such file: $rel"
case "$p/" in
"$root"/*) printf '%s\n' "$p" ;;
*) dd_die "outside the session folder: $rel" ;;
esac
}
dd_mime() {
case ${1,,} in
*.png) echo image/png ;;
*.jpg | *.jpeg) echo image/jpeg ;;
*.gif) echo image/gif ;;
*.webp) echo image/webp ;;
*.svg) echo image/svg+xml ;;
*.ico) echo image/x-icon ;;
*.bmp) echo image/bmp ;;
*.pdf) echo application/pdf ;;
*) echo "" ;;
esac
}
# dd_blob FILE LABEL: header + body for one file on disk.
dd_blob() {
local f=$1 label=$2 size mtime sha mime binary=false utf8=true body=true
read -r size mtime < <(stat -c '%s %Y' -- "$f")
mime=$(dd_mime "$f")
if [ "$size" -gt "$DEVDESK_FS_MAX" ]; then
jq -cMn --arg p "$label" --argjson s "$size" --argjson m "$mtime" --argjson max "$DEVDESK_FS_MAX" \
'{path: $p, size: $s, mtime: $m, tooLarge: true, max: $max}'
return
fi
if head -c 8192 -- "$f" | grep -qaP '\x00'; then
binary=true
[ -n "$mime" ] || body=false
elif ! iconv -f UTF-8 -t UTF-8 -- "$f" >/dev/null 2>&1; then
utf8=false
fi
sha=$(sha256sum -- "$f" | cut -d' ' -f1)
jq -cMn --arg p "$label" --argjson s "$size" --argjson m "$mtime" --arg h "$sha" \
--argjson b "$binary" --argjson u "$utf8" --arg mime "$mime" --argjson body "$body" \
'{path: $p, size: $s, mtime: $m, sha: $h, binary: $b, utf8: $u, body: $body,
mime: (if $mime == "" then null else $mime end)}'
printf '%s\n' "$DD_BLOB_BEGIN"
if $body; then base64 -w0 -- "$f"; fi
printf '\n%s\n' "$DD_BLOB_END"
}
# --fs-list SESSION [REL]: one directory level, folders first, .git skipped.
# In a git repository, entries .gitignore covers are flagged (the UI dims them).
cmd_fs_list() {
if [ $# -lt 1 ] || [ $# -gt 2 ]; then dd_die "usage: devdesk attach --fs-list SESSION [PATH]"; fi
local root dir rel=${2:-.} tmp ignored=""
root=$(dd_session_root "$1")
dir=$(dd_fs_path "$root" "$rel")
[ -d "$dir" ] || dd_die "not a folder: $rel"
tmp=$(mktemp)
# %y: type, %Y: type behind a symlink, %s: size, %T@: mtime, %f: name.
{ find "$dir" -mindepth 1 -maxdepth 1 ! -name .git -printf '%y\t%Y\t%s\t%T@\t%f\0' 2>/dev/null || true; } |
head -z -n "$((DEVDESK_FS_LIST_MAX + 1))" >"$tmp"
if git --no-optional-locks -C "$dir" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
ignored=$(tr '\0' '\n' <"$tmp" | cut -f5- | git --no-optional-locks -C "$dir" check-ignore --stdin 2>/dev/null || true)
fi
jq -cMRs --arg root "$root" --arg rel "${dir#"$root"}" --argjson max "$DEVDESK_FS_LIST_MAX" --arg ign "$ignored" '
($ign | split("\n") | map(select(length > 0) | rtrimstr("/")) | INDEX(.)) as $ignored
| (split("\u0000") | map(select(length > 0) | split("\t") |
{t: .[0], to: .[1], s: (.[2] | tonumber), m: (.[3] | tonumber | floor), n: (.[4:] | join("\t"))}
| if $ignored[.n] then . + {ig: true} else . end)) as $all
| {root: $root, dir: ($rel | ltrimstr("/")), truncated: ($all | length > $max),
entries: ($all[:$max] | sort_by([(if .t == "d" or .to == "d" then 0 else 1 end), (.n | ascii_downcase)]))}' <"$tmp"
rm -f "$tmp"
}
# --fs-find SESSION: every file under the session folder, for "go to file".
# git ls-files in a repository (tracked + untracked, .gitignore honoured), else
# find without node_modules, .git and build/cache folders; capped at
# DEVDESK_FS_FIND_MAX (10000).
cmd_fs_find() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --fs-find SESSION"
local root max=${DEVDESK_FS_FIND_MAX:-10000}
root=$(dd_session_root "$1")
if git --no-optional-locks -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
git --no-optional-locks -C "$root" ls-files -co --exclude-standard -z 2>/dev/null || true
else
(cd "$root" && find . -maxdepth 8 \( -name node_modules -o -name .git -o -name __pycache__ -o -name .venv -o -name venv \
-o -name dist -o -name build -o -name target -o -name .cache -o -name .next -o -name coverage \) -prune \
-o -type f -printf '%P\0' 2>/dev/null) || true
fi | head -z -n "$((max + 1))" |
jq -cMRs --argjson max "$max" --arg root "$root" \
'(split("\u0000") | map(select(length > 0))) as $f | {root: $root, truncated: ($f | length > $max), files: $f[:$max]}'
}
# --fs-read SESSION REL
cmd_fs_read() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-read SESSION PATH"
local root f
root=$(dd_session_root "$1")
f=$(dd_fs_path "$root" "$2")
[ -f "$f" ] || dd_die "not a file: $2"
dd_blob "$f" "${f#"$root"/}"
}
# --git-status SESSION: changed files under the session folder, with line counts
# and whether Claude edited them (from devdesk-state-hook's edit log).
cmd_git_status() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --git-status SESSION"
local root g top prefix branch edits
root=$(dd_session_root "$1")
edits="$DEVDESK_STATE_DIR/edits/$1.txt"
g=(git --no-optional-locks -C "$root")
if ! top=$("${g[@]}" rev-parse --show-toplevel 2>/dev/null); then
# Not a repository: only what Claude edited.
jq -cMn --arg root "$root" --rawfile e <(cat -- "$edits" 2>/dev/null || true) '
{git: false, root: $root, files: [$e | split("\n")[] | select(length > 0) | select(startswith($root + "/"))
| {path: ltrimstr($root + "/"), x: " ", y: " ", claude: true}]}'
return
fi
prefix=$("${g[@]}" rev-parse --show-prefix)
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
{
printf 'S\n'
{ "${g[@]}" status --porcelain=v1 -z --untracked-files=all -- . 2>/dev/null || true; } | head -z -n 1000 | tr '\0' '\n'
printf 'N\n'
{ "${g[@]}" diff --numstat HEAD -- . 2>/dev/null || true; } | head -n 1000
} | jq -cMRs --arg root "$root" --arg top "$top" --arg prefix "$prefix" --arg branch "$branch" \
--rawfile e <(cat -- "$edits" 2>/dev/null || true) '
(split("\nN\n") | .[0] | ltrimstr("S\n") | split("\n") | map(select(length > 2))) as $st
| (split("\nN\n") | .[1] // "" | split("\n") | map(select(length > 0) | split("\t")
| {key: .[2:] | join("\t"), value: {add: (.[0] | tonumber? // null), del: (.[1] | tonumber? // null)}}) | from_entries) as $num
| ($e | split("\n") | map(select(length > 0))) as $claude
# porcelain -z: "XY path", and after a rename/copy entry the next item is the old path.
| reduce range(0; $st | length) as $i ({skip: false, files: []};
if .skip then .skip = false
else ($st[$i]) as $line | ($line[3:]) as $p
| .files += [{repoPath: $p, x: $line[0:1], y: $line[1:2],
from: (if ($line[0:1] | test("[RC]")) then $st[$i + 1] else null end)}]
| .skip = ($line[0:1] | test("[RC]"))
end)
| .files | map(. + {path: (if (.repoPath | startswith($prefix)) then .repoPath | ltrimstr($prefix) else null end),
add: $num[.repoPath].add, del: $num[.repoPath].del,
claude: (($top + "/" + .repoPath) as $abs | any($claude[]; . == $abs))}) as $files
| {git: true, root: $root, branch: $branch,
files: ($files + [$claude[] | select(startswith($root + "/")) | ltrimstr($root + "/") as $p
| select(any($files[]; .path == $p) | not)
| {path: $p, repoPath: null, x: " ", y: " ", claude: true}])}'
}
# --git-show SESSION REL: the HEAD version of a file, for diffs. An empty body
# when HEAD doesn't have it (new file).
cmd_git_show() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --git-show SESSION PATH"
local root g top prefix tmp rel=$2
root=$(dd_session_root "$1")
case $rel in /* | *$'\n'* | *..*) dd_die "bad path: $rel" ;; esac
g=(git --no-optional-locks -C "$root")
top=$("${g[@]}" rev-parse --show-toplevel 2>/dev/null) || dd_die "not a git repository"
prefix=$("${g[@]}" rev-parse --show-prefix)
tmp=$(mktemp)
"${g[@]}" show "HEAD:$prefix$rel" >"$tmp" 2>/dev/null || : >"$tmp"
dd_blob "$tmp" "$rel"
rm -f "$tmp"
}
# --fs-write SESSION REL BYTES BASE (devdesk#20): save a file from the editor.
# The content arrives as base64 on stdin after the READY line (like uploads).
# BASE is the sha256 the editor loaded: if the file changed since, nothing is
# written and the reply is {conflict: true, sha: <current>}. BASE "new" creates
# a file (and missing folders) and refuses to overwrite one. Writes go to a
# temp file in the same folder, keep the mode and owner, and land with mv.
cmd_fs_write() {
[ $# -eq 4 ] || dd_die "usage: devdesk attach --fs-write SESSION PATH BYTES BASESHA|new"
local root rel=$2 size=$3 base=$4 target dir name tmp b64len actual current sha mtime
root=$(dd_session_root "$1")
if ! [[ $size =~ ^[0-9]+$ ]] || [ "$size" -gt "$DEVDESK_FS_MAX" ]; then
dd_die "too large: the editor saves files up to $((DEVDESK_FS_MAX / 1024)) KiB"
fi
[[ $base =~ ^([0-9a-f]{64}|new)$ ]] || dd_die "bad base: $base"
case $rel in /* | *$'\n'* | *$'\r'* | '' | */ | . | ..) dd_die "bad path: $rel" ;; esac
name=${rel##*/}
if [ "$name" = . ] || [ "$name" = .. ]; then dd_die "bad path: $rel"; fi
if [ "$base" = new ]; then
if [ -e "$root/$rel" ] || [ -L "$root/$rel" ]; then dd_die "already exists: $rel"; fi
case $rel in */*) dir=$root/${rel%/*} ;; *) dir=$root ;; esac
# Missing folders are created, but never through a symlink out of the
# root: check the deepest folder that exists before creating any.
local existing=$dir
while [ ! -e "$existing" ]; do existing=${existing%/*}; done
existing=${existing#"$root"}
dd_fs_path "$root" "${existing#/}" >/dev/null
mkdir -p -- "$dir" || dd_die "cannot create folder for $rel"
dir=${dir#"$root"}
dir=$(dd_fs_path "$root" "${dir#/}")
target="$dir/$name"
else
target=$(dd_fs_path "$root" "$rel")
[ -f "$target" ] || dd_die "not a file: $rel"
dir=${target%/*}
fi
tmp=$(mktemp --tmpdir="$dir" ".${name}.devdesk-XXXXXX") || dd_die "cannot write in ${dir#"$root"/}"
trap 'rm -f "$tmp"' HUP INT TERM
# shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose
b64len=$(((size + 2) / 3 * 4))
stty raw -echo 2>/dev/null || true
printf 'DEVDESK-WRITE-READY\r\n'
if [ "$b64len" -gt 0 ]; then
head -c "$b64len" | base64 -d >"$tmp" 2>/dev/null || true
fi
stty sane 2>/dev/null || true
actual=$(stat -c %s "$tmp" 2>/dev/null || echo 0)
if [ "$actual" != "$size" ]; then
rm -f "$tmp"
dd_die "save incomplete ($actual of $size bytes)"
fi
if [ "$base" = new ]; then
if [ -e "$target" ]; then
rm -f "$tmp"
dd_die "already exists: $rel"
fi
chmod 0644 "$tmp"
else
current=$(sha256sum -- "$target" | cut -d' ' -f1)
if [ "$current" != "$base" ]; then
rm -f "$tmp"
trap - HUP INT TERM
jq -cMn --arg p "$rel" --arg h "$current" '{ok: false, conflict: true, path: $p, sha: $h}'
return
fi
chmod --reference="$target" "$tmp" 2>/dev/null || true
chown --reference="$target" "$tmp" 2>/dev/null || true
fi
mv -f -- "$tmp" "$target" || { rm -f "$tmp"; dd_die "cannot replace $rel"; }
trap - HUP INT TERM
sha=$(sha256sum -- "$target" | cut -d' ' -f1)
mtime=$(stat -c %Y -- "$target")
jq -cMn --arg p "${target#"$root"/}" --arg h "$sha" --argjson s "$size" --argjson m "$mtime" \
'{ok: true, path: $p, sha: $h, size: $s, mtime: $m}'
}
# --asset NAME: a lazily loaded part of the web UI (the file viewer's editor),
# shipped in $DEVDESK_ASSETS and cached by the browser.
cmd_asset() {
if [ $# -ne 1 ] || ! [[ $1 =~ ^[a-z0-9-]+\.js$ ]]; then dd_die "usage: devdesk attach --asset NAME.js"; fi
[ -f "$DEVDESK_ASSETS/$1" ] || dd_die "no such asset: $1"
dd_blob "$DEVDESK_ASSETS/$1" "$1"
}
+269
View File
@@ -0,0 +1,269 @@
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
# Explorer file operations (devdesk#54), sourced by bin/devdesk and reached through
# `devdesk attach --fs-mkdir|--fs-move|--fs-copy|--fs-delete|--fs-restore|--fs-put|--fs-download`.
# Same rules as devdesk-files.sh: every path is relative to the session's folder
# and stays inside it. Entries are handled as themselves (a symlink is moved or
# deleted as a link, never followed), nothing is ever overwritten unless asked,
# and deleting moves to a per-session trash that can be restored for 7 days.
#
# set -e does not reach into $(...): every helper whose result is captured is
# followed by `|| exit 1`, or a failed check would carry on with an empty path.
: "${DEVDESK_TRASH_DAYS:=7}"
# A name the explorer may create: not empty, no slash, not . or .., no newline.
dd_fs_valid_name() {
case $1 in '' | . | .. | */* | *$'\n'* | *$'\r'*) return 1 ;; esac
return 0
}
# dd_fs_inside ROOT ABS: dies unless ABS is ROOT or inside it (a second guard,
# whatever the callers checked).
dd_fs_inside() {
{ [ -n "$1" ] && [ -n "$2" ]; } || dd_die "internal error: empty path"
case "$2/" in "$1"/*) ;; *) dd_die "outside the session folder: $2" ;; esac
}
# dd_fs_split REL -> sets DD_PARENT (relative, may be empty) and DD_NAME, or dies.
dd_fs_split() {
local rel=$1 part
case $rel in /* | *$'\n'* | *$'\r'* | '' | */) dd_die "bad path: $rel" ;; esac
local IFS=/
for part in $rel; do
{ [ -n "$part" ] && [ "$part" != . ] && [ "$part" != .. ]; } || dd_die "bad path: $rel"
done
DD_NAME=${rel##*/}
if [[ $rel == */* ]]; then DD_PARENT=${rel%/*}; else DD_PARENT=""; fi
}
# dd_fs_entry ROOT REL -> the absolute path of the entry itself (its folder
# resolved, the last component not followed), which must exist and isn't ROOT.
dd_fs_entry() {
local root=$1 dir
dd_fs_split "$2"
dir=$(dd_fs_path "$root" "${DD_PARENT:-.}") || exit 1
[ -d "$dir" ] || dd_die "not a folder: $DD_PARENT"
{ [ -e "$dir/$DD_NAME" ] || [ -L "$dir/$DD_NAME" ]; } || dd_die "no such file: $2"
dd_fs_inside "$root" "$dir"
printf '%s/%s\n' "$dir" "$DD_NAME"
}
# dd_fs_mkdirs ROOT RELDIR -> creates the missing folders of RELDIR one level at
# a time, each checked to be a real folder inside ROOT (never through a symlink
# out of it), and prints the absolute folder.
dd_fs_mkdirs() {
local root=$1 rel=$2 cur part
cur=$root
[ -n "$rel" ] || { printf '%s\n' "$root"; return; }
local IFS=/
for part in $rel; do
dd_fs_valid_name "$part" || dd_die "bad folder name: $part"
if [ ! -e "$cur/$part" ] && [ ! -L "$cur/$part" ]; then
mkdir -- "$cur/$part" || dd_die "cannot create folder: $part"
fi
cur=$(realpath -e -- "$cur/$part" 2>/dev/null) || dd_die "cannot use folder: $part"
case "$cur/" in "$root"/*) ;; *) dd_die "outside the session folder: $rel" ;; esac
[ -d "$cur" ] || dd_die "not a folder: $part"
done
printf '%s\n' "$cur"
}
# dd_fs_dest ROOT REL -> absolute path for a new entry REL (folders above it are
# created); dies if something is already there.
dd_fs_dest() {
local root=$1 dir
dd_fs_split "$2"
dir=$(dd_fs_mkdirs "$root" "$DD_PARENT") || exit 1
if [ -e "$dir/$DD_NAME" ] || [ -L "$dir/$DD_NAME" ]; then dd_die "already exists: $2"; fi
dd_fs_inside "$root" "$dir"
printf '%s/%s\n' "$dir" "$DD_NAME"
}
# --fs-mkdir SESSION REL: a new folder (and any missing ones above it).
cmd_fs_mkdir() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-mkdir SESSION PATH"
local root dest
root=$(dd_session_root "$1") || exit 1
dest=$(dd_fs_dest "$root" "$2") || exit 1
mkdir -- "$dest" || dd_die "cannot create folder: $2"
jq -cMn --arg p "${dest#"$root"/}" '{ok: true, path: $p}'
}
# --fs-move SESSION FROM TO / --fs-copy SESSION FROM TO: TO is the full new path.
# Never onto something that exists, never a folder into itself.
dd_fs_transfer() {
local op=$1 session=$2 from=$3 to=$4 root src dest
root=$(dd_session_root "$session") || exit 1
src=$(dd_fs_entry "$root" "$from") || exit 1
dest=$(dd_fs_dest "$root" "$to") || exit 1
case "$dest/" in "$src"/*) dd_die "cannot put a folder inside itself: $to" ;; esac
if [ "$op" = move ]; then
mv -T -n -- "$src" "$dest" 2>/dev/null || dd_die "cannot move $from"
if [ -e "$src" ] || [ -L "$src" ]; then dd_die "cannot move $from"; fi
else
cp -a -T -n -- "$src" "$dest" 2>/dev/null || { rm -rf -- "$dest"; dd_die "cannot copy $from"; }
fi
jq -cMn --arg f "${src#"$root"/}" --arg t "${dest#"$root"/}" '{ok: true, from: $f, path: $t}'
}
cmd_fs_move() {
[ $# -eq 3 ] || dd_die "usage: devdesk attach --fs-move SESSION FROM TO"
dd_fs_transfer move "$@"
}
cmd_fs_copy() {
[ $# -eq 3 ] || dd_die "usage: devdesk attach --fs-copy SESSION FROM TO"
dd_fs_transfer copy "$@"
}
dd_trash_dir() {
printf '%s/trash/%s\n' "$DEVDESK_STATE_DIR" "$1"
}
# --fs-delete SESSION REL...: moves the entries to the session's trash
# ($DEVDESK_STATE_DIR/trash/SESSION/ID, kept DEVDESK_TRASH_DAYS days) and prints
# {ok, trash: ID, paths} for --fs-restore.
cmd_fs_delete() {
[ $# -ge 2 ] || dd_die "usage: devdesk attach --fs-delete SESSION PATH..."
local session=$1 root trash id rel src i=0 entries=()
shift
root=$(dd_session_root "$session") || exit 1
# Check every path before moving any.
for rel in "$@"; do
src=$(dd_fs_entry "$root" "$rel") || exit 1
entries+=("$src")
done
trash=$(dd_trash_dir "$session") || exit 1
mkdir -p -- "$trash" && chmod 700 "$DEVDESK_STATE_DIR/trash" "$trash" 2>/dev/null
find "$trash" -mindepth 1 -maxdepth 1 -mtime +"$DEVDESK_TRASH_DAYS" -exec rm -rf -- {} + 2>/dev/null || true
id=$(date +%Y%m%d-%H%M%S)-$RANDOM
mkdir -- "$trash/$id" || dd_die "cannot use the trash"
local done_paths=()
for src in "${entries[@]}"; do
mv -T -- "$src" "$trash/$id/$i" 2>/dev/null || break
done_paths+=("${src#"$root"/}")
i=$((i + 1))
done
printf '%s\0' "${done_paths[@]}" | jq -cMRs --arg root "$root" '{root: $root, paths: (split("\u0000") | map(select(length > 0)))}' >"$trash/$id/manifest.json"
[ "${#done_paths[@]}" -eq "$#" ] || dd_die "deleted ${#done_paths[@]} of $#; the rest is still there (trash $id)"
jq -cMn --arg id "$id" --slurpfile m "$trash/$id/manifest.json" '{ok: true, trash: $id, paths: $m[0].paths}'
}
# --fs-restore SESSION ID: puts a deletion back where it was (if nothing has
# taken those paths since); prints {ok, paths}.
cmd_fs_restore() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-restore SESSION ID"
local session=$1 id=$2 root dir rel i=0 restored=() dest
[[ $id =~ ^[0-9]{8}-[0-9]{6}-[0-9]+$ ]] || dd_die "bad trash id: $id"
root=$(dd_session_root "$session") || exit 1
dir="$(dd_trash_dir "$session")/$id"
[ -f "$dir/manifest.json" ] || dd_die "nothing to restore (emptied after $DEVDESK_TRASH_DAYS days?)"
[ "$(jq -r .root "$dir/manifest.json")" = "$root" ] || dd_die "that deletion was in another folder"
while IFS= read -r -d '' rel; do
if [ -e "$dir/$i" ] || [ -L "$dir/$i" ]; then
dest=$(dd_fs_dest "$root" "$rel") || exit 1
mv -T -- "$dir/$i" "$dest" || dd_die "cannot restore $rel"
restored+=("$rel")
fi
i=$((i + 1))
done < <(jq -j '.paths[] | ., "\u0000"' "$dir/manifest.json")
rm -rf -- "$dir"
printf '%s\0' "${restored[@]}" | jq -cMRs '{ok: true, paths: (split("\u0000") | map(select(length > 0)))}'
}
# --fs-put SESSION REL BYTES new|replace: upload any file (up to
# DEVDESK_UPLOAD_MAX) to REL, creating missing folders. "new" refuses to replace
# anything; "replace" overwrites a regular file. The content arrives as base64
# after the READY line, into a temp file next to the target, moved into place.
cmd_fs_put() {
[ $# -eq 4 ] || dd_die "usage: devdesk attach --fs-put SESSION PATH BYTES new|replace"
local root rel=$2 size=$3 mode=$4 dir target tmp b64len actual
root=$(dd_session_root "$1") || exit 1
{ [[ $size =~ ^[0-9]+$ ]] && [ "$size" -le "$DEVDESK_UPLOAD_MAX" ]; } ||
dd_die "larger than $((DEVDESK_UPLOAD_MAX / 1048576)) MB"
case $mode in new | replace) ;; *) dd_die "mode is new or replace" ;; esac
dd_fs_split "$rel"
dir=$(dd_fs_mkdirs "$root" "$DD_PARENT") || exit 1
dd_fs_inside "$root" "$dir"
target="$dir/$DD_NAME"
if [ -e "$target" ] || [ -L "$target" ]; then
[ "$mode" = replace ] || dd_die "already exists: $rel"
{ [ -f "$target" ] && [ ! -L "$target" ]; } || dd_die "not a regular file: $rel"
fi
tmp=$(mktemp --tmpdir="$dir" ".${DD_NAME:0:100}.devdesk-XXXXXX") || dd_die "cannot write in ${dir#"$root"/}"
trap 'rm -f "$tmp"' HUP INT TERM
# shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose
b64len=$(((size + 2) / 3 * 4))
stty raw -echo 2>/dev/null || true
printf 'DEVDESK-PUT-READY\r\n'
if [ "$b64len" -gt 0 ]; then head -c "$b64len" | base64 -d >"$tmp" 2>/dev/null || true; fi
stty sane 2>/dev/null || true
actual=$(stat -c %s "$tmp" 2>/dev/null || echo 0)
if [ "$actual" != "$size" ]; then
rm -f "$tmp"
dd_die "upload incomplete ($actual of $size bytes)"
fi
if [ -f "$target" ]; then
chmod --reference="$target" "$tmp" 2>/dev/null || true
chown --reference="$target" "$tmp" 2>/dev/null || true
mv -f -- "$tmp" "$target" || { rm -f "$tmp"; dd_die "cannot replace $rel"; }
else
chmod 0644 "$tmp"
# -n: something created meanwhile is not replaced.
mv -n -T -- "$tmp" "$target" 2>/dev/null || true
if [ -e "$tmp" ]; then
rm -f "$tmp"
dd_die "already exists: $rel"
fi
fi
trap - HUP INT TERM
jq -cMn --arg p "${target#"$root"/}" --argjson s "$size" '{ok: true, path: $p, size: $s}'
}
# --fs-download SESSION REL: any regular file up to DEVDESK_UPLOAD_MAX, as a blob
# with its body whatever the type (the browser saves it).
cmd_fs_download() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --fs-download SESSION PATH"
local root f size
root=$(dd_session_root "$1") || exit 1
f=$(dd_fs_path "$root" "$2") || exit 1
[ -f "$f" ] || dd_die "not a file: $2"
size=$(stat -c %s -- "$f")
[ "$size" -le "$DEVDESK_UPLOAD_MAX" ] || dd_die "larger than $((DEVDESK_UPLOAD_MAX / 1048576)) MB"
jq -cMn --arg p "${f#"$root"/}" --argjson s "$size" '{path: $p, size: $s, body: true, download: true}'
printf '%s\n' "$DD_BLOB_BEGIN"
base64 -w0 -- "$f"
printf '\n%s\n' "$DD_BLOB_END"
}
# --fs-grep SESSION FLAGS QUERY (devdesk#56): search the session's files. FLAGS
# is "-" or letters: i (ignore case), r (regular expression, else literal),
# w (whole word). git grep in a repository (tracked + untracked, .gitignore
# honoured), else grep without node_modules/.git/build folders. Binary files are
# skipped; at most DEVDESK_GREP_MAX (2000) lines, each cut to 400 characters.
cmd_fs_grep() {
[ $# -eq 3 ] || dd_die "usage: devdesk attach --fs-grep SESSION FLAGS QUERY"
local root flags=$2 query=$3 max=${DEVDESK_GREP_MAX:-2000} opts=()
root=$(dd_session_root "$1") || exit 1
[[ $flags =~ ^(-|[irw]+)$ ]] || dd_die "flags are i, r and w"
{ [ -n "$query" ] && [ "${#query}" -le 500 ]; } || dd_die "search for 1 to 500 characters"
[[ $flags == *i* ]] && opts+=(-i)
[[ $flags == *w* ]] && opts+=(-w)
if [[ $flags == *r* ]]; then opts+=(-E); else opts+=(-F); fi
{
if git --no-optional-locks -C "$root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
(cd "$root" && timeout 20 git --no-optional-locks grep -n -I -z --untracked --exclude-standard "${opts[@]}" -e "$query" -- . 2>/dev/null) || true
else
(cd "$root" && timeout 20 grep -r -n -I -Z "${opts[@]}" \
--exclude-dir=node_modules --exclude-dir=.git --exclude-dir=__pycache__ --exclude-dir=.venv --exclude-dir=venv \
--exclude-dir=dist --exclude-dir=build --exclude-dir=target --exclude-dir=.cache --exclude-dir=.next \
--exclude-dir=coverage --exclude-dir=.worktrees -e "$query" . 2>/dev/null) || true
fi
} | head -n "$((max + 1))" |
jq -cMRs --arg root "$root" --argjson max "$max" '
# git grep -z: path NUL line NUL text; grep -Z: path NUL line:text
[split("\n")[] | select(length > 0) | split("\u0000") | select(length >= 2)
| if length >= 3 then {p: .[0], l: (.[1] | tonumber? // 0), t: (.[2:] | join(" "))}
else (.[1] | index(":")) as $i | select($i != null)
| {p: .[0], l: (.[1][:$i] | tonumber? // 0), t: .[1][($i + 1):]} end
| {p: (.p | ltrimstr("./")), l, t: (.t | .[:400])}] as $m
| {root: $root, truncated: ($m | length > $max), matches: $m[:$max]}'
}
+54
View File
@@ -0,0 +1,54 @@
#!/bin/bash
# Claude Code PreToolUse hook for Bash (wired in via claude-settings.json):
# commands that destroy things (containers, VMs, pools, disks, root-level
# folders, cluster members…) get permissionDecision "ask", so Claude has to get
# your OK even in auto mode (devdesk#27). It never blocks and never allows on
# its own; anything else passes through untouched.
#
# DEVDESK_GUARD=0 in /etc/devdesk/env.local turns it off. Extra rules go in
# /etc/devdesk/guard.local, one per line: an extended regex, a tab, a reason.
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" 2>/dev/null || exit 0
[ "${DEVDESK_GUARD:-1}" != 0 ] || exit 0
command=$(jq -rM '.tool_input.command // empty' 2>/dev/null) || exit 0
[ -n "$command" ] || exit 0
# Built-in rules: extended regex <TAB> reason.
rules=$(
cat <<'RULES'
\bpct[[:space:]]+destroy\b destroys a Proxmox container
\bqm[[:space:]]+destroy\b destroys a Proxmox VM
\bpvecm[[:space:]]+delnode\b removes a node from the Proxmox cluster
\bpveceph[[:space:]]+(osd|pool|mon|mgr|mds|fs)[[:space:]]+destroy\b destroys Ceph components
\bceph[[:space:]]+osd[[:space:]]+(purge|destroy|rm|out)\b removes or takes out a Ceph OSD
\bceph[[:space:]]+osd[[:space:]]+pool[[:space:]]+(delete|rm)\b deletes a Ceph pool
\bceph[[:space:]]+(fs[[:space:]]+rm|mon[[:space:]]+remove|auth[[:space:]]+del)\b removes Ceph components
\brbd[[:space:]]+(rm|remove|purge)\b deletes RBD images
\bzfs[[:space:]]+destroy\b destroys a ZFS dataset or snapshot
\bzpool[[:space:]]+(destroy|labelclear)\b destroys a ZFS pool
\b(lvremove|vgremove|pvremove|wipefs|shred)\b wipes volumes or disks
\bmkfs(\.[a-z0-9]+)?\b formats a filesystem
\bsgdisk[[:space:]]+(-[a-zA-Z]*Z|--zap) wipes a partition table
\bdd\b[^|;&]*\bof=/dev/ writes raw to a device
\brm[[:space:]]+(-[^[:space:]]*[[:space:]]+)*-[^[:space:]]*[rR][^[:space:]]*[[:space:]]+(-[^[:space:]]*[[:space:]]+)*['"]?/([a-z0-9_.*-]+/?)?['"]?([[:space:];&|)]|$) recursively deletes / or a top-level folder
\bgit[[:space:]]+push\b[^|;&]*([[:space:]]-f\b|--force) force-pushes (rewrites remote history)
\b(DROP[[:space:]]+(DATABASE|SCHEMA|TABLE)|TRUNCATE[[:space:]]+TABLE)\b drops database objects
\bsystemctl[[:space:]]+(stop|restart|kill|disable|mask)[[:space:]]+devdesk-tmux ends every DevDesk session
(^|[;&|][[:space:]]*|\bssh[[:space:]]+[^[:space:]]+[[:space:]]+['"]?|\bsystemctl[[:space:]]+)(reboot|poweroff|halt|shutdown)\b reboots or shuts down a machine
\bkubectl[[:space:]]+delete\b deletes Kubernetes resources
\bdocker[[:space:]]+(system[[:space:]]+prune|volume[[:space:]]+(rm|prune))\b deletes Docker data
RULES
cat "${DEVDESK_GUARD_LOCAL:-/etc/devdesk/guard.local}" 2>/dev/null
)
while IFS=$'\t' read -r pattern reason; do
if [ -z "$pattern" ] || [ "${pattern:0:1}" = "#" ]; then continue; fi
if grep -Eiq -- "$pattern" <<<"$command" 2>/dev/null; then
jq -cMn --arg r "DevDesk guard: this command ${reason:-matches a rule in guard.local}. Confirm before it runs." \
'{hookSpecificOutput: {hookEventName: "PreToolUse", permissionDecision: "ask", permissionDecisionReason: $r}}'
exit 0
fi
done <<<"$rules"
exit 0
+144
View File
@@ -0,0 +1,144 @@
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
# Past conversations (devdesk#24) and client presence (devdesk#26), sourced by
# bin/devdesk and reached through `devdesk attach --search|--resume-convo|--presence`.
# --search QUERY: full-text search over every Claude conversation on this host
# (DevDesk and code-server alike). Case-insensitive, plain text. Newest first;
# per conversation: title, folder, dates and up to 3 snippets (what you asked,
# Claude's answers, commands it ran, their output).
cmd_search() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --search QUERY"
local query=$1 dir files f
if [ ${#query} -lt 2 ] || [ ${#query} -gt 200 ] || [[ $query == *$'\n'* ]]; then dd_die "search for 2 to 200 characters"; fi
dir=$(dd_projects_dir)
# Files that mention it at all (grep is fast even over hundreds of MB), newest first.
mapfile -t files < <({ LC_ALL=C grep -rlFi --include='*.jsonl' -- "$query" "$dir" 2>/dev/null || true; } |
while IFS= read -r f; do printf '%s\t%s\n' "$(stat -c %Y "$f")" "$f"; done | sort -rn | head -n 30 | cut -f2-)
# Six conversations at a time (each is a grep and a jq over large lines).
local tmp i=0
tmp=$(mktemp -d)
for f in "${files[@]}"; do
dd_search_one "$query" "$f" >"$tmp/$(printf '%03d' "$i")" &
i=$((i + 1))
if [ $((i % 6)) -eq 0 ]; then wait; fi
done
wait
{ cat "$tmp"/* 2>/dev/null || true; } | jq -cMs --arg q "$query" '{query: $q, results: [.[] | select(. != null)][:25]}'
rm -rf "$tmp"
}
# One conversation's search result (or nothing), for cmd_search.
dd_search_one() {
local query=$1 f=$2
{ LC_ALL=C grep -Fi -m 30 -- "$query" "$f" || true; } | jq -cMs --arg q "$query" --arg id "$(basename "$f" .jsonl)" \
--arg mtime "$(stat -c %Y "$f")" --slurpfile head <(head -n 80 "$f" | jq -cM 'select(.type == "custom-title" or .type == "user" or .cwd)' 2>/dev/null | jq -cMs .) '
def texts:
if .type == "user" then
(.message.content | if type == "string" then [{role: "you", text: .}]
else [.[]? | if .type == "text" then {role: "you", text: .text}
elif .type == "tool_result" then {role: "output", text: (.content | if type == "string" then . else ([.[]? | .text? // empty] | join("\n")) end)}
else empty end] end)
elif .type == "assistant" then
[.message.content[]? | if .type == "text" then {role: "claude", text: .text}
elif .type == "tool_use" then {role: "command", text: (.input.command // .input.file_path // .input.url // .input.pattern // empty)}
else empty end]
else [] end;
($q | ascii_downcase) as $lq
| [.[] | select(.isSidechain != true) | . as $e | texts[] | select(.text | type == "string")
| (.text | ascii_downcase | index($lq)) as $i | select($i != null)
| {role, at: $e.timestamp,
snippet: ((if $i > 90 then "…" else "" end) + (.text[([$i - 90, 0] | max):($i + ($q | length) + 140)] | gsub("\\s+"; " "))
+ (if (.text | length) > $i + ($q | length) + 140 then "…" else "" end))}] as $m
| select($m | length > 0)
| ($head[0] // []) as $h
| {id: $id,
title: (([$h[] | select(.type == "custom-title") | .customTitle] | last)
// ([$h[] | select(.type == "user" and (.message.content | type == "string") and (.message.content | startswith("<") | not)) | .message.content] | first | .[0:120])
// "untitled"),
prompt: ([$h[] | select(.type == "user" and (.message.content | type == "string") and (.message.content | startswith("<") | not)) | .message.content] | first | .[0:160]),
cwd: ([$h[] | .cwd // empty] | first),
started: ([$h[] | .timestamp // empty] | first),
updated: ($mtime | tonumber),
hits: ($m | length), matches: ($m[:3])}' 2>/dev/null
}
# --resume-convo NAME ID [fork]: open a past conversation in a new session NAME,
# in the folder it ran in. "fork" continues it as a new conversation and leaves
# the original untouched (claude --fork-session).
cmd_resume_convo() {
if [ $# -lt 2 ] || [ $# -gt 3 ]; then dd_die "usage: devdesk attach --resume-convo NAME ID [fork]"; fi
local name=$1 id=$2 fork=${3:-} file dir
dd_valid_name "$name" || dd_die "invalid session name: $name"
if [ -n "$fork" ] && [ "$fork" != fork ]; then dd_die "the third argument can only be: fork"; fi
file=$(dd_convo_file "$id") || dd_die "no conversation $id"
[ -n "$file" ] || dd_die "no conversation $id"
if dd_server_up && dd_tmux has-session -t "=$name" 2>/dev/null; then dd_die "a session called $name is already running"; fi
# Two Claudes on one transcript would interleave it: resuming one that is
# open elsewhere is refused (forking is fine).
if [ -z "$fork" ]; then
local other
for other in $(jq -rM --arg id "$id" '.sessions | to_entries[] | select(.value.claude_session_id == $id) | .key' "$DEVDESK_REGISTRY" 2>/dev/null); do
if [ "$other" != "$name" ] && dd_tmux has-session -t "=$other" 2>/dev/null; then
dd_die "that conversation is open in session $other: open it there, or fork it"
fi
done
fi
dir=$(jq -rM 'select(.cwd) | .cwd' "$file" 2>/dev/null | head -n1)
[ -n "$dir" ] && [ -d "$dir" ] || dir=$(dd_resolve_dir "$name")
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, claude_session_id: $id, claude_source: "devdesk-history"}
+ (if $fork == "fork" then {fork: true} else {} end))' \
--arg n "$name" --arg d "$dir" --arg id "$id" --arg fork "$fork"
dd_ensure_server
create_detached "$name" "$dir"
}
# --presence SESSION on|off (devdesk#26): while a DevDesk tab shows a session,
# its Claude sees a client-presence marker (CLAUDE_CLIENT_PRESENCE_FILE, set by
# devdesk-claude-run per tmux session) and Remote Control holds back phone
# pushes. The browser refreshes it every ~45 s; it disappears ~2 minutes after
# the last refresh, so pushes resume once you walk away.
cmd_presence() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --presence SESSION on|off"
local session=$1 state=$2 sid marker
dd_valid_name "$session" || dd_die "invalid session name: $session"
dd_server_up || return 0
sid=$(dd_tmux display -p -t "=$session:" '#{session_id}' 2>/dev/null) || return 0
marker="$DEVDESK_PRESENCE_DIR/${sid#\$}"
if [ "$state" = off ]; then
rm -f "$marker"
return 0
fi
[ "$state" = on ] || dd_die "presence is on or off"
mkdir -p "$DEVDESK_PRESENCE_DIR" && touch "$marker"
# Expire it unless refreshed meanwhile.
# (fd 3 closed too: a reaper must not hold a caller's extra descriptors open)
setsid -f bash -c 'sleep 120; [ -n "$(find "$1" -newermt "-110 seconds" 2>/dev/null)" ] || rm -f "$1"' _ "$marker" \
</dev/null >/dev/null 2>&1 3>&- || true
}
# --delete-convo ID (devdesk#61): permanently delete a conversation's local files
# (the transcript ~/.claude/projects/<folder>/<id>.jsonl and its <id>/ folder of
# subagent transcripts and tool results). Refused while a running session uses
# it. Dormant sessions that would resume it forget it (they start fresh).
# Copies kept by Anthropic (Remote Control, the API) are not touched: those are
# deleted at claude.ai.
cmd_delete_convo() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --delete-convo ID"
local id=$1 file dir other
[[ $id =~ ^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$ ]] || dd_die "bad conversation id: $id"
file=$(dd_convo_file "$id") || true
{ [ -n "$file" ] && [ -f "$file" ]; } || dd_die "no conversation $id"
case "$file" in "$(dd_projects_dir)"/*/"$id.jsonl") ;; *) dd_die "unexpected path: $file" ;; esac
for other in $(jq -rM --arg id "$id" '.sessions | to_entries[] | select(.value.claude_session_id == $id) | .key' "$DEVDESK_REGISTRY" 2>/dev/null); do
if dd_server_up && dd_tmux has-session -t "=$other" 2>/dev/null; then
dd_die "session $other is using this conversation: end it first"
fi
done
dir=${file%.jsonl}
rm -f -- "$file"
[ ! -d "$dir" ] || rm -rf -- "$dir"
# shellcheck disable=SC2016 # jq variables
dd_registry_update '.sessions |= with_entries(if .value.claude_session_id == $id then .value |= (del(.claude_session_id) | del(.claude_source)) else . end)' \
--arg id "$id"
jq -cMn --arg id "$id" '{ok: true, deleted: $id}'
}
+134
View File
@@ -0,0 +1,134 @@
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
# Answer a session without switching to it (devdesk#22, #23), sourced by
# bin/devdesk and reached through `devdesk attach --answer|--peek|--reply`.
dd_state_file() {
printf '%s/%s.json\n' "$DEVDESK_STATUS_DIR" "$1"
}
# The Claude window's visible screen (not the scrollback).
dd_claude_screen() {
dd_tmux capture-pane -p -J -t "=$1:claude" 2>/dev/null
}
# --answer SESSION SINCE allow|deny: press the permission prompt's key in that
# session's Claude window. Only when the same wait is still on (SINCE) and the
# prompt is actually on screen, so a stale button never types into Claude.
cmd_answer() {
[ $# -eq 3 ] || dd_die "usage: devdesk attach --answer SESSION SINCE allow|deny"
local session=$1 since=$2 choice=$3 file
dd_valid_name "$session" || dd_die "invalid session name: $session"
[[ $since =~ ^[0-9]+$ ]] || dd_die "bad wait id: $since"
case $choice in allow | deny) ;; *) dd_die "answer allow or deny" ;; esac
if ! { dd_server_up && dd_tmux has-session -t "=$session" 2>/dev/null; }; then dd_die "session '$session' isn't running"; fi
file=$(dd_state_file "$session")
if [ "$(jq -rM '"\(.state) \(.since) \(.request != null)"' "$file" 2>/dev/null)" != "waiting $since true" ]; then
dd_die "nothing to answer: it was already answered"
fi
# Every permission prompt lists "1. Yes" first (Bash, edits, fetches, MCP).
if ! dd_claude_screen "$session" | tail -n 40 | grep -Eq '^[[:space:]]*(❯[[:space:]]*)?1\.[[:space:]]+Yes'; then
dd_die "the permission prompt isn't on screen any more"
fi
if [ "$choice" = allow ]; then
dd_tmux send-keys -t "=$session:claude" 1
else
dd_tmux send-keys -t "=$session:claude" Escape
fi
# A denial doesn't fire a hook; the turn ends there. An approval is followed
# by PostToolUse, which takes it from here.
local tmp
tmp=$(mktemp "$DEVDESK_STATUS_DIR/.$session.XXXXXX") || return 0
if jq -cMn --arg s "$([ "$choice" = allow ] && echo working || echo idle)" --arg m "$([ "$choice" = allow ] && echo "Allowed from DevDesk" || echo "Denied from DevDesk")" \
'{state: $s, since: (now | floor), event: "DevDeskAnswer", message: $m}' >"$tmp"; then
mv -f "$tmp" "$file"
else
rm -f "$tmp"
fi
}
# The session's conversation file, from the id the SessionStart hook recorded.
dd_transcript() {
local id
id=$(dd_registry_get "$1" claude_session_id)
dd_convo_file "$id"
}
# --peek SESSION: what the session is doing and the last exchange, from the end
# of its transcript (the last assistant text and the last thing you asked).
cmd_peek() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --peek SESSION"
local session=$1 transcript status commands
dd_valid_name "$session" || dd_die "invalid session name: $session"
status=$(jq -cM . "$(dd_state_file "$session")" 2>/dev/null) || status=null
# The last commands this session ran (devdesk#27).
commands=$(tail -n 8 "$DEVDESK_STATE_DIR/commands/$session.jsonl" 2>/dev/null | jq -cMs '.' 2>/dev/null) || commands='[]'
[ -n "$commands" ] || commands='[]'
transcript=$(dd_transcript "$session") || transcript=""
if [ -z "$transcript" ] || [ ! -r "$transcript" ]; then
jq -cMn --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '{session: $s, status: $st, assistant: null, user: null, commands: $c}'
return
fi
tail -n 400 -- "$transcript" | jq -cMs --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '
def text: if type == "string" then . else ([.[]? | select(.type == "text") | .text] | join("\n")) end;
[.[] | select(.isSidechain != true and .isMeta != true)] as $all
| ([$all[] | select(.type == "assistant") | {text: (.message.content | text), at: .timestamp} | select(.text != "")] | last) as $a
| ([$all[] | select(.type == "user" and (.message.content | type == "string" or (any(.[]?; .type == "text"))))
| {text: (.message.content | text), at: .timestamp}
| select(.text != "" and (.text | startswith("<") | not))] | last) as $u
| {session: $s, status: $st,
assistant: (if $a then {text: ($a.text | .[-4000:]), at: $a.at, truncated: ($a.text | length > 4000)} else null end),
user: (if $u then {text: ($u.text | .[-600:]), at: $u.at} else null end), commands: $c}' 2>/dev/null ||
jq -cMn --arg s "$session" --argjson st "${status:-null}" --argjson c "$commands" '{session: $s, status: $st, assistant: null, user: null, commands: $c}'
}
# --reply SESSION BYTES: type a message into the session's Claude prompt and
# send it. The text arrives as base64 after the READY line (like uploads) and is
# pasted as one bracketed paste, so newlines in it don't send it early.
cmd_reply() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --reply SESSION BYTES"
local session=$1 size=$2 file tmp b64len
dd_valid_name "$session" || dd_die "invalid session name: $session"
if ! [[ $size =~ ^[0-9]+$ ]] || [ "$size" -lt 1 ] || [ "$size" -gt 16384 ]; then dd_die "a reply is 1 to 16384 bytes"; fi
if ! { dd_server_up && dd_tmux has-session -t "=$session" 2>/dev/null; }; then dd_die "session '$session' isn't running"; fi
file=$(dd_state_file "$session")
if [ "$(jq -rM '"\(.state) \(.request != null)"' "$file" 2>/dev/null)" = "waiting true" ]; then
dd_die "Claude is asking for a permission: answer that first"
fi
tmp=$(mktemp)
trap 'rm -f "$tmp"' HUP INT TERM
# shellcheck disable=SC2017 # integer ceil(size/3)*4: the base64 length, on purpose
b64len=$(((size + 2) / 3 * 4))
stty raw -echo 2>/dev/null || true
printf 'DEVDESK-REPLY-READY\r\n'
head -c "$b64len" | base64 -d >"$tmp" 2>/dev/null || true
stty sane 2>/dev/null || true
if [ "$(stat -c %s "$tmp")" != "$size" ]; then
rm -f "$tmp"
dd_die "reply incomplete"
fi
dd_tmux load-buffer -b devdesk-reply "$tmp" && dd_tmux paste-buffer -p -d -b devdesk-reply -t "=$session:claude" &&
sleep 0.2 && dd_tmux send-keys -t "=$session:claude" Enter
rm -f "$tmp"
trap - HUP INT TERM
}
# --last-reply SESSION (devdesk#47): Claude's whole answer to your last message,
# every text part of it (between tool calls), as the Markdown Claude wrote.
cmd_last_reply() {
[ $# -eq 1 ] || dd_die "usage: devdesk attach --last-reply SESSION"
local session=$1 transcript
dd_valid_name "$session" || dd_die "invalid session name: $session"
transcript=$(dd_transcript "$session") || transcript=""
{ [ -n "$transcript" ] && [ -r "$transcript" ]; } || dd_die "no conversation recorded for '$session' yet"
tail -n 3000 -- "$transcript" | jq -cMs --arg s "$session" '
def text: if type == "string" then . else ([.[]? | select(.type == "text") | .text] | join("\n")) end;
[.[] | select(.isSidechain != true and .isMeta != true)] as $all
# Your last message: a user entry with text of its own, not a tool result.
| ([range(0; $all | length) | select($all[.] as $e | $e.type == "user"
and ($e.message.content | type == "string" or any(.[]?; .type == "text"))
and ($e.message.content | text | startswith("<") | not))] | last // -1) as $u
| [$all[($u + 1):][] | select(.type == "assistant") | .message.content | text | select(length > 0)] as $parts
| if ($parts | length) == 0 then {session: $s, text: null}
else {session: $s, text: ($parts | join("\n\n")), at: ([$all[] | select(.type == "assistant")] | last | .timestamp)} end' 2>/dev/null ||
dd_die "could not read the conversation of '$session'"
}
+172 -21
View File
@@ -5,24 +5,82 @@
# 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"
# Local overrides go in env.local: it is never shipped, so upgrades don't prompt about it.
DEVDESK_ENV_LOCAL=${DEVDESK_ENV_LOCAL:-${DEVDESK_ENV}.local}
# The env files are read by systemd (EnvironmentFile=) as well, so they are
# parsed here the way systemd parses them, never sourced: KEY=value lines, the
# value taken literally (no $ expansion, no command substitution), optionally
# in matching quotes. Anything else is reported and skipped (devdesk#12).
dd_load_env() {
local _dd_f _dd_line _dd_n _dd_key _dd_val
for _dd_f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do
[ -r "$_dd_f" ] || continue
_dd_n=0
while IFS= read -r _dd_line || [ -n "$_dd_line" ]; do
_dd_n=$((_dd_n + 1))
_dd_line=${_dd_line%$'\r'}
[[ $_dd_line =~ ^[[:space:]]*([#\;].*)?$ ]] && continue
if ! [[ $_dd_line =~ ^[[:space:]]*([A-Z_][A-Z0-9_]*)=(.*)$ ]]; then
printf 'devdesk: %s:%s: not a KEY=value line, ignored\n' "$_dd_f" "$_dd_n" >&2
continue
fi
_dd_key=${BASH_REMATCH[1]}
_dd_val=${BASH_REMATCH[2]}
_dd_val=${_dd_val%"${_dd_val##*[![:space:]]}"} # trailing blanks, like systemd
if [[ $_dd_val =~ ^\"(.*)\"$ || $_dd_val =~ ^\'(.*)\'$ ]]; then
_dd_val=${BASH_REMATCH[1]}
elif [[ $_dd_val == [\"\']* ]]; then
printf 'devdesk: %s:%s: unmatched quote, ignored\n' "$_dd_f" "$_dd_n" >&2
continue
fi
printf -v "$_dd_key" '%s' "$_dd_val"
done <"$_dd_f"
done
}
dd_load_env
: "${DEVDESK_PROJECTS_ROOT:=/root/code}"
: "${DEVDESK_DEFAULT_DIR:=$DEVDESK_PROJECTS_ROOT}"
: "${DEVDESK_DEFAULT_SESSION:=main}"
: "${DEVDESK_SOCKET:=/run/devdesk/tmux.sock}"
: "${DEVDESK_STATE_DIR:=/var/lib/devdesk}"
: "${DEVDESK_LIBEXEC:=/usr/lib/devdesk}"
: "${DEVDESK_SHARE:=/usr/share/devdesk}"
: "${DEVDESK_TMUX_UNIT:=devdesk-tmux.service}"
: "${DEVDESK_GITEA_URL:=https://code.lotusguild.org}"
: "${DEVDESK_GITEA_ALIASES:=10.10.10.63}"
: "${DEVDESK_GITEA_TOKEN_FILE:=/etc/devdesk/gitea-token}"
: "${DEVDESK_GRAFANA_URL:=}"
: "${DEVDESK_GRAFANA_PUBLIC_URL:=$DEVDESK_GRAFANA_URL}"
: "${DEVDESK_GRAFANA_TOKEN_FILE:=/etc/devdesk/grafana-token}"
: "${DEVDESK_PVE_NODES:=}"
: "${DEVDESK_UPLOAD_MAX:=26214400}"
: "${DEVDESK_PUBLIC_URL:=}"
: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}"
: "${DEVDESK_MATRIX_DELAY:=60}"
: "${DEVDESK_MATRIX_QUIET:=600}"
: "${DEVDESK_USAGE_WARN:=90}"
: "${DEVDESK_PRESENCE_DIR:=/run/devdesk/presence}"
DEVDESK_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context"
DEVDESK_STATUS_DIR="$DEVDESK_STATE_DIR/state"
DEVDESK_NAME_RE='^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$'
# Under ttyd, a command that exits right after printing sometimes loses its last
# output: ttyd sees the exit and closes the WebSocket before reading the pty
# (devdesk#35). One-shot replies and errors therefore wait, at most
# DEVDESK_LINGER seconds, for the browser to hang up once it has the answer
# (ttyd then sends SIGHUP). devdesk-serve sets DEVDESK_VIA_TTYD=1.
dd_linger() {
[ "${DEVDESK_VIA_TTYD:-}" = 1 ] && [ -t 0 ] || return 0
read -r -t "${DEVDESK_LINGER:-1}" _ 2>/dev/null || true
}
dd_die() {
printf 'devdesk: %s\n' "$*" >&2
dd_linger
exit 1
}
@@ -67,24 +125,57 @@ dd_registry_get() {
jq -Mr --arg n "$1" --arg f "$2" '.sessions[$n][$f] // empty' "$DEVDESK_REGISTRY" 2>/dev/null
}
# dd_registry_update JQ_FILTER [jq args...] — read-modify-write under flock.
dd_registry_update() {
local filter=$1
shift
# dd_registry_locked CMD [ARGS...] — run CMD holding the registry lock, so a
# step that reads tmux and then writes the registry (the SessionStart hook,
# rename) can't interleave with another. Nests: an update inside reuses it.
dd_registry_locked() {
if [ -n "${DD_REGISTRY_LOCKED:-}" ]; then
"$@"
return
fi
mkdir -p "$DEVDESK_STATE_DIR"
(
flock -w 5 9 || exit 1
DD_REGISTRY_LOCKED=1
"$@"
) 9>"$DEVDESK_REGISTRY.lock"
}
# dd_registry_update JQ_FILTER [jq args...] — read-modify-write under flock.
dd_registry_update() {
dd_registry_locked _dd_registry_apply "$@"
}
_dd_registry_apply() {
local filter=$1 tmp
shift
[ -s "$DEVDESK_REGISTRY" ] || printf '{"sessions":{}}\n' >"$DEVDESK_REGISTRY"
local tmp
tmp=$(mktemp "$DEVDESK_REGISTRY.XXXXXX") || exit 1
tmp=$(mktemp "$DEVDESK_REGISTRY.XXXXXX") || return 1
if jq "$@" "$filter" "$DEVDESK_REGISTRY" >"$tmp"; then
chmod 600 "$tmp"
mv -f "$tmp" "$DEVDESK_REGISTRY"
else
rm -f "$tmp"
exit 1
return 1
fi
) 9>"$DEVDESK_REGISTRY.lock"
}
# Claude's conversation files: ~/.claude/projects/<folder>/<id>.jsonl.
dd_projects_dir() {
printf '%s/projects\n' "${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
}
dd_convo_file() {
[[ $1 =~ ^[0-9a-f-]{36}$ ]] || return 1
compgen -G "$(dd_projects_dir)/*/$1.jsonl" | head -n1
}
# Temp files of writers that were killed mid-write (hooks run under Claude's
# timeouts): .<session>.XXXXXX and registry.json.XXXXXX, older than 10 minutes.
dd_sweep_tmp() {
find "$DEVDESK_CONTEXT_DIR" "$DEVDESK_STATUS_DIR" -maxdepth 1 -type f -name '.*.??????' -mmin +10 -delete 2>/dev/null
find "$DEVDESK_STATE_DIR" -maxdepth 1 -type f -name 'registry.json.??????' -mmin +10 -delete 2>/dev/null
return 0
}
dd_registry_set_dir() {
@@ -92,6 +183,18 @@ dd_registry_set_dir() {
--arg n "$1" --arg d "$2" --arg t "$(date -Is)"
}
# Remote Control for session NAME (devdesk#61): its own choice if it made one,
# else the default set in Settings (state settings.json), else
# DEVDESK_REMOTE_CONTROL from the env files. Prints 1 or 0.
dd_rc_wanted() {
local own def
# (not dd_registry_get: its `// empty` would turn false into "unset")
own=$(jq -Mr --arg n "$1" '.sessions[$n].remote_control | if . == null then empty else tostring end' "$DEVDESK_REGISTRY" 2>/dev/null)
case $own in true) echo 1; return ;; false) echo 0; return ;; esac
def=$(jq -rM '.remote_control_default // empty' "$DEVDESK_STATE_DIR/settings.json" 2>/dev/null)
case $def in true) echo 1 ;; false) echo 0 ;; *) [ "${DEVDESK_REMOTE_CONTROL:-0}" = 1 ] && echo 1 || echo 0 ;; esac
}
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)"
@@ -99,17 +202,32 @@ dd_registry_set_claude() {
dd_registry_remove() {
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
rm -f "$DEVDESK_CONTEXT_DIR/$1.json" "$DEVDESK_STATUS_DIR/$1.json" "$DEVDESK_STATE_DIR/edits/$1.txt" \
"$DEVDESK_STATE_DIR/commands/$1.jsonl" "$DEVDESK_STATE_DIR/tags/$1.json" "$DEVDESK_STATE_DIR/prompts/$1.txt"
}
# Session name -> absolute project directory (must resolve inside the projects root).
dd_resolve_dir() {
dd_registry_rename() {
dd_registry_update 'if .sessions[$old] then .sessions[$new] = .sessions[$old] | del(.sessions[$old]) else . end' \
--arg old "$1" --arg new "$2"
local d
for d in "$DEVDESK_CONTEXT_DIR" "$DEVDESK_STATUS_DIR"; do
if [ -f "$d/$1.json" ]; then
mv -f "$d/$1.json" "$d/$2.json"
fi
done
local f
for f in "edits/%s.txt" "commands/%s.jsonl" "tags/%s.json" "prompts/%s.txt"; do
# shellcheck disable=SC2059 # the pattern is ours
if [ -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" ]; then
# shellcheck disable=SC2059
mv -f "$DEVDESK_STATE_DIR/$(printf "$f" "$1")" "$DEVDESK_STATE_DIR/$(printf "$f" "$2")"
fi
done
}
# Project name (directory or its slug) -> absolute directory inside the projects root.
dd_project_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
@@ -121,8 +239,6 @@ dd_resolve_dir() {
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
@@ -132,6 +248,41 @@ dd_resolve_dir() {
esac
}
# Upload filename -> safe basename ([A-Za-z0-9._-], no leading dots, max 100 chars).
dd_safe_filename() {
local n=${1##*/}
n=${n//[^A-Za-z0-9._-]/_}
while [[ $n == .* ]]; do n=${n:1}; done
n=${n:0:100}
printf '%s' "${n:-upload}"
}
# Session name -> working directory. Sessions don't need a project: the
# registry wins, then a project of the same name, then DEVDESK_DEFAULT_DIR.
dd_resolve_dir() {
local name=$1 dir
dir=$(dd_registry_get "$name" dir)
if [ -n "$dir" ] && [ -d "$dir" ]; then
printf '%s\n' "$dir"
elif [ "$name" != "$DEVDESK_DEFAULT_SESSION" ] && dir=$(dd_project_dir "$name"); then
printf '%s\n' "$dir"
else
realpath -e -- "$DEVDESK_DEFAULT_DIR" 2>/dev/null || printf '%s\n' "$HOME"
fi
}
# Name of the DevDesk session this process runs in. Asks tmux, so it stays
# right after a rename; falls back to the DEVDESK_SESSION set at creation.
dd_current_session() {
local name="" outer=${TMUX:-}
# Only DevDesk's own tmux server: a Claude started with these settings in
# some other tmux must not write into DevDesk's state.
if [ "${outer%%,*}" = "$DEVDESK_SOCKET" ] && [ -n "${TMUX_PANE:-}" ]; then
name=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
fi
printf '%s\n' "${name:-${DEVDESK_SESSION:-}}"
}
# Arguments for `tmux new-session` that create session NAME in DIR running Claude.
dd_new_session_args() {
printf '%s\0' -s "$1" -c "$2" -n claude -e "DEVDESK_SESSION=$1" "$DEVDESK_LIBEXEC/devdesk-claude-run"
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
# devdesk-notify-matrix SESSION SINCE
# devdesk-notify-matrix --usage PCT RESETS_AT
#
# Started (detached) by devdesk-state-hook when a session starts waiting on you.
# After DEVDESK_MATRIX_DELAY seconds, if that same wait is still on, no DevDesk
# tab is in view, and this session hasn't posted in DEVDESK_MATRIX_QUIET seconds,
# post what Claude is asking to the hookshot webhook in DEVDESK_MATRIX_WEBHOOK_FILE
# (devdesk#28, #50).
set -euo pipefail
# shellcheck source=libexec/devdesk-lib.sh
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
# Post BODY (JSON with a .text) to the hookshot webhook. The URL is a secret
# (anyone holding it can post to the room): it goes to curl on stdin, never on
# the command line.
post() {
local webhook
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the URL
webhook=$(tr -d '[:space:]"\\' <"$DEVDESK_MATRIX_WEBHOOK_FILE")
printf 'url = "%s"\n' "$webhook" |
curl -fsS --max-time 10 --retry 2 -K - -H 'Content-Type: application/json' --data-binary "$1" >/dev/null
}
# devdesk-notify-matrix --usage PCT RESETS_AT (devdesk#45): started by the status
# line when the 5-hour plan window passes DEVDESK_USAGE_WARN percent. Once per
# window, and only when no DevDesk tab has been in view for ~2 minutes (presence
# markers; you're away. A tab in view shows its own warning).
if [ "${1:-}" = --usage ]; then
pct=${2:-} resets=${3:-}
[[ $pct =~ ^[0-9]+$ && $resets =~ ^[0-9]+$ ]] || exit 0
marker="$DEVDESK_STATE_DIR/usage-warned"
exec 9>>"$marker.lock"
flock -n 9 || exit 0
[ "$(cat "$marker" 2>/dev/null)" != "$resets" ] || exit 0
printf '%s\n' "$resets" >"$marker"
[ -r "$DEVDESK_MATRIX_WEBHOOK_FILE" ] || exit 0
[ -z "$(find "$DEVDESK_PRESENCE_DIR" -type f 2>/dev/null | head -n 1)" ] || exit 0
at=$(date -d "@$resets" '+%H:%M %Z')
post "$(jq -cMn --arg p "$pct" --arg at "$at" --arg h "$(hostname)" \
'{kind: "usage", host: $h, text: "Claude plan usage is at \($p)% of the 5-hour window; it resets at \($at)."}')"
exit 0
fi
session=${1:-}
since=${2:-}
dd_valid_name "$session" || exit 0
[[ $since =~ ^[0-9]+$ ]] || exit 0
[ -r "$DEVDESK_MATRIX_WEBHOOK_FILE" ] || exit 0
sleep "$DEVDESK_MATRIX_DELAY"
state_file="$DEVDESK_STATUS_DIR/$session.json"
# Still the same wait? (Answering it moves the state on; a new wait has its own notifier.)
[ "$(jq -rM '"\(.state) \(.since)"' "$state_file" 2>/dev/null)" = "waiting $since" ] || exit 0
# You're at DevDesk if any tab has shown a session in the last ~2 minutes
# (presence markers, devdesk#26): the browser tells you itself (devdesk#50).
[ -z "$(find "$DEVDESK_PRESENCE_DIR" -type f 2>/dev/null | head -n 1)" ] || exit 0
# The idle reminder isn't a request (the state hook drops it; old states may have it).
message=$(jq -rM '.message // ""' "$state_file" 2>/dev/null)
request=$(jq -cM '.request // null' "$state_file" 2>/dev/null) || request=null
if [ "$request" = null ] && { [ -z "$message" ] || [[ $message == *"waiting for your input"* ]]; }; then exit 0; fi
# At most one message per session per DEVDESK_MATRIX_QUIET seconds.
sent="$DEVDESK_STATE_DIR/matrix-sent/$session"
mkdir -p "${sent%/*}"
if [ -n "$(find "$sent" -newermt "-$DEVDESK_MATRIX_QUIET seconds" 2>/dev/null)" ]; then exit 0; fi
touch "$sent"
url=""
# existing=1: if the session was ended by the time you tap, say so instead of
# starting a new Claude there (devdesk#38).
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session&existing=1"
# What exactly it wants: "alpha: Claude wants to run Bash (Remove x)" and the command.
body=$(jq -cMn --arg s "$session" --arg m "$message" --argjson r "$request" --arg u "$url" --arg h "$(hostname)" '
(if $r then "\($s): Claude wants to run \($r.tool)\(if $r.description then " (\($r.description))" else "" end)"
else "\($s): \($m)" end) as $head
| ($r.detail // null | if . then .[0:300] else null end) as $d
| {session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h,
text: ($head + (if $d then "\n" + $d else "" end) + (if $u == "" then "" else "\nAnswer: " + $u end))}')
post "$body"
+300
View File
@@ -0,0 +1,300 @@
# shellcheck shell=bash disable=SC2016 # single-quoted jq filters use jq variables
# Ops (devdesk#39): Grafana alerts, the Proxmox guests they are about, and
# "Ask Claude" investigations in a session of their own. Sourced by bin/devdesk.
DEVDESK_GUESTS="$DEVDESK_STATE_DIR/guests.json"
# ---------------------------------------------------------------- Proxmox guests
# Which CT/VM has which IP, and on which node it runs right now: guests are HA
# and move between nodes, so this is read live from the cluster, over SSH to the
# nodes in DEVDESK_PVE_NODES (empty = off). Cached in guests.json, where a
# stopped guest keeps the IPs it had when it was last seen running.
# Cluster view, from any one node: guests (id, name, node, status, HA) and nodes.
DD_PVE_INVENTORY='pvesh get /cluster/resources --type vm --output-format json && echo && pvesh get /cluster/status --output-format json'
# Per node: its own addresses, then those of its running guests ("<id>\t<IPs>").
IFS= read -r -d '' DD_PVE_NODE_SCAN <<'EOF' || true
printf '%s\t%s\n' "$(hostname)" "$(hostname -I)"
for id in $(lxc-ls --running 2>/dev/null); do
printf '%s\t%s\n' "$id" "$(lxc-info -n "$id" -iH 2>/dev/null | tr '\n' ' ')"
done
# VMs only through the QEMU guest agent, where one is configured; slow, so all at once.
for f in /var/run/qemu-server/*.pid; do
id=${f##*/}
id=${id%.pid}
case $id in '' | *[!0-9]*) continue ;; esac
grep -qE '^agent: *(1|enabled=1)' "/etc/pve/qemu-server/$id.conf" 2>/dev/null || continue
printf '%s\t%s\n' "$id" "$(timeout 3 qm guest cmd "$id" network-get-interfaces 2>/dev/null |
grep -oE '"ip-address" *: *"[0-9.]+"' | grep -oE '[0-9.]+' | tr '\n' ' ')" &
done
wait
EOF
dd_pve_ssh() {
local host=$1
shift
timeout 12 "${DEVDESK_PVE_SSH:-ssh}" -o BatchMode=yes -o ConnectTimeout=4 "$host" "$@"
}
# Refresh guests.json. Returns non-zero if no node answered (the cache stays).
dd_pve_scan() {
[ -n "${DEVDESK_PVE_NODES:-}" ] || return 1
local host inv="" tmp node ip pids=() rc=0
for host in $DEVDESK_PVE_NODES; do
inv=$(dd_pve_ssh "$host" "$DD_PVE_INVENTORY" 2>/dev/null) && jq -es 'length == 2' >/dev/null 2>&1 <<<"$inv" && break
inv=""
done
[ -n "$inv" ] || return 1
tmp=$(mktemp -d) || return 1
# Every online node at once: by name, or by its cluster address if the name doesn't answer.
while IFS=$'\t' read -r node ip; do
{ dd_pve_ssh "$node" "$DD_PVE_NODE_SCAN" || dd_pve_ssh "$ip" "$DD_PVE_NODE_SCAN"; } >"$tmp/$node" 2>/dev/null </dev/null &
pids+=($!)
done < <(jq -rs '.[1][] | select(.type == "node" and .online == 1) | [.name, .ip] | @tsv' <<<"$inv")
[ ${#pids[@]} -eq 0 ] || wait "${pids[@]}" 2>/dev/null || true
mkdir -p "$DEVDESK_STATE_DIR"
# Scan lines: the first is "<node>\t<its IPs>", then "<CT id>\t<its IPs>".
if { cat "$tmp"/* 2>/dev/null || true; } | jq -cMRs --slurpfile inv <(jq -s . <<<"$inv") \
--slurpfile old <(cat "$DEVDESK_GUESTS" 2>/dev/null || echo '{}') --arg src "$host" '
def v4: [splits("[[:space:]]+") | select(test("^[0-9]{1,3}(\\.[0-9]{1,3}){3}$") and (startswith("127.") | not))];
(split("\n") | map(select(length > 0) | split("\t"))) as $rows
| ([$rows[] | select(.[0] | test("^[0-9]+$")) | {key: .[0], value: (.[1] // "" | v4)}] | from_entries) as $live
| ([$rows[] | select(.[0] | test("^[0-9]+$") | not) | {key: .[0], value: (.[1] // "" | v4)}] | from_entries) as $nodeips
| ([$old[0].guests[]? | {key: (.id | tostring), value: .ips}] | from_entries) as $prev
| {updated: (now | floor), source: $src,
nodes: [$inv[0][1][] | select(.type == "node")
| {name, online: (.online == 1), ips: (([.ip] + ($nodeips[.name] // [])) | map(select(. != null)) | unique)}],
guests: [$inv[0][0][] | select(.vmid != null) | (.vmid | tostring) as $id
| ($live[$id] // []) as $now
| {id: .vmid, type, name, node, status, ha: (.hastate // null),
ips: (if ($now | length) > 0 then $now else ($prev[$id] // []) end),
ips_live: (($now | length) > 0)}]
| sort_by(.id)}' >"$tmp/out" && [ -s "$tmp/out" ]; then
chmod 644 "$tmp/out"
mv -f "$tmp/out" "$DEVDESK_GUESTS"
else
rc=1
fi
rm -rf "$tmp"
return $rc
}
# Refresh, one scan at a time: "wait" for a running one (then scan again), else
# skip when one is already running.
dd_pve_refresh() {
mkdir -p "$DEVDESK_STATE_DIR"
if [ "${1:-}" = wait ]; then
(flock -w 30 9 || exit 1; dd_pve_scan) 9>"$DEVDESK_GUESTS.lock"
else
(flock -n 9 || exit 0; dd_pve_scan) 9>"$DEVDESK_GUESTS.lock"
fi
}
# Seconds since guests.json was written (large when there is none).
dd_guests_age() {
local m
m=$(stat -c %Y "$DEVDESK_GUESTS" 2>/dev/null) || { echo 999999; return; }
echo $(($(date +%s) - m))
}
# jq: locate the guest or node a host/label is about, given the guests.json
# document as $g. A target: {kind: "ct"|"vm"|"node", id, name, node, status, ha,
# ip, by: "ip"|"host"|"name"}.
DD_LOCATE_JQ='
def kindof: if .type == "qemu" then "vm" else "ct" end;
def as_guest: {kind: kindof, id, name, node, status, ha};
def as_node: {kind: "node", name, node: .name, status: (if .online then "online" else "offline" end)};
def isv4: test("^[0-9]{1,3}(\\.[0-9]{1,3}){3}$");
# "http://10.0.0.1:8080/x", "10.0.0.1:9100", "host.example.org:9100" -> the host
def hostpart: sub("^[A-Za-z][A-Za-z0-9+.-]*://"; "") | sub("/.*$"; "") | sub("^[^@]*@"; "") | sub(":[0-9]+$"; "") | ascii_downcase;
def norm: ascii_downcase | gsub("[^a-z0-9]+"; "-");
def by_host($g; $h):
if ($h | isv4) then
([$g.nodes[]? | select(.ips | index([$h])) | as_node + {ip: $h, by: "ip"}]
+ [$g.guests[]? | select(.ips | index([$h])) | as_guest + {ip: $h, by: "ip", ip_seen: (.ips_live | not)}]) | first
else
($h | split(".")[0]) as $s
| ([$g.nodes[]? | select((.name | ascii_downcase) == $s) | as_node + {by: "host"}]
+ [$g.guests[]? | select((.name | ascii_downcase) == $s) | as_guest + {by: "host"}]) | first
end;
# A guest or node whose name appears as whole words in $v ("draupnir-node",
# "compute-storage-01-ceph", "Draupnir Down"); the longest name wins.
def by_name($g; $v):
("-" + ($v | norm) + "-") as $t
| [($g.nodes[]? | as_node), ($g.guests[]? | as_guest)]
| map(select((.name // "" | norm) as $n | ($n | length) >= 4 and ($t | contains("-" + $n + "-"))))
| sort_by(-(.name | length)) | first | if . then . + {by: "name"} else empty end;
# Targets of one alert (an element of the alerts list below): by each label
# set: its instance (IP or host name), else its job; with no labels at all, by the
# name of the alert itself ("Draupnir Down").
def targets($g):
if ($g | type) != "object" then [] else
.name as $alert
| [.labelsets[] | (.instance // "" | hostpart) as $h
| if $h != "" then (by_host($g; $h) // by_name($g; .job // "") // {kind: "unknown", ip: $h})
elif .job then by_name($g; .job)
else empty end]
| if length == 0 then [by_name($g; $alert)] else . end
| map(select(. != null)) | unique_by([.kind, .id, .name, .ip]) | .[:6]
end;
'
# ---------------------------------------------------------------- alerts
# Firing and pending Grafana alert rules, most severe first, each with the
# guests/nodes it is about (from the guest cache, refreshed in the background).
cmd_alerts() {
if [ -z "$DEVDESK_GRAFANA_URL" ]; then
jq -cMn '{configured: false, alerts: []}'
return
fi
local raw
if ! raw=$({
if [ -r "$DEVDESK_GRAFANA_TOKEN_FILE" ]; then
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token
printf 'header = "Authorization: Bearer %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GRAFANA_TOKEN_FILE")"
fi
} | curl -fsS --max-time 6 -K - "$DEVDESK_GRAFANA_URL/api/prometheus/grafana/api/v1/rules" 2>&1); then
jq -cMn --arg e "${raw:-no response}" '{configured: true, error: $e, alerts: []}'
return
fi
if [ -n "${DEVDESK_PVE_NODES:-}" ] && [ "$(dd_guests_age)" -gt 300 ]; then
setsid -f "$0" guests --refresh </dev/null >/dev/null 2>&1 3>&- || true
fi
jq -cM --arg pub "$DEVDESK_GRAFANA_PUBLIC_URL" --slurpfile g <(cat "$DEVDESK_GUESTS" 2>/dev/null || echo null) "$DD_LOCATE_JQ"'
def num: (tonumber? // .) | tostring;
($g[0]) as $guests
| {configured: true, checked: (now | floor), pve: ($guests != null), alerts: [
.data.groups[] | .name as $group | .file as $folder | .rules[]
| select(.state == "firing" or .state == "pending")
| ([.alerts[]? | select(.state == "Alerting" or .state == "Pending")]) as $active
| {uid, name, state, severity: (.labels.severity // null), group: $group, folder: $folder,
query: (.query // null),
since: (.activeAt // $active[0].activeAt // null),
summary: ((.annotations.summary // .annotations.description // "")
| gsub("\\{\\{ *\\$value *\\}\\}"; ($active[0].value // "?" | num))),
description: (if .annotations.summary then (.annotations.description // null) else null end),
labelsets: [$active[] | .labels
| del(.__alert_rule_uid__, .__grafana_origin__, .alertname, .grafana_folder, .__name__, .severity)
| select(length > 0)],
url: "\($pub)/alerting/grafana/\(.uid)/view"}
| .instances = [.labelsets[] | to_entries | map("\(.key)=\(.value)") | join(" ")]
| .targets = targets($guests)
] | sort_by(if .severity == "critical" then 0 elif .severity == "warning" then 1 else 2 end)}' <<<"$raw"
}
# ---------------------------------------------------------------- guests command
# devdesk guests [--json] [--cached] [FILTER...]: Proxmox guests with their node,
# state and IPs, fresh from the cluster (the cache if no node answers).
# FILTER matches an id, an IP, or part of a name or node.
cmd_guests() {
local json="" cached="" filters=()
while [ $# -gt 0 ]; do
case $1 in
--json) json=1 ;;
--cached) cached=1 ;;
--refresh)
dd_pve_refresh
return 0
;;
-*) dd_die "usage: devdesk guests [--json] [--cached] [ID|IP|NAME...]" ;;
*) filters+=("$1") ;;
esac
shift
done
[ -n "${DEVDESK_PVE_NODES:-}" ] || dd_die "no Proxmox nodes configured (DEVDESK_PVE_NODES in /etc/devdesk/env.local)"
if [ -z "$cached" ] && ! dd_pve_refresh wait; then
[ -s "$DEVDESK_GUESTS" ] || dd_die "no Proxmox node answered ($DEVDESK_PVE_NODES)"
printf 'devdesk: no Proxmox node answered; showing what was known %s minutes ago\n' "$(($(dd_guests_age) / 60))" >&2
fi
[ -s "$DEVDESK_GUESTS" ] || dd_die "nothing known yet: run devdesk guests without --cached"
local select='[.guests[] | . as $x | select(($f | length) == 0 or any($f[]; . as $q
| ($x.id | tostring) == $q or ($x.ips | index([$q])) or ($x.name | ascii_downcase | contains($q | ascii_downcase))
or ($x.node | ascii_downcase | contains($q | ascii_downcase))))]'
local f
f=$(printf '%s\n' "${filters[@]}" | jq -R . | jq -cs 'map(select(length > 0))')
if [ -n "$json" ]; then
jq -cM --argjson f "$f" "{updated, nodes, guests: $select}" "$DEVDESK_GUESTS"
return
fi
jq -rM --argjson f "$f" "$select"' as $g
| if ($g | length) == 0 then "no matching guests" else
(["ID", "TYPE", "NAME", "NODE", "STATUS", "HA", "IP"] | @tsv),
($g[] | [(.id | tostring), (if .type == "qemu" then "vm" else "ct" end), .name, .node, .status, (.ha // "-"),
((.ips | join(",")) + (if (.ips | length) > 0 and (.ips_live | not) then " (last seen)" else "" end) | if . == "" then "-" else . end)] | @tsv)
end' "$DEVDESK_GUESTS" | column -t -s $'\t'
}
# ---------------------------------------------------------------- investigate
# The opening prompt for an investigation of alert $a (one element of the
# alerts output), with $since already readable.
DD_PROMPT_JQ='
def ref: if .kind == "ct" then "CT \(.id) \"\(.name)\"" elif .kind == "vm" then "VM \(.id) \"\(.name)\"" else "Proxmox node \(.name)" end;
def state: [(if .status then .status else empty end), (if .ha then "HA state \(.ha)" else empty end)] | join(", ");
def how:
if .kind == "ct" then "commands inside it: `ssh \(.node) pct exec \(.id) -- <command>`"
elif .kind == "vm" then "commands inside it (needs the guest agent): `ssh \(.node) qm guest exec \(.id) -- <command>`"
else "reach it with `ssh \(.name)`" end;
def where:
if .kind == "unknown" then "- \(.ip) is not a Proxmox guest or node that DevDesk knows (`devdesk guests` lists them all)."
elif .kind == "node" then "- \(if .ip then "\(.ip) is " else "" end)\(ref)\(if .by == "name" then " (probably: matched by name)" else "" end), \(.status); \(how)"
else "- \(if .by == "name" then "Probably " else "" end)\(if .ip then "\(.ip) is " else "" end)\(ref) on node \(.node)"
+ "\(if .by == "name" then " (matched by name, not by IP)" elif .ip_seen then " (its IP when last seen running)" else "" end), \(state); \(how)"
end;
$a
| ([.targets[] | select(.kind == "ct" or .kind == "vm")] | first) as $guest
| [
"Investigate this Grafana alert and find the cause. Look first and change nothing yet: tell me what you found and what you would change, then wait for my go-ahead.",
"",
"Alert: \(.name) (\(.severity // "no severity"), \(.state) since \($since))",
"Rule: \(.folder) / \(.group), uid \(.uid). Look at it with the grafana MCP server: \(.url)",
(if .query then "Query: `\(.query)`" else empty end),
(if .summary != "" then "Summary: \(.summary)" else empty end),
(if .description then "Details: \(.description)" else empty end),
(if (.instances | length) > 0 then "Firing for: \(.instances | join("; "))"
else "Firing for: no labels. The rule aggregates, so it does not say what it is about: run the inner query to find the series"
+ (if $pve then ", then `devdesk guests <IP>` shows which Proxmox guest each one is and where it runs." else "." end) end),
(if $pve and (.targets | length) > 0 then
("", "Where it runs (Proxmox, looked up just now):"),
(.targets[] | where),
(if $guest then "Guests are HA and can migrate between nodes, so check again before acting: `devdesk guests \($guest.id)` shows where it is now." else empty end)
else empty end)
] | join("\n")'
# --investigate SESSION RULE_UID: start Claude in a new session SESSION with an
# investigation of that alert as its first prompt. An existing session is left as
# it is: {"ok":true,"session":…,"started":false}.
cmd_investigate() {
[ $# -eq 2 ] || dd_die "usage: devdesk attach --investigate SESSION ALERT_RULE_UID"
local name=$1 uid=$2 alerts alert since prompt dir pve=false
dd_valid_name "$name" || dd_die "invalid session name: $name"
[[ $uid =~ ^[A-Za-z0-9_-]{1,64}$ ]] || dd_die "invalid alert rule id: $uid"
if dd_server_up && dd_tmux has-session -t "=$name" 2>/dev/null; then
jq -cMn --arg s "$name" '{ok: true, session: $s, started: false}'
return
fi
[ -n "$DEVDESK_GRAFANA_URL" ] || dd_die "Grafana is not configured"
# Where the guests are right now (HA moves them); the cache if no node answers.
[ -z "${DEVDESK_PVE_NODES:-}" ] || dd_pve_refresh wait || true
alerts=$(cmd_alerts)
alert=$(jq -cM --arg uid "$uid" '.alerts[] | select(.uid == $uid)' <<<"$alerts" | head -n1)
if [ -z "$alert" ]; then
dd_die "$(jq -rM 'if .error then "Grafana unreachable: \(.error)" else "that alert is no longer firing" end' <<<"$alerts")"
fi
since=$(jq -rM '.since // empty' <<<"$alert")
since=$(date -d "$since" '+%a %-d %b %H:%M %Z' 2>/dev/null || echo "${since:-an unknown time}")
[ -s "$DEVDESK_GUESTS" ] && [ -n "${DEVDESK_PVE_NODES:-}" ] && pve=true
prompt=$(jq -rnM --argjson a "$alert" --arg since "$since" --argjson pve "$pve" "$DD_PROMPT_JQ") || dd_die "could not write the prompt"
# devdesk-claude-run sends this once, as the new conversation's first prompt.
mkdir -p "$DEVDESK_STATE_DIR/prompts"
(umask 077 && printf '%s\n' "$prompt" >"$DEVDESK_STATE_DIR/prompts/$name.txt")
# A new conversation, even if an earlier investigation used this name (that
# one stays in Search past conversations).
dd_registry_update 'if .sessions[$n] then .sessions[$n] |= del(.claude_session_id, .claude_source, .fork) else . end' --arg n "$name"
dir=$(dd_resolve_dir "$name")
dd_ensure_server
create_detached "$name" "$dir"
jq -cMn --arg s "$name" '{ok: true, session: $s, started: true}'
}
+4 -1
View File
@@ -13,7 +13,7 @@ args=(
--writable # ttyd >= 1.7.4 is read-only by default
--url-arg # ?arg=<session> becomes `devdesk attach <session>`
--check-origin # refuse cross-site WebSocket connections
--max-clients "${DEVDESK_MAX_CLIENTS:-8}"
--max-clients "${DEVDESK_MAX_CLIENTS:-32}"
--credential "$DEVDESK_CREDENTIAL" # injected by NPM as an Authorization header
--index "${DEVDESK_INDEX:-/usr/share/devdesk/index.html}"
--debug "${DEVDESK_TTYD_LOG_LEVEL:-3}" # errors+warnings only: notice level would log the credential
@@ -22,4 +22,7 @@ if [ -n "${DEVDESK_BIND:-}" ]; then
args+=(--interface "$DEVDESK_BIND")
fi
# Tells `devdesk attach` it talks to a browser through ttyd (see dd_linger).
export DEVDESK_VIA_TTYD=1
exec "${DEVDESK_TTYD:-/usr/lib/devdesk/bin/ttyd}" "${args[@]}" "${DEVDESK_BIN:-/usr/bin/devdesk}" attach
+11 -4
View File
@@ -7,13 +7,20 @@
# 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
# The session's name is read under the registry lock: a rename holds it too,
# so the id can't land under the old name after the entry moved.
# shellcheck disable=SC2317 # called through dd_registry_locked
record() {
local session
session=$(dd_current_session)
if [ -z "$session" ] || ! dd_valid_name "$session"; then return 0; fi
dd_registry_set_claude "$session" "$id" "$source"
}
dd_registry_locked record 2>/dev/null
dd_sweep_tmp
exit 0
+128
View File
@@ -0,0 +1,128 @@
#!/bin/bash
# Claude Code hook (wired in via claude-settings.json) that tracks what Claude is
# doing in each DevDesk session, for the sidebar's at-a-glance status:
# UserPromptSubmit, PostToolUse -> working
# PermissionRequest -> waiting, with what Claude wants to run
# (tool + command/file/URL), for the inbox's
# Allow/Deny (devdesk#22)
# Notification -> waiting (Claude needs input or a permission)
# Stop, SessionStart -> idle
# SessionEnd -> exited
# Fires on every tool call, so it stays tiny and only writes when the state changes.
# Entering "waiting" also starts devdesk-notify-matrix, which posts to Matrix if
# the wait lasts and nobody is attached (devdesk#28).
input=$(cat)
outer=${TMUX:-}
[ "${outer%%,*}" = "${DEVDESK_SOCKET:-/run/devdesk/tmux.sock}" ] && [ -n "${TMUX_PANE:-}" ] || exit 0
session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
[[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]] || exit 0
# Unit separator, not tab: read collapses runs of whitespace delimiters, so an
# empty field would shift the ones after it.
IFS=$'\x1f' read -r event message tool file ntype < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\u001f\n]"; " ")),
.tool_name // "", (.tool_input.file_path // .tool_input.notebook_path // "" | gsub("[\u001f\n]"; " ")),
.notification_type // ""] | join("\u001f")' <<<"$input" 2>/dev/null)
# Claude's idle reminder ("Claude is waiting for your input", about a minute
# after a turn ends) is not a request: the turn is simply done. It must not
# turn the session red or reach Matrix (devdesk#50).
if [ "$event" = Notification ] &&
{ [ "$ntype" = idle_prompt ] || { [ -z "$ntype" ] && [[ $message == *"waiting for your input"* ]]; }; }; then
exit 0
fi
# Files Claude edits, for the file viewer's "Claude" badge (devdesk#19).
case $event:$tool in
PostToolUse:Edit | PostToolUse:Write | PostToolUse:MultiEdit | PostToolUse:NotebookEdit)
if [[ $file == /* ]]; then
log="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/edits/$session.txt"
mkdir -p "${log%/*}" 2>/dev/null
if ! grep -qxF -- "$file" "$log" 2>/dev/null && [ "$(wc -l <"$log" 2>/dev/null || echo 0)" -lt 1000 ]; then
printf '%s\n' "$file" >>"$log"
fi
fi
;;
esac
# Ops trail (devdesk#27): every Bash command, and the hosts a session works on
# (ssh targets, Proxmox CT/VM ids) as tags for the session list.
if [ "$event:$tool" = PostToolUse:Bash ]; then
cmd=$(jq -rM '.tool_input.command // empty' <<<"$input" 2>/dev/null)
if [ -n "$cmd" ]; then
base="${DEVDESK_STATE_DIR:-/var/lib/devdesk}"
mkdir -p "$base/commands" "$base/tags" 2>/dev/null
log="$base/commands/$session.jsonl"
jq -cMn --arg c "${cmd:0:500}" '{at: (now | floor), cmd: $c}' >>"$log" 2>/dev/null
if [ "$(wc -l <"$log" 2>/dev/null || echo 0)" -gt 600 ]; then
tail -n 500 "$log" >"$log.tmp" 2>/dev/null && mv -f "$log.tmp" "$log"
fi
hosts=$(
# ssh [options] [user@]host: the first word that isn't an option (or an option's value)
awk '{ for (i = 1; i <= NF; i++) if ($i == "ssh") { j = i + 1
while (j <= NF && $j ~ /^-/) { if ($j ~ /^-[bcDEeFIiJLlmOopQRSWw]$/) j++; j++ }
if (j <= NF) { h = $j; sub(/^[^@]*@/, "", h); gsub(/[^A-Za-z0-9_.-].*$/, "", h); if (h != "") print h } } }' <<<"$cmd"
grep -oE '\bpct[[:space:]]+(exec|enter|push|pull|config|set|start|stop|shutdown|reboot|status|snapshot|rollback|migrate)[[:space:]]+[0-9]{3,}' <<<"$cmd" | grep -oE '[0-9]+$' | sed 's/^/CT /'
grep -oE '\bqm[[:space:]]+(guest[[:space:]]+exec|terminal|config|set|start|stop|shutdown|reboot|status|snapshot|rollback|migrate)[[:space:]]+[0-9]{3,}' <<<"$cmd" | grep -oE '[0-9]+$' | sed 's/^/VM /'
)
if [ -n "$hosts" ]; then
tags="$base/tags/$session.json"
jq -cMn --rawfile new <(printf '%s\n' "$hosts") --slurpfile old <(cat "$tags" 2>/dev/null || echo '{}') '
($new | split("\n") | map(select(length > 0))) as $n
| {hosts: ($n + ($old[0].hosts // []) | reduce .[] as $h ([]; if index([$h]) then . else . + [$h] end) | .[:4]),
at: (now | floor)}' >"$tags.tmp" 2>/dev/null && mv -f "$tags.tmp" "$tags"
fi
fi
fi
case $event in
UserPromptSubmit | PostToolUse) state=working ;;
Notification | PermissionRequest) state=waiting ;;
Stop | SessionStart) state=idle ;;
SessionEnd) state=exited ;;
*) exit 0 ;;
esac
dir="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/state"
file="$dir/$session.json"
# Unchanged state (the common case during tool runs): nothing to write.
if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then
exit 0
fi
# What a permission prompt is about. tool_use_id is null in PermissionRequest,
# so a wait is identified by its `since` instead.
request=null
if [ "$event" = PermissionRequest ]; then
request=$(jq -cM '(.tool_input // {}) as $i | {tool: (.tool_name // "a tool"),
detail: (($i.command // $i.file_path // $i.notebook_path // $i.url // $i.pattern // $i.query // $i.path // ($i | tostring)) | tostring | .[0:600]),
description: ($i.description // null)}' <<<"$input" 2>/dev/null) || request=null
message="Claude wants to use ${tool:-a tool}"
fi
# A second Notification during the same wait updates the message but keeps
# `since` (and what the wait is about), so the wait stays one wait (one Matrix
# message at most).
since=$(date +%s)
was_waiting=0
if [ "$state" = waiting ] && grep -q '"state":"waiting"' "$file" 2>/dev/null; then
was_waiting=1
since=$(jq -rM '.since // empty' "$file" 2>/dev/null) || since=$(date +%s)
[[ $since =~ ^[0-9]+$ ]] || since=$(date +%s)
if [ "$request" = null ]; then
request=$(jq -cM '.request // null' "$file" 2>/dev/null) || request=null
[ "$request" = null ] || message=$(jq -rM '.message // empty' "$file" 2>/dev/null)
fi
fi
mkdir -p "$dir" 2>/dev/null || exit 0
tmp=$(mktemp "$dir/.$session.XXXXXX" 2>/dev/null) || exit 0
if jq -cMn --arg s "$state" --arg m "$message" --arg e "$event" --argjson t "$since" --argjson r "${request:-null}" \
'{state: $s, since: $t, event: $e, message: (if $m == "" then null else $m end)} + (if $r then {request: $r} else {} end)' >"$tmp"; then
mv -f "$tmp" "$file"
else
rm -f "$tmp"
exit 0
fi
if [ "$state" = waiting ] && [ "$was_waiting" = 0 ]; then
setsid -f "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-notify-matrix" "$session" "$since" \
</dev/null >/dev/null 2>&1 || true
fi
exit 0
+74
View File
@@ -0,0 +1,74 @@
#!/bin/bash
# Claude Code statusLine command (wired in via claude-settings.json). Claude pipes
# its status JSON in on every update; we keep a snapshot per DevDesk session for
# the web UI (context, plan usage, PR) and print a one-line status for Claude's footer.
# Runs often while Claude streams, so it stays cheap and never fails loudly.
input=$(cat)
# Only record for panes on DevDesk's own tmux server.
session="" outer=${TMUX:-}
if [ "${outer%%,*}" = "${DEVDESK_SOCKET:-/run/devdesk/tmux.sock}" ] && [ -n "${TMUX_PANE:-}" ]; then
session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
session=${session:-${DEVDESK_SESSION:-}}
fi
if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
dir="${DEVDESK_STATE_DIR:-/var/lib/devdesk}/context"
if mkdir -p "$dir" 2>/dev/null && tmp=$(mktemp "$dir/.$session.XXXXXX" 2>/dev/null); then
if jq -cM '{
updated: (now | floor),
claude_session_id: .session_id,
model: .model.display_name,
model_id: .model.id,
effort: .effort.level,
used_pct: .context_window.used_percentage,
window: .context_window.context_window_size,
used_tokens: (.context_window.current_usage
| if . then ((.input_tokens // 0) + (.cache_creation_input_tokens // 0)
+ (.cache_read_input_tokens // 0)) else null end),
cost_usd: .cost.total_cost_usd,
lines_added: .cost.total_lines_added,
lines_removed: .cost.total_lines_removed,
# devdesk#25: plan usage (account-wide) and the pull request of the branch
rate_limits: (.rate_limits // null | if . then {
five_hour: (.five_hour | if . then {pct: .used_percentage, resets_at} else null end),
seven_day: (.seven_day | if . then {pct: .used_percentage, resets_at} else null end)} else null end),
pr: (.pr // null | if . then {number, url, review_state, kind} else null end)
}' <<<"$input" >"$tmp" 2>/dev/null; then
mv -f "$tmp" "$dir/$session.json"
else
rm -f "$tmp"
fi
fi
fi
# Plan usage past DEVDESK_USAGE_WARN: ping Matrix if you're away (devdesk#45).
# The marker holds the window already warned about, so this stays one read.
if [[ $session != "" ]] && read -r pct resets < <(jq -rM '.rate_limits.five_hour
| select(.used_percentage != null and .resets_at != null)
| "\(.used_percentage | floor) \(.resets_at)"' <<<"$input" 2>/dev/null) &&
[ "$pct" -ge "${DEVDESK_USAGE_WARN:-90}" ] &&
[ "$(cat "${DEVDESK_STATE_DIR:-/var/lib/devdesk}/usage-warned" 2>/dev/null)" != "$resets" ]; then
setsid -f "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-notify-matrix" --usage "$pct" "$resets" \
</dev/null >/dev/null 2>&1 || true
fi
# Footer line: session · model · context % and tokens · plan use. ANSI colour numbers, not
# RGB, so the browser's dark or light palette applies (devdesk#31). Claude sends
# rate_limits only to Pro/Max subscribers; there .cost is an API-price estimate,
# not a bill, so show the 5-hour plan use instead and keep $ for API-key billing.
jq -rM --arg s "$session" '
def c($sgr; $t): "\u001b[\($sgr)m\($t)\u001b[0m";
def pct: if . == null then "—" else "\(. | floor)%" end;
def tok: if . >= 1000000 then "\(. / 100000 | round / 10)M" elif . >= 1000 then "\(. / 1000 | round)k" else "\(.)" end;
def ctxtok: .context_window | (.current_usage | if . then (.input_tokens // 0) + (.cache_creation_input_tokens // 0)
+ (.cache_read_input_tokens // 0) else null end) as $u
| if $u == null then "" else " \($u | tok)\(if .context_window_size then "/\(.context_window_size | tok)" else "" end)" end;
[ (if $s != "" then c("33"; "◈ \($s)") else empty end),
c("36"; .model.display_name // "Claude"),
c("37"; "ctx \(.context_window.used_percentage | pct)\(ctxtok)"),
(if .rate_limits.five_hour.used_percentage != null
then c("37"; "5h \(.rate_limits.five_hour.used_percentage | pct)")
else c("37"; "$\((.cost.total_cost_usd // 0) * 100 | round / 100)") end)
] | join(c("90"; " · "))' <<<"$input" 2>/dev/null || true
+330 -1
View File
@@ -7,20 +7,216 @@
"name": "devdesk",
"license": "MIT",
"devDependencies": {
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.1",
"@codemirror/lang-markdown": "6.5.2",
"@codemirror/language": "6.13.1",
"@codemirror/legacy-modes": "6.5.5",
"@codemirror/merge": "6.12.2",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.6",
"@codemirror/view": "6.43.14",
"@fontsource/jetbrains-mono": "5.3.0",
"@lezer/highlight": "1.2.5",
"@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",
"dompurify": "3.4.16",
"esbuild": "0.28.2",
"eslint": "8.57.1"
"eslint": "8.57.1",
"marked": "18.1.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@codemirror/autocomplete": {
"version": "6.20.3",
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0"
}
},
"node_modules/@codemirror/commands": {
"version": "6.11.1",
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.7.0",
"@codemirror/view": "^6.27.0",
"@lezer/common": "^1.1.0"
}
},
"node_modules/@codemirror/lang-css": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@lezer/common": "^1.0.2",
"@lezer/css": "^1.1.7"
}
},
"node_modules/@codemirror/lang-html": {
"version": "6.4.12",
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/lang-css": "^6.0.0",
"@codemirror/lang-javascript": "^6.0.0",
"@codemirror/language": "^6.4.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/css": "^1.1.0",
"@lezer/html": "^1.3.12"
}
},
"node_modules/@codemirror/lang-javascript": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.0.0",
"@codemirror/language": "^6.6.0",
"@codemirror/lint": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/common": "^1.0.0",
"@lezer/javascript": "^1.0.0"
}
},
"node_modules/@codemirror/lang-markdown": {
"version": "6.5.2",
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.2.tgz",
"integrity": "sha512-AwBOdkWYuA//WcM0xO5PfHPUcmz/O2i5o0Nsg1U69SII/loCJlFI1Romd9xp2HYb1kYJRGZotyqRghuHH5n8Kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/autocomplete": "^6.7.1",
"@codemirror/lang-html": "^6.0.0",
"@codemirror/language": "^6.3.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.0.0",
"@lezer/common": "^1.2.1",
"@lezer/markdown": "^1.0.0"
}
},
"node_modules/@codemirror/language": {
"version": "6.13.1",
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/streamparser": "^6.0.0",
"@codemirror/view": "^6.23.0",
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0",
"style-mod": "^4.0.0"
}
},
"node_modules/@codemirror/legacy-modes": {
"version": "6.5.5",
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.5.tgz",
"integrity": "sha512-9ltT214oMb3Y1w5eAZMSigzrjzZnMI2Wa3Feq73rU/i4C56MkwrpfswfQYOr28C5pXr6dWwspNTMyeHXlzWMxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/streamparser": "^6.0.0"
}
},
"node_modules/@codemirror/lint": {
"version": "6.9.7",
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.42.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/merge": {
"version": "6.12.2",
"resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.12.2.tgz",
"integrity": "sha512-V8JvyAPjHbPupqP7BeMcsdsYCbyPij74jxIbaIJDORI+VZzW44zFmon8bF+oxGWvOKhcRmkiUMXd8MxHr3YA2w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/language": "^6.0.0",
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.17.0",
"@lezer/highlight": "^1.0.0",
"style-mod": "^4.1.0"
}
},
"node_modules/@codemirror/search": {
"version": "6.7.2",
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.0.0",
"@codemirror/view": "^6.37.0",
"crelt": "^1.0.5"
}
},
"node_modules/@codemirror/state": {
"version": "6.7.6",
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@marijn/find-cluster-break": "^1.0.0"
}
},
"node_modules/@codemirror/streamparser": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
"dev": true,
"license": "MIT"
},
"node_modules/@codemirror/view": {
"version": "6.43.14",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.14.tgz",
"integrity": "sha512-hC45YnmJNR1rz42OF57BLoQNFMV12/13drnvibDsR2quNDmldcb4WvYVV81hFQg56i9lcqQMEDqilhX2QDcGQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.7.0",
"crelt": "^1.0.6",
"style-mod": "^4.1.0",
"w3c-keyname": "^2.2.4"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -574,6 +770,87 @@
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@lezer/common": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@lezer/css": {
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/highlight": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.3.0"
}
},
"node_modules/@lezer/html": {
"version": "1.3.13",
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.0.0",
"@lezer/lr": "^1.0.0"
}
},
"node_modules/@lezer/javascript": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.2.0",
"@lezer/highlight": "^1.1.3",
"@lezer/lr": "^1.3.0"
}
},
"node_modules/@lezer/lr": {
"version": "1.4.11",
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
"integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.0.0"
}
},
"node_modules/@lezer/markdown": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.8.0.tgz",
"integrity": "sha512-X6w3A2NpyhdtGqTWRJB8gR4QDXBaFMbTOs780ZdZ15KKCfBXOQTKcB3Vr+W6tqJHVlealt53tLmHRvOqPTQgYA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lezer/common": "^1.5.0",
"@lezer/highlight": "^1.0.0"
}
},
"node_modules/@marijn/find-cluster-break": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -612,6 +889,14 @@
"node": ">= 8"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"dev": true,
"license": "MIT",
"optional": true
},
"node_modules/@ungap/structured-clone": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
@@ -812,6 +1097,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/crelt": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -865,6 +1157,16 @@
"node": ">=6.0.0"
}
},
"node_modules/dompurify": {
"version": "3.4.16",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.16.tgz",
"integrity": "sha512-sqo+pNp3qRhCIpbgRi1y8Tgk27Bo2Ry7w0dC1NBeNTdZChWjz9Xb/KOoZbRP/R6pQZ80Qw8YhXw13hWWBbMRnQ==",
"dev": true,
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
@@ -1423,6 +1725,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/marked": {
"version": "18.1.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.1.0.tgz",
"integrity": "sha512-PamYXWWWg2nboG3oX5Ffzpy3EFZROqZJK9hSlMmUnekp7TxPs5DcT95vwd46m73/exfKCRVMtgXoPMI4FEJL6w==",
"dev": true,
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
@@ -1705,6 +2020,13 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/style-mod": {
"version": "4.1.4",
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
"dev": true,
"license": "MIT"
},
"node_modules/supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
@@ -1761,6 +2083,13 @@
"punycode": "^2.1.0"
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"dev": true,
"license": "MIT"
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+17 -3
View File
@@ -4,21 +4,35 @@
"description": "Persistent browser terminal for Claude Code (ttyd + tmux), LotusGuild TDS styling",
"license": "MIT",
"type": "module",
"engines": { "node": ">=18" },
"engines": {
"node": ">=18"
},
"scripts": {
"build": "node web/build.mjs",
"lint": "eslint --ext .js,.mjs web/src web/build.mjs tests/protocol.test.mjs",
"lint": "eslint --ext .js,.mjs web/src web/editor web/build.mjs tests/protocol.test.mjs",
"test": "node --test tests/*.test.mjs"
},
"devDependencies": {
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.11.1",
"@codemirror/lang-markdown": "6.5.2",
"@codemirror/language": "6.13.1",
"@codemirror/legacy-modes": "6.5.5",
"@codemirror/merge": "6.12.2",
"@codemirror/search": "6.7.2",
"@codemirror/state": "6.7.6",
"@codemirror/view": "6.43.14",
"@fontsource/jetbrains-mono": "5.3.0",
"@lezer/highlight": "1.2.5",
"@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",
"dompurify": "3.4.16",
"esbuild": "0.28.2",
"eslint": "8.57.1"
"eslint": "8.57.1",
"marked": "18.1.0"
}
}
+90
View File
@@ -1,5 +1,6 @@
{
"preferredNotifChannel": "terminal_bell",
"theme": "dark-ansi",
"hooks": {
"SessionStart": [
{
@@ -8,9 +9,98 @@
"type": "command",
"command": "/usr/lib/devdesk/devdesk-session-hook",
"timeout": 10
},
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
],
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
],
"PermissionRequest": [
{
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-state-hook",
"timeout": 5
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "/usr/lib/devdesk/devdesk-guard",
"timeout": 5
}
]
}
]
},
"statusLine": {
"type": "command",
"command": "/usr/lib/devdesk/devdesk-statusline",
"padding": 0
}
}
+22 -13
View File
@@ -6,10 +6,16 @@ set -s escape-time 10 # Esc reaches Claude (interrupt) immediat
set -s focus-events on
set -s set-clipboard on # tmux copy-mode -> OSC 52 -> browser clipboard
set -s default-terminal "tmux-256color"
set -as terminal-features ",xterm-256color:RGB"
set -s terminal-features[90] "xterm-256color:RGB" # fixed index: re-sourcing must not append
set -g history-limit 50000
set -g mouse on # wheel scrolls history; Shift+drag selects natively
set -g allow-passthrough on
set -g mouse on # wheel scrolls history; a drag selects in the browser (devdesk#51)
# No right-click menus (devdesk#52): one stray click on them splits a pane or
# kills a window (Claude's). Splits and kills stay on the keyboard (^b % ^b x).
unbind -n MouseDown3Pane
unbind -n M-MouseDown3Pane
unbind -n MouseDown3Status
unbind -n MouseDown3StatusLeft
set -g allow-passthrough off # output may not bypass tmux (title/clipboard spoofing)
set -g renumber-windows on
# --- bells reach the browser (Claude's terminal_bell notifications) ----------
@@ -24,18 +30,21 @@ 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 ---------------------------------------------------
# --- colours ---------------------------------------------------------------------
# ANSI colour names, not hex: the browser maps them through its dark or light
# palette, so the status bar follows DevDesk's theme (devdesk#31). black is the
# bar, white muted text, cyan the accent, yellow the session name.
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-style "bg=black,fg=white"
set -g status-left "#[fg=yellow,bold] #S #[fg=brightblack,nobold]│ "
set -g status-left-length 40
set -g status-right "#[fg=#3e607a]devdesk #[fg=#00D4FF]%H:%M "
set -g status-right ""
set -g window-status-format " #I:#W "
set -g window-status-current-format "#[fg=#030508,bg=#00D4FF,bold] #I:#W "
set -g window-status-bell-style "fg=#FFB300,bold"
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"
set -g window-status-current-format "#[fg=cyan,reverse,bold] #I:#W "
set -g window-status-bell-style "fg=yellow,bold"
set -g message-style "bg=black,fg=cyan"
set -g mode-style "fg=cyan,reverse"
set -g pane-border-style "fg=brightblack"
set -g pane-active-border-style "fg=cyan"
source-file -q /etc/devdesk/tmux.local.conf
+1255 -7
View File
File diff suppressed because it is too large Load Diff
+32 -1
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
Client, FlowControl, decodeServer, encodeBinaryInput, encodeInit, encodeInput, encodeResize,
parseListOutput, parseTitle, sessionFromSearch, tokenUrl, wsUrl,
parseListOutput, parseTitle, pasteSafe, sessionFromSearch, slugify, stripAnsi, tokenUrl, wsUrl,
} from '../web/src/protocol.js';
const text = (bytes) => new TextDecoder().decode(bytes);
@@ -34,6 +34,19 @@ test('URLs follow the page location, including a path prefix', () => {
assert.equal(tokenUrl(loc), 'https://dev.lotusguild.org/token');
const sub = { protocol: 'http:', host: 'localhost:7681', pathname: '/desk/index.html' };
assert.equal(wsUrl(sub, '--list'), 'ws://localhost:7681/desk/ws?arg=--list');
assert.equal(wsUrl(loc, ['--rename', 'old', 'new']), 'wss://dev.lotusguild.org/ws?arg=--rename&arg=old&arg=new');
});
test('free text becomes a valid session name', () => {
assert.equal(slugify(' PBS upgrade! '), 'PBS-upgrade');
assert.equal(slugify('--../etc'), 'etc');
assert.equal(slugify('tinker_tickets'), 'tinker_tickets');
assert.equal(slugify('!!!'), '');
assert.equal(slugify('x'.repeat(80)).length, 64);
});
test('ANSI colour and title sequences are stripped', () => {
assert.equal(stripAnsi('\x1b[1;31mdevdesk: no\x1b[0m\x1b]0;t\x07'), 'devdesk: no');
});
test('only valid session names are taken from the URL', () => {
@@ -67,3 +80,21 @@ test('flow control pauses after highWater pending writes and resumes below lowWa
assert.equal(flow.release(), null); // 1
assert.equal(flow.release(), Client.RESUME); // 0
});
test('untrusted text loses control characters before it is typed (#2)', () => {
assert.equal(pasteSafe('ok\nrm -rf /\r\x1b[31mred\x07'), 'ok rm -rf / [31mred ');
assert.equal(pasteSafe('plain text'), 'plain text');
assert.equal(pasteSafe(null), '');
});
test('file paths in terminal output are found, URLs and plain words are not (#21)', async () => {
const { findPaths } = await import('../web/src/links.js');
const paths = (text) => findPaths(text).map((p) => `${p.path}${p.line ? `:${p.line}` : ''}`);
assert.deepEqual(paths('● Update(web/src/files.js)'), ['web/src/files.js']);
assert.deepEqual(paths(' ⎿ Wrote 12 lines to notes/todo.md'), ['notes/todo.md']);
assert.deepEqual(paths('error at src/app.py:42:7 and ./README.md:3'), ['src/app.py:42', './README.md:3']);
assert.deepEqual(paths('see /root/code/devdesk/Makefile'), ['/root/code/devdesk/Makefile']);
assert.deepEqual(paths('open https://code.lotusguild.org/a/b.js now'), []);
// Candidates only: they become links only if they're files of the session.
assert.deepEqual(paths('just some words, v1.2 and e.g. stuff'), ['v1.2']);
});
+27 -1
View File
@@ -2,6 +2,7 @@
// 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 { createHash } from 'node:crypto';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -33,8 +34,29 @@ async function fontFaces(weights) {
return faces.join('\n');
}
// The file viewer's editor (devdesk#19): its own IIFE, served over the ttyd
// channel by `devdesk attach --asset editor.js` and cached by the browser. The
// page carries its SHA-256, which is both the integrity check and the cache key.
async function editorBundle() {
const out = await build({
entryPoints: [at('web', 'editor', 'index.js')],
bundle: true,
minify: true,
format: 'iife',
globalName: 'DevDeskEditor',
target: ['es2020'],
write: false,
legalComments: 'eof',
});
const code = out.outputFiles[0].text;
await mkdir(at('dist', 'assets'), { recursive: true });
await writeFile(at('dist', 'assets', 'editor.js'), code);
return { sha: createHash('sha256').update(code).digest('hex'), size: Buffer.byteLength(code) };
}
async function main() {
const ver = await version();
const editor = await editorBundle();
const js = await build({
entryPoints: [at('web', 'src', 'main.js')],
@@ -44,7 +66,10 @@ async function main() {
target: ['es2020'],
write: false,
legalComments: 'eof',
define: { __DEVDESK_VERSION__: JSON.stringify(ver) },
define: {
__DEVDESK_VERSION__: JSON.stringify(ver),
__EDITOR_SHA__: JSON.stringify(editor.sha),
},
});
const css = await transform([
@@ -67,6 +92,7 @@ async function main() {
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})`);
console.log(`dist/assets/editor.js ${(editor.size / 1024).toFixed(0)} KiB sha256 ${editor.sha.slice(0, 12)}`);
}
main().catch((err) => {
+336
View File
@@ -0,0 +1,336 @@
// The file viewer's editor bundle (devdesk#19): CodeMirror 6 with stream-mode
// languages, a unified diff view, and markdown rendering. Built separately to
// dist/assets/editor.js (window.DevDeskEditor) and loaded over the ttyd channel
// the first time the Files pane opens, so the page itself stays small.
import {
EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars,
rectangularSelection, crosshairCursor, dropCursor,
} from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap, indentOnInput } from '@codemirror/language';
import { search, searchKeymap, highlightSelectionMatches, gotoLine as gotoLineDialog } from '@codemirror/search';
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { highlightTree, classHighlighter } from '@lezer/highlight';
import { markdownLanguage } from '@codemirror/lang-markdown';
import { unifiedMergeView } from '@codemirror/merge';
import { tags as t } from '@lezer/highlight';
import { shell } from '@codemirror/legacy-modes/mode/shell';
import { python } from '@codemirror/legacy-modes/mode/python';
import { javascript, json, typescript } from '@codemirror/legacy-modes/mode/javascript';
import { yaml } from '@codemirror/legacy-modes/mode/yaml';
import { toml } from '@codemirror/legacy-modes/mode/toml';
import { properties } from '@codemirror/legacy-modes/mode/properties';
import { nginx } from '@codemirror/legacy-modes/mode/nginx';
import { diff as diffMode } from '@codemirror/legacy-modes/mode/diff';
import { css } from '@codemirror/legacy-modes/mode/css';
import { xml, html } from '@codemirror/legacy-modes/mode/xml';
import { go } from '@codemirror/legacy-modes/mode/go';
import { rust } from '@codemirror/legacy-modes/mode/rust';
import { standardSQL } from '@codemirror/legacy-modes/mode/sql';
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
import { lua } from '@codemirror/legacy-modes/mode/lua';
import { perl } from '@codemirror/legacy-modes/mode/perl';
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
import { Marked } from 'marked';
import DOMPurify from 'dompurify';
const MODES = {
shell, python, javascript, json, typescript, yaml, toml, properties, nginx, diff: diffMode, css, xml, html,
go, rust, sql: standardSQL, dockerfile: dockerFile, lua, perl, ruby,
};
const BY_EXT = {
md: 'markdown', markdown: 'markdown', mdx: 'markdown',
sh: 'shell', bash: 'shell', zsh: 'shell', ksh: 'shell', env: 'shell', bats: 'shell',
py: 'python', pyi: 'python',
js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
ts: 'typescript', tsx: 'typescript', mts: 'typescript',
json: 'json', jsonc: 'json', webmanifest: 'json',
yml: 'yaml', yaml: 'yaml',
toml: 'toml',
ini: 'properties', cfg: 'properties', conf: 'properties', properties: 'properties',
service: 'properties', timer: 'properties', socket: 'properties', mount: 'properties', network: 'properties',
diff: 'diff', patch: 'diff',
css: 'css', scss: 'css',
xml: 'xml', svg: 'xml', plist: 'xml',
html: 'html', htm: 'html', php: 'html', j2: 'html', jinja: 'html',
go: 'go', rs: 'rust', sql: 'sql', lua: 'lua', pl: 'perl', pm: 'perl', rb: 'ruby',
};
// Language for a path: extension, a few well-known names, else none.
export function languageFor(path) {
const name = path.split('/').pop().toLowerCase();
if (name === 'dockerfile' || name.startsWith('dockerfile.') || name.endsWith('.dockerfile')) return 'dockerfile';
if (name === 'makefile' || name === 'gnumakefile') return 'shell';
if (/(^|\/)nginx\//.test(path) || name === 'nginx.conf') return 'nginx';
if (['.bashrc', '.profile', '.bash_profile', '.zshrc', 'pkgbuild'].includes(name)) return 'shell';
if (name.startsWith('.env')) return 'shell';
const ext = name.includes('.') ? name.split('.').pop() : '';
return BY_EXT[ext] ?? null;
}
function languageExtension(lang) {
if (lang === 'markdown') return markdownLanguage;
if (lang && MODES[lang]) return StreamLanguage.define(MODES[lang]);
return [];
}
// Colours come from the page's TDS variables, so dark/light mode just works.
const highlight = HighlightStyle.define([
{ tag: [t.keyword, t.controlKeyword, t.operatorKeyword, t.modifier], color: 'var(--accent-orange)' },
{ tag: [t.string, t.special(t.string), t.regexp], color: 'var(--accent-green)' },
{ tag: [t.comment, t.lineComment, t.blockComment, t.meta], color: 'var(--text-muted)', fontStyle: 'italic' },
{ tag: [t.number, t.bool, t.atom, t.null], color: 'var(--accent-purple)' },
{ tag: [t.typeName, t.className, t.namespace], color: 'var(--accent-amber)' },
{ tag: [t.function(t.variableName), t.function(t.propertyName), t.definition(t.variableName)], color: 'var(--accent-cyan)' },
{ tag: [t.propertyName, t.attributeName], color: 'var(--accent-cyan)' },
{ tag: [t.tagName], color: 'var(--accent-orange)' },
{ tag: [t.heading, t.heading1, t.heading2, t.heading3], color: 'var(--accent-cyan)', fontWeight: 'bold' },
{ tag: t.strong, fontWeight: 'bold' },
{ tag: t.emphasis, fontStyle: 'italic' },
{ tag: t.strikethrough, textDecoration: 'line-through' },
{ tag: [t.link, t.url], color: 'var(--accent-cyan)', textDecoration: 'underline' },
{ tag: [t.monospace], color: 'var(--accent-amber)' },
{ tag: [t.quote], color: 'var(--text-secondary)', fontStyle: 'italic' },
{ tag: t.inserted, color: 'var(--accent-green)' },
{ tag: t.deleted, color: 'var(--accent-red)' },
{ tag: t.invalid, color: 'var(--accent-red)' },
]);
const theme = EditorView.theme({
'&': { height: '100%', color: 'var(--text-primary)', backgroundColor: 'var(--bg-terminal)', fontSize: 'var(--dd-editor-font, 13px)' },
'.cm-scroller': { fontFamily: 'var(--font-mono)', lineHeight: '1.5' },
'.cm-content': { caretColor: 'var(--accent-orange)' },
'.cm-cursor': { borderLeftColor: 'var(--accent-orange)' },
'.cm-gutters': { backgroundColor: 'var(--bg-secondary)', color: 'var(--text-dim)', border: 'none', borderRight: '1px solid var(--border-dim)' },
'.cm-activeLine': { backgroundColor: 'var(--accent-cyan-dim)' },
'.cm-activeLineGutter': { backgroundColor: 'var(--accent-cyan-dim)', color: 'var(--accent-cyan)' },
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, ::selection': { backgroundColor: 'rgba(0, 150, 220, 0.28) !important' },
'.cm-searchMatch': { backgroundColor: 'var(--accent-amber-dim)', outline: '1px solid var(--accent-amber)' },
'.cm-searchMatch.cm-searchMatch-selected': { backgroundColor: 'var(--accent-orange-dim)' },
'.cm-selectionMatch': { backgroundColor: 'var(--accent-cyan-dim)' },
'.cm-panels': { backgroundColor: 'var(--bg-secondary)', color: 'var(--text-primary)', borderColor: 'var(--border-color)' },
'.cm-panel input, .cm-panel button': { fontFamily: 'var(--font-mono)', fontSize: '12px' },
'.cm-textfield': { backgroundColor: 'var(--bg-input)', color: 'var(--text-primary)', border: '1px solid var(--border-color)' },
'.cm-button': { backgroundImage: 'none', backgroundColor: 'var(--bg-tertiary)', color: 'var(--text-primary)', border: '1px solid var(--border-color)' },
'.cm-foldGutter span': { color: 'var(--text-dim)' },
// unified diff
'.cm-changedLine': { backgroundColor: 'var(--accent-green-dim) !important' },
'.cm-changedText': { backgroundColor: 'rgba(0, 200, 120, 0.22) !important' },
'.cm-deletedChunk': { backgroundColor: 'var(--accent-red-dim) !important' },
'.cm-deletedChunk .cm-deletedText, .cm-deletedChunk del': { backgroundColor: 'rgba(255, 45, 85, 0.22)', textDecoration: 'none' },
'.cm-insertedLine': { backgroundColor: 'var(--accent-green-dim)' },
'.cm-changeGutter': { width: '3px' },
'.cm-changedLineGutter': { backgroundColor: 'var(--accent-green)' },
'.cm-deletedLineGutter': { backgroundColor: 'var(--accent-red)' },
});
function baseExtensions(path, { readOnly = true, wrap = false, onCursor } = {}) {
return [
lineNumbers(), foldGutter(), highlightSpecialChars(), highlightActiveLine(), highlightActiveLineGutter(),
drawSelection(), dropCursor(), history(), search({ top: true }), highlightSelectionMatches(), bracketMatching(),
// VS Code habits (devdesk#55): Alt+click adds a cursor, Alt+drag selects a
// column, brackets and quotes close themselves, Ctrl+G goes to a line.
EditorState.allowMultipleSelections.of(true), rectangularSelection(), crosshairCursor(),
readOnly ? [] : [closeBrackets(), indentOnInput()],
syntaxHighlighting(highlight), theme, languageExtension(languageFor(path)),
onCursor ? EditorView.updateListener.of((update) => {
if (update.selectionSet || update.docChanged || update.focusChanged) onCursor(cursorInfo(update.state));
}) : [],
// Read-only document in an editable view: the caret, selection, arrow keys
// and Ctrl+F work, typing doesn't; no on-screen keyboard on phones.
EditorState.readOnly.of(readOnly),
readOnly ? EditorView.contentAttributes.of({ inputmode: 'none' }) : [],
keymap.of([
{ key: 'Mod-g', run: gotoLineDialog, preventDefault: true },
...(readOnly ? [] : closeBracketsKeymap), ...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap,
]),
wrap ? EditorView.lineWrapping : [],
];
}
// Where the cursor is, for the status bar: line, column, selections, selected
// characters; plus the document's line endings and indentation.
function cursorInfo(state) {
const main = state.selection.main;
const line = state.doc.lineAt(main.head);
let selected = 0;
for (const r of state.selection.ranges) selected += r.to - r.from;
return { line: line.number, col: main.head - line.from + 1, cursors: state.selection.ranges.length, selected, lines: state.doc.lines };
}
// Indentation a file uses, guessed from its first indented lines: "Tabs" or "Spaces: N".
export function detectIndent(text) {
let tabs = 0;
const widths = {};
for (const line of text.split('\n', 2000)) {
if (line.startsWith('\t')) tabs += 1;
else {
const m = /^( +)\S/.exec(line);
if (m) widths[m[1].length] = (widths[m[1].length] ?? 0) + 1;
}
}
const spaced = Object.values(widths).reduce((a, b) => a + b, 0);
if (tabs > spaced) return 'Tabs';
if (!spaced) return null;
const unit = [2, 4, 8, 3].find((w) => Object.keys(widths).every((k) => Number(k) % w === 0)) ?? 4;
return `Spaces: ${unit}`;
}
// Put the cursor on a line (1-based) and scroll it to the middle.
function gotoLine(view, line) {
const target = view.state.doc.line(Math.max(1, Math.min(line, view.state.doc.lines)));
view.dispatch({ selection: { anchor: target.from, head: target.to }, effects: EditorView.scrollIntoView(target.from, { y: 'center' }) });
view.focus();
}
// Read-only view of a file.
export function viewer(parent, { doc, path, wrap, onCursor }) {
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap, onCursor }) }) });
return { view, goto: (line) => gotoLine(view, line), destroy: () => view.destroy() };
}
// Editable file (devdesk#20): onChange(text) after every edit, onSave() on
// Ctrl/Cmd+S (which never reaches the browser or the terminal).
export function editor(parent, { doc, path, wrap, onChange, onSave, onCursor, onScroll }) {
const view = new EditorView({
parent,
state: EditorState.create({
doc,
extensions: [
keymap.of([{ key: 'Mod-s', preventDefault: true, run: () => { onSave?.(); return true; } }, indentWithTab]),
baseExtensions(path, { readOnly: false, wrap, onCursor }),
onScroll ? EditorView.domEventHandlers({ scroll: (event, v) => { onScroll(v.scrollDOM); } }) : [],
EditorView.updateListener.of((update) => {
if (update.docChanged) onChange?.(update.state.doc.toString());
}),
],
}),
});
return {
view,
goto: (line) => gotoLine(view, line),
info: () => cursorInfo(view.state),
text: () => view.state.doc.toString(),
// Replace the whole document (reload from disk) keeping the cursor roughly where it was.
setText: (text) => {
const pos = Math.min(view.state.selection.main.head, text.length);
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text }, selection: { anchor: pos } });
},
destroy: () => view.destroy(),
};
}
// Unified diff of the HEAD version against the working copy.
export function diff(parent, { original, modified, path, wrap }) {
const view = new EditorView({
parent,
state: EditorState.create({
doc: modified,
extensions: [baseExtensions(path, { wrap }), unifiedMergeView({ original, mergeControls: false, highlightChanges: true, gutter: true })],
}),
});
return { view, destroy: () => view.destroy() };
}
// ---------------------------------------------------------------- markdown (devdesk#55)
// Fence names and aliases -> a language for highlighting.
const FENCE = {
sh: 'shell', bash: 'shell', zsh: 'shell', shell: 'shell', console: 'shell', shellsession: 'shell', env: 'shell', dotenv: 'shell',
py: 'python', python: 'python', js: 'javascript', javascript: 'javascript', jsx: 'javascript', mjs: 'javascript', node: 'javascript',
ts: 'typescript', typescript: 'typescript', tsx: 'typescript', json: 'json', jsonc: 'json', json5: 'json',
yml: 'yaml', yaml: 'yaml', toml: 'toml', ini: 'properties', conf: 'properties', cfg: 'properties', properties: 'properties', systemd: 'properties',
nginx: 'nginx', diff: 'diff', patch: 'diff', css: 'css', scss: 'css', html: 'html', xml: 'xml', svg: 'xml',
go: 'go', golang: 'go', rust: 'rust', rs: 'rust', sql: 'sql', dockerfile: 'dockerfile', docker: 'dockerfile',
lua: 'lua', perl: 'perl', pl: 'perl', ruby: 'ruby', rb: 'ruby', md: 'markdown', markdown: 'markdown',
};
const parsers = new Map();
function parserFor(lang) {
const name = FENCE[(lang || '').toLowerCase()];
if (!name) return null;
if (!parsers.has(name)) parsers.set(name, name === 'markdown' ? markdownLanguage.parser : StreamLanguage.define(MODES[name]).parser);
return parsers.get(name);
}
const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
// Code as HTML with tok-* classes (CodeMirror's own parsers, no extra library).
export function highlightToHtml(code, lang) {
const parser = parserFor(lang);
if (!parser || code.length > 200000) return escapeHtml(code);
let out = '';
let pos = 0;
try {
highlightTree(parser.parse(code), classHighlighter, (from, to, classes) => {
if (from > pos) out += escapeHtml(code.slice(pos, from));
out += `<span class="${classes}">${escapeHtml(code.slice(from, to))}</span>`;
pos = to;
});
} catch {
return escapeHtml(code);
}
return out + escapeHtml(code.slice(pos));
}
// GitHub's heading ids: lowercase, punctuation dropped, spaces to dashes, and
// "-1", "-2" for repeats.
function slugger() {
const seen = new Map();
return (text) => {
const base = text.toLowerCase().trim().replace(/<[^>]*>/g, '').replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-') || 'section';
const n = seen.get(base) ?? 0;
seen.set(base, n + 1);
return n ? `${base}-${n}` : base;
};
}
// Leading YAML front matter (--- … ---) as simple key/value pairs.
function frontMatter(text) {
const m = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
if (!m) return { body: text, meta: null };
const meta = [];
for (const line of m[1].split(/\r?\n/)) {
const kv = /^([A-Za-z0-9_.-]+):\s*(.*)$/.exec(line);
if (kv) meta.push([kv[1], kv[2].replace(/^["']|["']$/g, '')]);
else if (meta.length && /^\s+\S/.test(line)) meta[meta.length - 1][1] += ` ${line.trim()}`;
}
return { body: text.slice(m[0].length), meta };
}
// Markdown to sanitized HTML for the preview (devdesk#55): GitHub-flavoured,
// heading ids, highlighted code blocks; returns { html, headings, meta }. No
// scripts, styles, forms, frames or inline style survive DOMPurify.
export function renderMarkdown(text) {
const { body, meta } = frontMatter(text);
const slug = slugger();
const headings = [];
const md = new Marked({
gfm: true,
breaks: false,
renderer: {
heading({ tokens, depth, text: raw }) {
const inner = this.parser.parseInline(tokens);
const id = slug(raw);
headings.push({ depth, id, text: inner.replace(/<[^>]*>/g, '') });
return `<h${depth} id="${id}"><a class="dd-anchor" href="#${id}" aria-hidden="true">#</a>${inner}</h${depth}>\n`;
},
code({ text: code, lang }) {
const name = (lang || '').split(/\s+/)[0];
return `<pre class="dd-code" data-lang="${escapeHtml(name)}"><code>${highlightToHtml(code.replace(/\n$/, ''), name)}</code></pre>\n`;
},
},
});
const html = DOMPurify.sanitize(md.parse(body), {
USE_PROFILES: { html: true },
FORBID_TAGS: ['style', 'form', 'iframe', 'object', 'embed', 'base', 'meta', 'link', 'textarea', 'select', 'button'],
FORBID_ATTR: ['style'],
});
return { html, headings, meta };
}
// Kept for older callers: just the HTML.
export function markdown(text) {
return renderMarkdown(text).html;
}
+122 -57
View File
@@ -2,12 +2,21 @@
<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="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
<meta name="color-scheme" content="dark light">
<meta name="theme-color" content="#030508">
<meta name="generator" content="devdesk">
<title>DevDesk</title>
<link rel="icon" id="favicon" href="__FAVICON__">
<script>
// Theme before first paint (same key as web_template's lt.theme), no dark flash.
try {
var t = localStorage.getItem('lt_theme');
if (t !== 'light' && t !== 'dark') t = matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', t);
document.documentElement.style.colorScheme = t;
} catch (e) { /* storage blocked: stay dark */ }
</script>
<!--STYLES-->
</head>
<body class="dd-app">
@@ -16,31 +25,53 @@
<div class="lt-header-left">
<div class="lt-brand">
<span class="lt-brand-title">DevDesk</span>
<span class="lt-brand-subtitle">Claude Code workspace</span>
</div>
<div class="dd-session">
<span class="lt-chip lt-chip-info" id="session-chip">—</span>
<button type="button" class="dd-session-chip" id="session-chip" data-state="" aria-haspopup="menu" aria-expanded="false" title="Switch session">
<span class="dd-state-dot" aria-hidden="true"></span><span class="dd-session-label" id="session-name">—</span><span class="dd-turn-timer" id="turn-timer" hidden></span><span class="dd-caret" aria-hidden="true">▾</span>
</button>
<span class="dd-pane-title" id="pane-title"></span>
<div class="dd-menu dd-switcher" id="switcher" role="menu" hidden></div>
</div>
</div>
<div class="lt-header-right">
<span class="lt-ws-status" id="ws-status" data-state="connecting" role="status">
<button type="button" class="lt-chip dd-attention" id="attention-chip" title="Sessions waiting for you"></button>
<button type="button" class="lt-btn lt-btn-danger lt-btn-sm dd-reauth" id="reauth" hidden title="Your Authelia session expired. Claude keeps running; sign in again to keep using the panels.">Sign in again</button>
<span class="lt-ws-status dd-ws" id="ws-status" data-state="connecting" role="status">
<span class="lt-dot"></span><span id="ws-label">connecting</span>
</span>
<button type="button" class="lt-chip dd-sso" id="sso-badge" data-state="unknown" title="Signed in through Authelia">SSO</button>
<div class="lt-btn-group dd-tools">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="font-down" title="Smaller text" aria-label="Smaller text">A−</button>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="font-up" title="Larger text" aria-label="Larger text">A+</button>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="notify-toggle" title="Desktop notification when Claude needs you">Alerts off</button>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="fullscreen-toggle" title="Fullscreen (also captures browser shortcuts in Chromium)">Full</button>
<button type="button" class="lt-btn lt-btn-sm" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false">Panels</button>
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-cmd-btn" id="cmd-toggle" title="Command palette (Ctrl+Shift+P)" aria-label="Open command palette">⌕<span class="dd-cmd-kbd">&nbsp;⌃⇧P</span></button>
<button type="button" class="lt-theme-btn" id="theme-toggle" aria-label="Switch to light mode" title="Switch to light mode">☀</button>
<div class="lt-notif-dropdown-wrap dd-inbox">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm lt-notif-bell-btn" id="inbox-toggle" aria-haspopup="dialog" aria-expanded="false" aria-controls="inbox-panel" aria-label="Inbox" title="Inbox">🔔</button>
<span class="lt-notif-badge" id="inbox-badge" data-count="0" aria-hidden="true"></span>
<div class="lt-notif-panel dd-inbox-panel" id="inbox-panel" role="dialog" aria-label="Inbox" aria-hidden="true">
<div class="lt-notif-panel-header"><span>Inbox</span><button type="button" class="lt-notif-panel-clear">Mark all read</button></div>
<div class="lt-notif-panel-list"></div>
</div>
</div>
<div class="dd-more">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-more-toggle" id="more-toggle" aria-haspopup="menu" aria-expanded="false" title="Settings" aria-label="Settings">⋯</button>
<div class="dd-menu dd-more-menu" id="more-menu" role="menu" hidden>
<button type="button" class="dd-menu-item" id="settings-menu" role="menuitem">Settings…</button>
<div class="dd-menu-row dd-usage-row" id="usage-row" hidden><span>Plan usage</span><span class="dd-usage" id="usage-menu"></span></div>
<button type="button" class="dd-menu-item" id="history-menu" role="menuitem">Search past conversations</button>
<button type="button" class="dd-menu-item" id="palette-menu" role="menuitem">Command palette</button>
<button type="button" class="dd-menu-item" id="help-open" role="menuitem">Keyboard shortcuts</button>
<button type="button" class="dd-menu-item" id="fullscreen-toggle" role="menuitem" title="In Chromium, fullscreen also captures Ctrl+W/Ctrl+T for the terminal">Fullscreen</button>
<div class="dd-menu-foot" id="version">devdesk</div>
</div>
</div>
<button type="button" class="lt-btn lt-btn-sm dd-files-toggle" id="files-toggle" aria-controls="files" aria-expanded="false" title="Files: browse, preview and diff this session's folder (Ctrl+Shift+E)"><span class="dd-panels-label">Files</span><span class="dd-panels-icon" aria-hidden="true">🗀</span></button>
<button type="button" class="lt-btn lt-btn-sm dd-panels" id="sidebar-toggle" aria-controls="sidebar" aria-expanded="false"><span class="dd-panels-label">Panels</span><span class="dd-panels-icon" aria-hidden="true">▤</span></button>
</div>
</header>
<main class="dd-main">
<section class="lt-frame dd-terminal-frame" aria-label="Terminal">
<div id="terminal" class="dd-terminal"></div>
<div id="drop-overlay" class="dd-drop-overlay" hidden><span>Drop to attach to <b id="drop-session">this session</b></span></div>
<div id="upload-status" class="dd-upload-status" role="status" hidden></div>
<div id="overlay" class="dd-overlay" data-tone="info" hidden>
<div class="lt-frame dd-overlay-card" role="alertdialog" aria-labelledby="overlay-title" aria-describedby="overlay-text">
<div class="lt-section-header" id="overlay-title">Disconnected</div>
@@ -52,71 +83,89 @@
</div>
</section>
<section class="lt-frame dd-files" id="files" aria-label="Files" hidden>
<div class="dd-files-resizer" id="files-resizer" role="separator" aria-orientation="vertical" aria-label="Resize the files pane" tabindex="0"></div>
<div class="dd-files-head">
<button type="button" class="dd-icon-btn dd-explorer-toggle" title="Show / hide the explorer" aria-label="Toggle explorer">☰</button>
<div class="dd-tabs" role="tablist" aria-label="Open files"></div>
<span class="dd-files-actions">
<button type="button" class="dd-icon-btn dd-files-new" title="New file" aria-label="New file">+</button>
<button type="button" class="dd-icon-btn dd-files-find" title="Go to file (Ctrl+P)" aria-label="Go to file">⌕</button>
<button type="button" class="dd-icon-btn dd-files-refresh" title="Refresh" aria-label="Refresh">↻</button>
<button type="button" class="dd-icon-btn dd-files-close" title="Close (Esc)" aria-label="Close files">✕</button>
</span>
</div>
<div class="dd-files-main">
<div class="dd-explorer">
<div class="dd-explorer-views" role="tablist">
<button type="button" role="tab" data-files-view="explorer" aria-selected="true">Explorer</button>
<button type="button" role="tab" data-files-view="changes" aria-selected="false">Changes <span class="dd-files-count"></span></button>
<button type="button" role="tab" data-files-view="search" aria-selected="false" title="Search in files (Ctrl+Shift+F)">Search</button>
<span class="dd-explorer-actions">
<button type="button" class="dd-icon-btn" data-explorer-act="new-file" title="New file" aria-label="New file">🗋</button>
<button type="button" class="dd-icon-btn" data-explorer-act="new-folder" title="New folder" aria-label="New folder">🗀</button>
<button type="button" class="dd-icon-btn" data-explorer-act="upload" title="Upload files (or drop them on a folder)" aria-label="Upload files">⇪</button>
<button type="button" class="dd-icon-btn" data-explorer-act="collapse" title="Collapse all folders" aria-label="Collapse all folders">⊟</button>
</span>
</div>
<div class="dd-explorer-body" role="tree" aria-label="Files" aria-multiselectable="true"></div>
<div class="dd-upload-bar" hidden><span class="dd-upload-text"></span><span class="dd-upload-meter"><span class="dd-upload-fill"></span></span></div>
<input type="file" class="dd-upload-input" multiple hidden>
</div>
<div class="dd-editor-area"></div>
</div>
</section>
<div class="dd-backdrop" id="backdrop" hidden></div>
<aside id="sidebar" class="dd-sidebar" aria-label="Panels">
<section class="lt-frame dd-widget" id="widget-sessions" data-state="live">
<div class="lt-section-header">Sessions
<span class="dd-header-actions">
<button type="button" class="dd-header-action" id="new-session-toggle" aria-expanded="false" title="New session">+ New</button>
<button type="button" class="dd-header-action" id="sessions-refresh" title="Refresh" aria-label="Refresh sessions">↻</button>
</span>
</div>
<div class="lt-section-body">
<form class="dd-new-session" id="new-session-form" autocomplete="off" hidden>
<input class="lt-input" id="new-session-name" maxlength="64" placeholder="Name, e.g. pbs-upgrade"
aria-label="New session name" spellcheck="false">
<select class="lt-select" id="project-select" aria-label="Start the new session in a project folder (optional)"></select>
<button type="submit" class="lt-btn lt-btn-primary lt-btn-sm">Open</button>
<label class="dd-check" id="new-session-worktree-label" hidden title="A separate checkout on branch devdesk/NAME, so this Claude never edits the same files as another session"><input type="checkbox" id="new-session-worktree"> own git worktree</label>
</form>
<ul class="dd-session-list" id="sessions-list"></ul>
<div class="dd-new-session">
<select class="lt-select" id="project-select" aria-label="Project for a new session"></select>
<button type="button" class="lt-btn lt-btn-primary lt-btn-sm" id="project-open">Open</button>
</div>
<div class="dd-widget-foot">
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm" id="open-picker">Picker</button>
<span class="dd-muted" id="sessions-status"></span>
</div>
<span class="dd-muted dd-status" id="sessions-status" role="status"></span>
</div>
</section>
<section class="lt-frame dd-widget" id="widget-repo" data-state="stub">
<div class="lt-section-header">Repo / CI <span class="lt-badge lt-badge-sm dd-soon">v1.1</span></div>
<section class="lt-frame dd-widget" id="widget-alerts" data-state="empty">
<div class="lt-section-header">Ops Alerts <span class="lt-badge lt-badge-sm dd-alert-count"></span></div>
<div class="lt-section-body">
<div class="lt-kv-grid">
<ul class="dd-alert-list"><li class="dd-empty">Loading…</li></ul>
<p class="dd-muted dd-stub-note dd-alerts-note"></p>
</div>
</section>
<section class="lt-frame dd-widget" id="widget-repo" data-state="empty">
<div class="lt-section-header">Repo / CI</div>
<div class="lt-section-body">
<div class="lt-kv-grid dd-repo-grid">
<span class="lt-kv-key">Repo</span><span class="lt-kv-val" data-field="repo">—</span>
<span class="lt-kv-key">Branch</span><span class="lt-kv-val" data-field="branch">—</span>
<span class="lt-kv-key">Tree</span><span class="lt-kv-val" data-field="dirty">—</span>
<span class="lt-kv-key">CI · HEAD</span><span class="lt-kv-val"><span class="lt-status lt-status-pending" data-field="ci">no data</span></span>
<span class="lt-kv-key">CI · HEAD</span><span class="lt-kv-val"><span class="lt-status" data-field="ci">—</span></span>
<span class="lt-kv-key">Open PRs</span><span class="lt-kv-val" data-field="prs">—</span>
</div>
<p class="dd-muted dd-stub-note">Gitea status for this session's repo arrives with devdesk-api.</p>
</div>
</section>
<section class="lt-frame dd-widget" id="widget-alerts" data-state="stub">
<div class="lt-section-header">Ops Alerts <span class="lt-badge lt-badge-sm dd-soon">v2</span></div>
<div class="lt-section-body">
<ul class="dd-alert-list">
<li class="dd-alert">
<span class="lt-dot lt-dot-idle"></span>
<span class="dd-alert-text">No alert source connected</span>
<button type="button" class="lt-btn lt-btn-sm dd-ask" disabled
data-ask-claude="Investigate the firing alert: ">Ask Claude</button>
</li>
</ul>
<p class="dd-muted dd-stub-note">Firing Grafana alerts, each with an “Ask Claude” prompt.</p>
</div>
</section>
<section class="lt-frame dd-widget" id="widget-context" data-state="stub">
<div class="lt-section-header">Claude Context <span class="lt-badge lt-badge-sm dd-soon">v1.1</span></div>
<div class="lt-section-body">
<div class="lt-progress lt-progress--cyan" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
<div class="lt-progress-bar" style="width:0%"></div>
</div>
<div class="lt-kv-grid dd-context-grid">
<span class="lt-kv-key">Used</span><span class="lt-kv-val" data-field="used">—</span>
<span class="lt-kv-key">Model</span><span class="lt-kv-val" data-field="model">—</span>
</div>
<p class="dd-muted dd-stub-note">Fed by a Claude statusLine hook through devdesk-api.</p>
<ul class="dd-repo-changes" hidden></ul>
<p class="dd-muted dd-repo-note"></p>
<button type="button" class="lt-btn lt-btn-sm dd-ask dd-fix-ci" hidden>Ask Claude to fix CI</button>
</div>
</section>
</aside>
</main>
<footer class="lt-footer dd-footer">
<div class="lt-footer-hints">
<div class="lt-footer-hints dd-hints">
<span class="lt-footer-hint"><span class="lt-footer-key">⇧⏎</span>newline</span>
<span class="lt-footer-sep">│</span>
<span class="lt-footer-hint"><span class="lt-footer-key">Esc</span>interrupt</span>
@@ -127,9 +176,25 @@
<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>
<span class="lt-footer-hint"><span class="lt-footer-key">drag</span>copy</span>
<span class="lt-footer-sep">│</span>
<span class="lt-footer-hint"><span class="lt-footer-key">drop / paste</span>attach files</span>
<span class="lt-footer-sep">│</span>
<span class="lt-footer-hint"><span class="lt-footer-key">^⇧P</span>commands</span>
<button type="button" class="dd-footer-help" id="footer-help">all keys</button>
</div>
<div class="dd-usage dd-footer-usage" id="usage-footer" hidden></div>
<div class="dd-keybar" id="keybar" role="toolbar" aria-label="Terminal keys" hidden>
<button type="button" data-keys="esc">Esc</button>
<button type="button" data-keys="tab">Tab</button>
<button type="button" data-keys="stab">⇧Tab</button>
<button type="button" data-keys="ctrlc">^C</button>
<button type="button" data-keys="up">↑</button>
<button type="button" data-keys="down">↓</button>
<button type="button" data-keys="enter">⏎</button>
<button type="button" data-keys="claude">Claude</button>
<button type="button" data-keys="shell">Shell</button>
</div>
<span class="dd-muted" id="version">devdesk</span>
</footer>
<!--SCRIPT-->
+84
View File
@@ -0,0 +1,84 @@
// Ops Alerts panel: firing/pending Grafana alert rules (`devdesk attach --alerts`),
// the Proxmox guest or node each is about (CT id, the node it runs on now), and
// an "Ask Claude" button that starts an investigation in a session of its own
// (devdesk#39).
import { el } from './dom.js';
import { slugify } from './protocol.js';
const SEVERITY = {
critical: 'lt-chip-critical',
warning: 'lt-chip-warn',
};
function age(iso) {
const t = Date.parse(iso || '');
if (Number.isNaN(t)) return '';
const s = Math.max(0, (Date.now() - t) / 1000);
if (s < 3600) return `${Math.max(1, Math.floor(s / 60))}m`;
if (s < 86400) return `${Math.floor(s / 3600)}h`;
return `${Math.floor(s / 86400)}d`;
}
// "CT 110 draupnir · monitor-02 · stopped", "node compute-storage-01".
function targetLabel(t) {
if (t.kind === 'unknown') return null;
const what = t.kind === 'node' ? `node ${t.name}` : `${t.kind.toUpperCase()} ${t.id} ${t.name}`;
const parts = [what, t.kind === 'node' ? null : t.node, t.status === 'running' || t.status === 'online' ? null : t.status];
return parts.filter(Boolean).join(' · ') + (t.by === 'name' ? ' (by name)' : '');
}
function targetTitle(t) {
const how = t.kind === 'ct' ? `ssh ${t.node} pct exec ${t.id} -- …`
: t.kind === 'vm' ? `ssh ${t.node} qm guest exec ${t.id} -- …` : `ssh ${t.name}`;
const note = t.by === 'name' ? '\nMatched by name, not by IP (the guest has not been seen running with this IP).'
: t.ip_seen ? '\nIts IP when it was last seen running.' : '';
return `${how}${t.ha ? `\nHA state: ${t.ha}` : ''}${note}`;
}
export const alertSession = (alert) => slugify(`alert-${alert.name}`).toLowerCase().slice(0, 48) || 'alert';
export function renderAlerts(root, data) {
const list = root.querySelector('.dd-alert-list');
const note = root.querySelector('.dd-alerts-note');
const count = root.querySelector('.dd-alert-count');
const alerts = data?.alerts ?? [];
count.textContent = alerts.length ? String(alerts.length) : '';
count.className = `lt-badge lt-badge-sm dd-alert-count${alerts.some((a) => a.severity === 'critical') ? ' lt-badge-p1' : alerts.length ? ' lt-badge-p2' : ''}`;
if (!data) {
root.dataset.state = 'empty';
list.replaceChildren(el('li', { class: 'dd-empty' }, 'Loading…'));
note.textContent = '';
return;
}
if (!data.configured) {
root.dataset.state = 'empty';
list.replaceChildren(el('li', { class: 'dd-empty' }, 'Grafana is not configured.'));
note.textContent = 'Set DEVDESK_GRAFANA_URL and a token file in /etc/devdesk/env.local.';
return;
}
root.dataset.state = 'live';
note.textContent = data.error ? `Grafana unreachable: ${data.error}` : '';
if (!alerts.length) {
list.replaceChildren(el('li', { class: 'dd-all-clear' },
el('span', { class: 'lt-dot lt-dot-up' }), data.error ? 'No data' : 'All clear: nothing firing'));
return;
}
list.replaceChildren(...alerts.map((a) => el('li', { class: `dd-alert is-${a.state}` },
el('div', { class: 'dd-alert-head' },
el('span', { class: `lt-chip ${SEVERITY[a.severity] || 'lt-chip-info'}` }, a.state === 'pending' ? 'pending' : (a.severity || 'alert')),
el('a', { class: 'dd-alert-name', href: a.url, target: '_blank', rel: 'noopener', title: `${a.folder} / ${a.group}` }, a.name),
el('span', { class: 'dd-muted dd-alert-age' }, age(a.since))),
a.instances?.length ? el('div', { class: 'dd-muted dd-alert-instances' }, a.instances.join(' · ')) : null,
...(a.targets ?? []).map((t) => {
const label = targetLabel(t);
return label ? el('div', { class: `dd-alert-where${t.by === 'name' ? ' is-guess' : ''}`, title: targetTitle(t) }, label) : null;
}),
a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null,
el('div', { class: 'dd-ask-row' },
el('button', {
type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-investigate': a.uid, 'data-session': alertSession(a),
title: `Start Claude in a new session (${alertSession(a)}) on this alert, with the rule, the query and where it runs (CT id, node)`,
}, 'Ask Claude')))));
}
+306 -11
View File
@@ -1,6 +1,6 @@
import {
Server, LIST_ARG, decodeServer, encodeBinaryInput, encodeCommand, encodeInit,
encodeInput, encodeResize, parseListOutput, tokenUrl, wsUrl, FlowControl,
encodeInput, encodeResize, parseListOutput, stripAnsi, tokenUrl, wsUrl, FlowControl,
} from './protocol.js';
export class AuthExpiredError extends Error {}
@@ -12,11 +12,17 @@ export async function fetchToken() {
const res = await fetch(tokenUrl(window.location), {
cache: 'no-store', credentials: 'same-origin', redirect: 'manual',
});
if (res.type === 'opaqueredirect') throw new AuthExpiredError('SSO session expired');
if (res.type === 'opaqueredirect') {
// Any request can discover an expired Authelia session; the header shows
// "Sign in again" when this fires (devdesk#13).
window.dispatchEvent(new CustomEvent('devdesk:auth', { detail: 'expired' }));
throw new AuthExpiredError('SSO session expired');
}
if (res.status === 401 || res.status === 403 || res.status === 407) {
throw new UnauthorizedError(`HTTP ${res.status}`);
}
if (!res.ok) throw new Error(`token request failed: HTTP ${res.status}`);
window.dispatchEvent(new CustomEvent('devdesk:auth', { detail: 'ok' }));
const body = await res.json();
return body.token || '';
}
@@ -94,35 +100,324 @@ export class TtydConnection {
}
}
// Ask the server for sessions + projects: a short-lived `devdesk attach --list`.
export async function fetchSessionList(timeoutMs = 8000) {
// Side-channel commands each occupy a ttyd client slot and a server process:
// run at most two at a time (devdesk#8).
let active = 0;
const waiting = [];
async function limited(task) {
if (active >= 2) await new Promise((resolve) => waiting.push(resolve));
active += 1;
try {
return await task();
} finally {
active -= 1;
waiting.shift()?.();
}
}
// Run a short-lived `devdesk attach <args…>` and return its output as text.
// ttyd is the only server-side endpoint, so session actions travel this way.
export function runCommand(args, timeoutMs = 8000) {
return limited(() => runCommandNow(args, timeoutMs));
}
// A full JSON reply, or devdesk's one-line error.
function isComplete(text) {
const clean = stripAnsi(text);
if (/^devdesk: .*\n/m.test(clean)) return true;
if (!/\}\s*$/.test(clean)) return false;
try {
parseListOutput(clean);
return true;
} catch {
return false;
}
}
async function runCommandNow(args, timeoutMs) {
const token = await fetchToken();
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl(window.location, LIST_ARG), ['tty']);
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
socket.binaryType = 'arraybuffer';
const chunks = [];
const timer = setTimeout(() => {
socket.close();
reject(new Error('session list timed out'));
reject(new Error('devdesk did not answer'));
}, timeoutMs);
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(new TextDecoder().decode(concat(chunks)));
};
const decoder = new TextDecoder();
let head = '';
let tail = '';
socket.onmessage = (event) => {
const { type, payload } = decodeServer(event.data);
if (type === Server.OUTPUT) chunks.push(payload.slice());
if (type !== Server.OUTPUT) return;
chunks.push(payload.slice());
const piece = decoder.decode(payload, { stream: true });
if (head.length < 4096) head += piece;
tail = (tail + piece).slice(-64);
// Hang up as soon as the answer is complete: devdesk waits for that
// before exiting, so ttyd can't drop its last output (devdesk#35).
// Replies are one JSON line (or a devdesk: error), so only a chunk that
// ends a line with "}" is worth a full parse; big replies stay linear.
if (/^devdesk: .*\n/m.test(head) || (/\}\s*\n\s*$/.test(tail) && isComplete(new TextDecoder().decode(concat(chunks))))) {
finish();
socket.close();
}
};
socket.onerror = () => {};
socket.onclose = finish;
});
}
// Sessions, projects and per-session Claude context (`devdesk attach --list`).
export async function fetchSessionList() {
return parseListOutput(await runCommand([LIST_ARG]));
}
// Firing/pending Grafana alerts (`devdesk attach --alerts`).
export async function fetchAlerts() {
return parseListOutput(await runCommand(['--alerts']));
}
function toBase64(bytes) {
let binary = '';
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export const UPLOAD_MAX = 25 * 1024 * 1024;
// Upload a file into the session's upload folder through ttyd: wait for the
// server's ready line, stream the file as base64 input in slices (never the
// whole file in memory), read back {ok, path}. Fails on 20 s of inactivity
// rather than a fixed total, so slow links still work (devdesk#9).
export function uploadFile(session, file, onProgress) {
if (!file.size) return Promise.reject(new Error('file is empty'));
if (file.size > UPLOAD_MAX) return Promise.reject(new Error('larger than 25 MB'));
return limited(() => uploadNow(session, file, onProgress));
}
async function uploadNow(session, file, onProgress) {
const name = (file.name || 'pasted-image.png').slice(-100);
const result = await streamIn(['--upload', session, name, String(file.size)], 'DEVDESK-UPLOAD-READY', file, onProgress);
if (!result.ok || !result.path) throw new Error('upload failed');
return result.path;
}
// Answer a session from the inbox (devdesk#22, #23).
export function answerPrompt(session, since, choice) {
return sessionAction('answer', session, String(since), choice);
}
export async function peekSession(session) {
return parseListOutput(await runCommand(['--peek', session], 15000));
}
// Claude's whole last answer, as Markdown (devdesk#47): {session, text, at}.
export async function lastReply(session) {
const output = stripAnsi(await runCommand(['--last-reply', session], 15000)).trim();
if (output.includes('"session"')) return parseListOutput(output);
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
throw new Error(message.replace(/^devdesk:\s*/, ''));
}
export async function replyTo(session, text) {
const bytes = new TextEncoder().encode(text);
const result = await limited(() => streamIn(['--reply', session, String(bytes.length)], 'DEVDESK-REPLY-READY', new Blob([bytes])));
if (!result.ok) throw new Error('not sent');
}
// Save a file from the editor (devdesk#20): `--fs-write SESSION PATH BYTES BASE`
// with the content streamed after the ready line. Resolves the server's JSON:
// {ok: true, sha, size, mtime} or {ok: false, conflict: true, sha} when the file
// changed since `base` (a sha256, or "new" to create).
export function writeFile(session, path, bytes, base) {
return limited(() => streamIn(['--fs-write', session, path, String(bytes.length), base], 'DEVDESK-WRITE-READY', new Blob([bytes])));
}
// Explorer uploads (devdesk#54): any file (up to 25 MB) to PATH in the session's
// folder, folders created as needed. mode "new" refuses to replace; "replace"
// overwrites. Resolves {ok, path, size}; rejects "already exists: …" etc.
export function putFile(session, path, file, mode = 'new', onProgress) {
if (file.size > UPLOAD_MAX) return Promise.reject(new Error('larger than 25 MB'));
return limited(() => streamIn(['--fs-put', session, path, String(file.size), mode], 'DEVDESK-PUT-READY', file, onProgress));
}
// A file of the session as a Blob, for the browser to save (up to 25 MB).
export async function downloadFile(session, path) {
const { header, base64 } = await runBlob(['--fs-download', session, path], 180000);
if (base64 === null || base64 === undefined) throw new Error('no content');
const bin = atob(base64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i += 1) bytes[i] = bin.charCodeAt(i);
return { name: (header.path || path).split('/').pop(), blob: new Blob([bytes]) };
}
// Run a devdesk command that reads base64 from the terminal after printing
// `ready`: stream `blob` in slices (never all in memory), then resolve the JSON
// reply. Fails on 20 s of inactivity rather than a fixed total (devdesk#9).
async function streamIn(args, ready, blob, onProgress) {
const token = await fetchToken();
const slice = 3 * 16 * 1024; // multiple of 3: base64 of slices concatenates cleanly
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
socket.binaryType = 'arraybuffer';
const decoder = new TextDecoder();
let output = '';
let sending = false;
let idle = 0;
const touch = () => {
clearTimeout(idle);
idle = setTimeout(() => socket.close(), 20000);
};
const send = async () => {
for (let offset = 0; offset < blob.size; offset += slice) {
while (socket.bufferedAmount > 1 << 20) await sleep(20);
if (socket.readyState !== WebSocket.OPEN) return;
const bytes = new Uint8Array(await blob.slice(offset, offset + slice).arrayBuffer());
socket.send(encodeInput(toBase64(bytes)));
touch();
onProgress?.(Math.min(1, (offset + slice) / blob.size));
}
};
const after = () => output.slice(output.indexOf(ready) + ready.length);
touch();
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => {
touch();
const { type, payload } = decodeServer(event.data);
if (type !== Server.OUTPUT) return;
output += decoder.decode(payload, { stream: true });
if (!sending && output.includes(ready)) {
sending = true;
send();
}
if (sending && isComplete(after())) {
socket.close(); // the reply is in; let devdesk exit (devdesk#35)
}
};
socket.onerror = () => {};
socket.onclose = () => {
clearTimeout(timer);
clearTimeout(idle);
const text = stripAnsi(output.includes(ready) ? after() : output);
try {
const text = new TextDecoder().decode(concat(chunks));
resolve(parseListOutput(text));
} catch (err) {
reject(err);
return;
} catch {
// fall through to the error message
}
const message = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop();
reject(new Error((message || 'no reply').replace(/^devdesk:\s*/, '')));
};
});
}
// Blob replies (`--fs-read`, `--git-show`, `--asset`): a JSON header line, then
// base64 between DEVDESK-BLOB-BEGIN/END. Resolves { header, base64 }, base64
// being null when the file has no body (too large, binary). Only the head and
// the tail of the stream are decoded while it arrives, so megabytes stay cheap.
export function runBlob(args, timeoutMs = 30000) {
return limited(() => runBlobNow(args, timeoutMs));
}
async function runBlobNow(args, timeoutMs) {
const token = await fetchToken();
return new Promise((resolve, reject) => {
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
socket.binaryType = 'arraybuffer';
const chunks = [];
const decoder = new TextDecoder();
let head = '';
let tail = '';
let settled = false;
const done = (fn, value) => {
if (settled) return;
settled = true;
clearTimeout(timer);
socket.close();
fn(value);
};
const timer = setTimeout(() => done(reject, new Error('devdesk did not answer in time')), timeoutMs);
const finish = () => {
const text = stripAnsi(new TextDecoder().decode(concat(chunks)));
const error = /^devdesk: (.*)$/m.exec(text);
const line = text.split(/\r?\n/).find((l) => l.startsWith('{'));
if (!line) {
done(reject, new Error(error ? error[1] : 'no reply'));
return;
}
let header;
try {
header = JSON.parse(line);
} catch {
done(reject, new Error('unreadable reply'));
return;
}
const body = /DEVDESK-BLOB-BEGIN\r?\n([\s\S]*?)\r?\nDEVDESK-BLOB-END/.exec(text);
done(resolve, { header, base64: body ? body[1].replace(/\s+/g, '') : null });
};
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
socket.onmessage = (event) => {
const { type, payload } = decodeServer(event.data);
if (type !== Server.OUTPUT) return;
chunks.push(payload.slice());
const piece = decoder.decode(payload, { stream: true });
if (head.length < 4096) head += piece;
tail = (tail + piece).slice(-256);
const firstLine = /^\s*(\{.*\})\r?$/m.exec(head);
if (tail.includes('DEVDESK-BLOB-END') || /^devdesk: .*\n/m.test(head)
|| (firstLine && !head.includes('DEVDESK-BLOB-BEGIN') && /"tooLarge":true/.test(firstLine[1]))) {
finish();
}
};
socket.onerror = () => {};
socket.onclose = () => {
if (!settled) finish();
};
});
}
// Git + Gitea CI status for a session's folder (`devdesk attach --repo NAME`).
export async function fetchRepo(session) {
return parseListOutput(await runCommand(['--repo', session]));
}
// `devdesk attach --new|--rename|--end …`: resolves on {"ok":true}, otherwise
// rejects with devdesk's own error message.
export async function sessionAction(action, ...args) {
const output = stripAnsi(await runCommand([`--${action}`, ...args])).trim();
// Success: the reply's JSON ({ok: true, …}) for callers that need its fields.
if (/"ok"\s*:\s*true/.test(output)) {
try {
return parseListOutput(output);
} catch {
return { ok: true };
}
}
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
throw new Error(message.replace(/^devdesk:\s*/, ''));
}
// Ops Alerts → Ask Claude (devdesk#39): start Claude in SESSION on alert rule UID.
// The server looks the alert up and writes the prompt. {session, started}.
export async function investigateAlert(session, uid) {
const output = stripAnsi(await runCommand(['--investigate', session, uid], 40000)).trim();
if (/"ok"\s*:\s*true/.test(output)) return parseListOutput(output);
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
throw new Error(message.replace(/^devdesk:\s*/, ''));
}
function concat(chunks) {
const total = chunks.reduce((n, c) => n + c.length, 0);
const out = new Uint8Array(total);
+13
View File
@@ -0,0 +1,13 @@
// Tiny DOM builder: el('a', { class, href, onclick }, ...children). Attributes
// that are null/undefined/false are skipped; on* keys become event listeners.
export function el(tag, attrs = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (value === undefined || value === null || value === false) continue;
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value === true ? '' : value);
}
node.append(...children.filter((c) => c !== null && c !== undefined));
return node;
}
+2486
View File
File diff suppressed because it is too large Load Diff
+155
View File
@@ -0,0 +1,155 @@
// Search past conversations (devdesk#24): every Claude conversation on this host
// (DevDesk and code-server), by what you asked, what Claude said, the commands
// it ran and their output. Resume one in a new session, or fork it.
import { runCommand, sessionAction } from './connection.js';
import { parseListOutput, slugify } from './protocol.js';
import { el } from './dom.js';
import { ago } from './sessions.js';
import { confirmDialog, openDialog, toast } from './ui.js';
const ROLE = { you: 'You', claude: 'Claude', command: 'Ran', output: 'Output' };
// Text with every case-insensitive occurrence of `query` in <mark>.
function highlight(text, query) {
const nodes = [];
const lower = text.toLowerCase();
const q = query.toLowerCase();
let at = 0;
for (let i = lower.indexOf(q); i >= 0 && q; i = lower.indexOf(q, at)) {
if (i > at) nodes.push(text.slice(at, i));
nodes.push(el('mark', {}, text.slice(i, i + q.length)));
at = i + q.length;
}
nodes.push(text.slice(at));
return nodes;
}
const day = (iso) => (iso ? new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '');
// hooks: sessions() -> session list, open(name), refresh()
export function openHistory(hooks) {
const input = el('input', { class: 'lt-input dd-history-input', type: 'search', placeholder: 'What did you work on? e.g. "NFS mount", "pct exec 167", "no_root_squash"', spellcheck: 'false', autocomplete: 'off' });
const status = el('div', { class: 'dd-dialog-hint' }, 'Searches every Claude conversation on this host: your prompts, Claude\'s answers, the commands it ran and their output.');
const results = el('div', { class: 'dd-history-results' });
let seq = 0;
let timer = 0;
let close = null;
const liveSessionFor = (id) => hooks.sessions().find((s) => s.running && s.claude_session_id === id)?.name;
// Resume or fork: confirm the new session's name inline, then open it.
const start = (result, fork, holder) => {
const name = el('input', { class: 'lt-input', value: slugify(`${fork ? 'fork-' : ''}${result.title}`).slice(0, 40) || 'resumed', spellcheck: 'false' });
const go = async () => {
const value = slugify(name.value);
if (!value) return;
try {
await sessionAction('resume-convo', value, result.id, ...(fork ? ['fork'] : []));
close?.();
toast.success(`${fork ? 'Forked' : 'Resumed'} “${result.title.slice(0, 40)}” in ${value}`);
hooks.refresh();
hooks.open(value);
} catch (error) {
toast.error(error.message);
}
};
name.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
go();
}
});
holder.replaceChildren(el('span', { class: 'dd-dialog-hint' }, `New session for ${fork ? 'a fork of ' : ''}this conversation:`), name,
el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: go }, fork ? 'Fork' : 'Resume'));
name.focus();
name.select();
};
const render = (data) => {
if (!data.results.length) {
results.replaceChildren(el('div', { class: 'dd-files-msg' }, `Nothing found for “${data.query}”.`));
return;
}
results.replaceChildren(...data.results.map((r) => {
const live = liveSessionFor(r.id);
const actions = el('div', { class: 'dd-history-actions' });
const fill = () => actions.replaceChildren(
live
? el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: () => { close?.(); hooks.open(live); } }, `Open ${live}`)
: el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: () => start(r, false, actions) }, 'Resume…'),
el('button', { type: 'button', class: 'lt-btn lt-btn-sm', onclick: () => start(r, true, actions), title: 'Continue as a new conversation; the original stays as it is' }, 'Fork…'),
el('button', {
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm',
onclick: () => navigator.clipboard?.writeText(`claude --resume ${r.id}`).then(() => toast.success('Copied the resume command')),
}, 'Copy command'),
live ? null : el('button', {
type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm dd-danger-text', title: 'Delete this conversation from this machine for good',
onclick: async () => {
const ok = await confirmDialog({
title: 'Delete this conversation?',
text: `“${r.title.slice(0, 80)}” (${day(r.started)}) is removed from this machine for good: its transcript and subagent files in ~/.claude/projects. It can't be resumed or found again. If its session had Remote Control on, a copy is also at claude.ai/code: delete it there too.`,
confirmLabel: 'Delete for good',
danger: true,
});
if (!ok) return;
try {
await sessionAction('delete-convo', r.id);
item.remove();
toast.success('Conversation deleted from this machine', 8000, [{ label: 'claude.ai/code', run: () => window.open('https://claude.ai/code', '_blank', 'noopener') }]);
} catch (error) {
toast.error(error.message);
}
},
}, 'Delete…'));
fill();
const item = el('div', { class: 'dd-history-item' },
el('div', { class: 'dd-history-title' }, r.title),
el('div', { class: 'dd-history-meta' },
[r.cwd, day(r.started), `updated ${ago(r.updated)}${ago(r.updated) === 'now' ? '' : ' ago'}`, `${r.hits} match${r.hits === 1 ? '' : 'es'}`, live ? `open in ${live}` : '']
.filter(Boolean).join(' · ')),
...r.matches.map((m) => el('div', { class: `dd-history-snippet is-${m.role}` },
el('span', { class: 'dd-history-role' }, ROLE[m.role] ?? m.role), el('span', {}, ...highlight(m.snippet, data.query)))),
actions);
return item;
}));
};
const search = async () => {
const query = input.value.trim();
if (query.length < 2) return;
const mine = ++seq;
status.textContent = `Searching for “${query}”…`;
try {
const data = parseListOutput(await runCommand(['--search', query], 30000));
if (mine !== seq) return;
const n = data.results.length;
status.textContent = `${n}${n === 25 ? '+' : ''} conversation${n === 1 ? ' mentions' : 's mention'} “${query}”, newest first.`;
render(data);
} catch (error) {
if (mine === seq) status.textContent = error.message;
}
};
input.addEventListener('input', () => {
clearTimeout(timer);
timer = setTimeout(search, 500);
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
clearTimeout(timer);
search();
}
});
openDialog({
title: 'Search past conversations',
className: 'is-wide',
body: el('div', { class: 'dd-history' }, input, status, results),
actions: [{ label: 'Close', value: null }],
onOpen: (overlay) => {
close = () => overlay.querySelector('.lt-modal-close')?.click();
return input;
},
});
}
+313
View File
@@ -0,0 +1,313 @@
// The bell (house .lt-notif-* pattern): what needs you right now (waiting
// sessions, firing Grafana alerts) plus recent events you may have missed
// (a background session finished or started waiting, CI passed or failed).
// Events live in localStorage, so every tab shows the same inbox.
import { el } from './dom.js';
import { ago } from './sessions.js';
import { answerPrompt, peekSession, replyTo } from './connection.js';
import { toast } from './ui.js';
const KEY = 'devdesk.inbox';
const MAX = 30;
const MAX_AGE = 7 * 86400 * 1000;
function load() {
try {
const list = JSON.parse(localStorage.getItem(KEY) || '[]');
return Array.isArray(list) ? list.filter((e) => Date.now() - e.at < MAX_AGE) : [];
} catch {
return [];
}
}
function save(events) {
try {
localStorage.setItem(KEY, JSON.stringify(events.slice(0, MAX)));
} catch {
// Private mode: the inbox lasts as long as the tab.
}
}
const when = (ms) => {
const a = ago(Math.floor(ms / 1000));
return a === 'now' ? 'just now' : `${a} ago`;
};
const KINDS = {
waiting: { icon: '!', tone: 'red' },
finished: { icon: '✓', tone: 'green' },
'ci-pass': { icon: '✓', tone: 'green' },
'ci-fail': { icon: '✗', tone: 'red' },
};
export class Inbox {
// hooks: openSession(name), showAlerts()
constructor({ button, badge, panel, hooks }) {
Object.assign(this, { button, badge, panel, hooks });
this.events = load();
this.waiting = [];
this.alerts = [];
this.known = null; // session names that exist, once the list has loaded
this.list = panel.querySelector('.lt-notif-panel-list');
panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead());
window.addEventListener('storage', (event) => {
if (event.key === KEY) {
this.events = load();
this.render();
}
});
}
get isOpen() {
return !this.panel.hasAttribute('aria-hidden');
}
setOpen(open) {
if (open) this.panel.removeAttribute('aria-hidden');
else this.panel.setAttribute('aria-hidden', 'true');
this.button.setAttribute('aria-expanded', String(open));
if (!open) this.peeking = null;
this.panel.classList.toggle('is-peek', false);
if (open) {
this.render();
this.markSeen();
}
}
// Opening the bell counts as seeing what's in it (devdesk#42): the badge clears
// but the entries keep their highlight until the panel is redrawn.
markSeen() {
if (!this.events.some((e) => !e.read)) return;
this.events = this.events.map((e) => ({ ...e, read: true }));
save(this.events);
this.renderBadge();
}
// Looking at a session reads what it reported (finished, waiting) while you were away.
markSessionRead(session) {
if (!session || !this.events.some((e) => !e.read && e.session === session && !e.url)) return;
this.events = this.events.map((e) => (e.session === session && !e.url ? { ...e, read: true } : e));
save(this.events);
this.render();
}
// Something happened. The same kind for the same session within a minute is
// one event (several tabs, quick flaps).
record({ kind, session, text, url }) {
const now = Date.now();
if (this.events.some((e) => e.kind === kind && e.session === session && now - e.at < 60000)) return;
this.events.unshift({ id: `${now}-${Math.random().toString(36).slice(2, 7)}`, kind, session, text, url, at: now, read: false });
this.events = this.events.slice(0, MAX);
save(this.events);
this.render();
}
// Live state: other sessions waiting on you, firing alerts.
setLive({ waiting, alerts, sessions }) {
if (waiting) this.waiting = waiting;
if (alerts) this.alerts = alerts;
if (sessions) this.known = new Set(sessions);
this.render();
}
markAllRead() {
this.events = this.events.map((e) => ({ ...e, read: true }));
save(this.events);
this.render();
}
unread() {
return this.events.filter((e) => !e.read && e.kind !== 'waiting' && !(this.known && e.session && !e.url && !this.known.has(e.session))).length;
}
render() {
this.renderBadge();
if (this.isOpen) this.renderPanel();
}
renderBadge() {
const count = this.waiting.length + this.alerts.length + this.unread();
const urgent = this.waiting.length > 0 || this.alerts.some((a) => a.severity === 'critical');
this.badge.textContent = count ? String(count) : '';
this.badge.dataset.count = String(count);
this.badge.classList.toggle('is-calm', count > 0 && !urgent);
this.button.title = count
? [
this.waiting.length && `${this.waiting.length} session(s) need you`,
this.alerts.length && `${this.alerts.length} alert(s) firing`,
this.unread() && `${this.unread()} new event(s)`,
].filter(Boolean).join(' · ')
: 'Inbox: nothing new';
}
// ------------------------------------------------------------ answer from here
async answer(session, since, choice, button) {
button.disabled = true;
try {
await answerPrompt(session, since, choice);
toast.success(choice === 'allow' ? `Allowed in ${session}` : `Denied in ${session}`);
this.hooks.refresh?.();
} catch (error) {
toast.error(`${session}: ${error.message}`);
this.hooks.refresh?.();
} finally {
button.disabled = false;
}
}
// Peek: the last exchange of a session, and a reply box.
async peek(session) {
this.peeking = session;
this.panel.classList.add('is-peek');
const body = el('div', { class: 'dd-peek' }, el('div', { class: 'dd-notif-empty' }, 'Loading…'));
this.list.replaceChildren(body);
let data;
try {
data = await peekSession(session);
} catch (error) {
body.replaceChildren(el('div', { class: 'dd-notif-empty' }, error.message));
return;
}
if (this.peeking !== session) return;
const input = el('textarea', { class: 'lt-input dd-peek-input', rows: '3', placeholder: `Reply to ${session}… (Ctrl+Enter sends)`, 'aria-label': `Reply to ${session}` });
const send = el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm' }, 'Send');
const submit = async () => {
const text = input.value.trim();
if (!text) return;
send.disabled = true;
try {
await replyTo(session, text);
toast.success(`Sent to ${session}`);
this.peeking = null;
this.setOpen(false);
this.hooks.refresh?.();
} catch (error) {
toast.error(`${session}: ${error.message}`);
} finally {
send.disabled = false;
}
};
send.addEventListener('click', submit);
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
submit();
}
event.stopPropagation(); // keys belong to the reply box, not the terminal or shortcuts
});
const status = data.status;
const waitingRequest = status?.state === 'waiting' && status.request;
body.replaceChildren(
el('div', { class: 'dd-peek-head' },
el('button', { type: 'button', class: 'dd-peek-back', onclick: () => { this.peeking = null; this.panel.classList.remove('is-peek'); this.renderPanel(); } }, '←'),
el('span', { class: 'dd-peek-title' }, session),
el('span', { class: 'dd-peek-state' }, status?.state ? `${status.state}${status.since ? ` · ${ago(status.since)}` : ''}` : '')),
data.user ? el('div', { class: 'dd-peek-user' }, el('span', { class: 'dd-peek-label' }, 'You'), data.user.text) : null,
el('div', { class: 'dd-peek-assistant' },
el('span', { class: 'dd-peek-label' }, 'Claude'),
data.assistant ? `${data.assistant.truncated ? '…' : ''}${data.assistant.text}` : 'Nothing yet.'),
data.commands?.length ? el('div', { class: 'dd-peek-commands' },
el('span', { class: 'dd-peek-label' }, 'Recent commands'),
...data.commands.slice(-8).map((c) => el('div', { class: 'dd-peek-command', title: c.cmd },
el('span', { class: 'dd-peek-when' }, ago(c.at)), el('code', {}, c.cmd)))) : null,
waitingRequest
? el('div', { class: 'dd-notif-empty' }, 'Claude is asking for a permission: answer it from the list, or open the session.')
: el('div', { class: 'dd-peek-reply' }, input, el('div', { class: 'dd-peek-actions' },
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: () => { this.setOpen(false); this.hooks.openSession(session); } }, 'Open session'),
send)));
if (!waitingRequest) input.focus();
body.querySelector('.dd-peek-assistant').scrollTop = 1e9;
}
renderPanel() {
if (this.peeking) return; // the peek view stays until you go back
const close = (run) => () => {
this.setOpen(false);
run();
};
const item = ({ dot, title, meta, unread, onclick }) => el('div', {
class: `lt-notif-item${unread ? ' lt-notif-item--unread' : ''}`, role: 'button', tabindex: '0', onclick,
onkeydown: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onclick();
}
},
},
el('span', { class: `lt-notif-dot dd-notif-${dot}` }),
el('div', { class: 'lt-notif-item-body' },
el('div', { class: 'lt-notif-item-title' }, title),
meta ? el('div', { class: 'lt-notif-item-time' }, meta) : null));
const nodes = [];
if (this.waiting.length) {
nodes.push(el('div', { class: 'dd-notif-section' }, 'Needs you'));
for (const s of this.waiting) {
const request = s.status?.request;
const node = item({
dot: 'red', unread: true, title: `${s.name}: ${s.status?.message || 'Claude is waiting for your input'}`,
meta: s.status?.since ? `waiting ${ago(s.status.since)}` : 'waiting',
onclick: close(() => this.hooks.openSession(s.name)),
});
const stop = (run) => (event) => {
event.stopPropagation();
run(event.currentTarget);
};
const body = node.querySelector('.lt-notif-item-body');
if (request) {
body.append(
request.description ? el('div', { class: 'dd-request-desc' }, request.description) : null,
el('code', { class: 'dd-request' }, request.detail || request.tool),
el('div', { class: 'dd-request-actions' },
el('button', { type: 'button', class: 'lt-btn lt-btn-primary lt-btn-sm', onclick: stop((b) => this.answer(s.name, s.status.since, 'allow', b)) }, 'Allow'),
el('button', { type: 'button', class: 'lt-btn lt-btn-danger lt-btn-sm', onclick: stop((b) => this.answer(s.name, s.status.since, 'deny', b)) }, 'Deny'),
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: stop(() => { this.setOpen(false); this.hooks.openSession(s.name); }) }, 'Open')));
} else {
body.append(el('div', { class: 'dd-request-actions' },
el('button', { type: 'button', class: 'lt-btn lt-btn-sm', onclick: stop(() => this.peek(s.name)) }, 'Peek & reply'),
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: stop(() => { this.setOpen(false); this.hooks.openSession(s.name); }) }, 'Open')));
}
nodes.push(node);
}
}
if (this.alerts.length) {
nodes.push(el('div', { class: 'dd-notif-section' }, 'Ops alerts'));
for (const a of this.alerts.slice(0, 5)) {
nodes.push(item({
dot: a.severity === 'critical' ? 'red' : 'amber', title: `${a.severity ? `${a.severity}: ` : ''}${a.name}`,
meta: [a.state, a.summary].filter(Boolean).join(' · '), onclick: close(() => this.hooks.showAlerts()),
}));
}
if (this.alerts.length > 5) nodes.push(el('div', { class: 'dd-notif-more' }, `+${this.alerts.length - 5} more in the Ops Alerts panel`));
}
const recent = this.events.filter((e) => e.kind !== 'waiting' || !this.waiting.some((s) => s.name === e.session));
if (recent.length) {
nodes.push(el('div', { class: 'dd-notif-section' }, 'Recent'));
for (const e of recent.slice(0, 12)) {
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
// An entry about a session that has since ended must not bring it back (devdesk#38).
const ended = !e.url && e.session && this.known && !this.known.has(e.session);
const recentNode = item({
dot: e.read || ended ? 'read' : kind.tone, unread: !e.read && !ended, title: e.text,
meta: `${when(e.at)}${ended ? ' · session ended' : ''}`,
onclick: close(() => {
this.events = this.events.map((x) => (x.id === e.id ? { ...x, read: true } : x));
save(this.events);
if (e.url) window.open(e.url, '_blank', 'noopener');
else if (ended) this.hooks.ended?.(e.session);
else if (e.session) this.hooks.openSession(e.session);
}),
});
if (e.kind === 'finished' && !ended) {
recentNode.querySelector('.lt-notif-item-body').append(el('div', { class: 'dd-request-actions' },
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: (event) => { event.stopPropagation(); this.peek(e.session); } }, 'Peek & reply')));
}
nodes.push(recentNode);
}
}
if (!nodes.length) nodes.push(el('div', { class: 'dd-notif-empty' }, 'Nothing needs you. Sessions that finish or wait while you\'re elsewhere show up here.'));
this.list.replaceChildren(...nodes);
}
}
+45
View File
@@ -0,0 +1,45 @@
// File paths in the terminal become links (devdesk#21): click one in Claude's
// output (web/src/files.js, ./notes.md:12, /root/code/x/y.py:3:9) to open it in
// the Files pane, at that line if there is one. Only paths that are files of
// the session become links, so words and URLs are left alone.
// A path: optional ./ ../ or /, segments, and either a slash or an extension;
// then an optional :line[:col].
const PATH_RE = /(?<![\w/.@~+-])((?:\.{1,2}\/|\/)?(?:[\w@+~-][\w.@+~-]*\/)*[\w@+~-][\w.@+~-]*(?:\.[A-Za-z0-9]{1,10})?)(?::(\d+)(?::(\d+))?)?/g;
const URL_RE = /\b[a-z][a-z0-9+.-]*:\/\/\S+/gi;
export function findPaths(text) {
const urls = [...text.matchAll(URL_RE)].map((m) => [m.index, m.index + m[0].length]);
const out = [];
for (const m of text.matchAll(PATH_RE)) {
const candidate = m[1];
if (!candidate.includes('/') && !/\.[A-Za-z0-9]{1,10}$/.test(candidate)) continue;
if (urls.some(([a, b]) => m.index >= a && m.index < b)) continue;
out.push({ index: m.index, length: m[0].length, path: candidate, line: m[2] ? Number(m[2]) : undefined });
}
return out;
}
export function registerPathLinks(term, { resolve, open }) {
term.registerLinkProvider({
provideLinks(y, callback) {
const line = term.buffer.active.getLine(y - 1);
if (!line) {
callback(undefined);
return;
}
const links = [];
for (const found of findPaths(line.translateToString(true))) {
const path = resolve(found.path);
if (!path) continue;
links.push({
range: { start: { x: found.index + 1, y }, end: { x: found.index + found.length, y } },
text: path,
decorations: { underline: true, pointerCursor: true },
activate: () => open(path, found.line),
});
}
callback(links.length ? links : undefined);
},
});
}
+1044 -74
View File
File diff suppressed because it is too large Load Diff
+28 -2
View File
@@ -6,13 +6,14 @@ import { FAVICON, FAVICON_BADGE } from './icon.js';
let unread = 0;
let baseTitle = document.title;
let statusPrefix = '';
function attentive() {
return !document.hidden && document.hasFocus();
}
function render() {
document.title = unread ? `(${unread}) ${baseTitle}` : baseTitle;
document.title = `${unread ? `(${unread}) ` : ''}${statusPrefix}${baseTitle}`;
const link = document.getElementById('favicon');
if (link) link.href = unread ? FAVICON_BADGE : FAVICON;
}
@@ -22,6 +23,13 @@ export function setBaseTitle(title) {
render();
}
// "⏳ 3m · " while Claude works on a turn in this tab's session (devdesk#48).
export function setStatusPrefix(prefix) {
if (prefix === statusPrefix) return;
statusPrefix = prefix;
render();
}
export function notificationsState() {
if (!('Notification' in window)) return 'unsupported';
return Notification.permission; // granted | denied | default
@@ -32,11 +40,29 @@ export async function requestNotifications() {
return Notification.requestPermission();
}
const MUTE_KEY = 'devdesk.notify.muted';
export function isMuted() {
try {
return localStorage.getItem(MUTE_KEY) === '1';
} catch {
return false;
}
}
export function setMuted(muted) {
try {
localStorage.setItem(MUTE_KEY, muted ? '1' : '0');
} catch {
// Private mode: the mute just won't persist.
}
}
export function ring(session, detail) {
if (attentive()) return;
unread += 1;
render();
if (notificationsState() === 'granted') {
if (notificationsState() === 'granted' && !isMuted()) {
const note = new Notification(`Claude · ${session || 'devdesk'}`, {
body: detail || 'Claude needs your attention',
tag: `devdesk-${session}`,
+24 -4
View File
@@ -53,12 +53,33 @@ export function tokenUrl(location) {
return `${location.protocol}//${location.host}${basePath(location)}token`;
}
export function wsUrl(location, arg) {
// ttyd --url-arg appends each ?arg= to the command line: `devdesk attach <args…>`.
export function wsUrl(location, args) {
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:';
const query = arg ? `?arg=${encodeURIComponent(arg)}` : '';
const list = Array.isArray(args) ? args : args ? [args] : [];
const query = list.length ? `?${list.map((a) => `arg=${encodeURIComponent(a)}`).join('&')}` : '';
return `${scheme}//${location.host}${basePath(location)}ws${query}`;
}
// Session names are [A-Za-z0-9_-]; turn free text ("PBS upgrade!") into one ("PBS-upgrade").
export function slugify(text) {
return String(text || '').trim().replace(/[^A-Za-z0-9_-]+/g, '-')
.replace(/^[-_]+/, '').replace(/-+$/, '').slice(0, 64);
}
// Untrusted text (e.g. Grafana alert annotations) typed into a terminal: drop
// every control character so it can't run a command (newline in a shell),
// submit early (CR) or inject escape sequences (devdesk#2).
export function pasteSafe(text) {
// eslint-disable-next-line no-control-regex
return String(text ?? '').replace(/[\x00-\x1f\x7f]+/g, ' ');
}
export function stripAnsi(text) {
// eslint-disable-next-line no-control-regex
return text.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, '').replace(/\x1b\][^\x07\x1b]*(\x07|\x1b\\)/g, '');
}
export function sessionFromSearch(search) {
const value = new URLSearchParams(search).get('arg');
return value && SESSION_RE.test(value) ? value : null;
@@ -74,8 +95,7 @@ export function parseTitle(title) {
// Output of `devdesk attach --list` arrives through a pty: CRLF line endings and
// possibly split across frames. Pull the JSON object out of the joined text.
export function parseListOutput(raw) {
// eslint-disable-next-line no-control-regex
const text = raw.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, ''); // tools may colorize on a tty
const text = stripAnsi(raw); // tools may colorize on a tty
const start = text.indexOf('{');
const end = text.lastIndexOf('}');
if (start < 0 || end < start) throw new Error('no session list in output');
+105
View File
@@ -0,0 +1,105 @@
// Repo / CI panel: git state of the current session's folder plus the Gitea CI
// status of its HEAD commit (`devdesk attach --repo <session>`).
const CI = {
success: ['passing', 'lt-status-completed'],
failure: ['failed', 'lt-status-failed'],
error: ['error', 'lt-status-failed'],
pending: ['running', 'lt-status-running'],
warning: ['warning', 'lt-status-pending'],
};
function link(href, text) {
const a = document.createElement('a');
a.href = href;
a.target = '_blank';
a.rel = 'noopener';
a.textContent = text;
return a;
}
// hooks: openDiff(path, diffable), openChanges() (devdesk#46)
export function renderRepo(root, repo, hooks = {}) {
const field = (name) => root.querySelector(`[data-field="${name}"]`);
const set = (name, value) => field(name).replaceChildren(value);
const grid = root.querySelector('.dd-repo-grid');
const note = root.querySelector('.dd-repo-note');
const fix = root.querySelector('.dd-fix-ci');
fix.hidden = true;
const changes = root.querySelector('.dd-repo-changes');
changes.hidden = true;
// No repository: one line instead of a grid of dashes (devdesk#17).
if (!repo || !repo.git) {
root.dataset.state = 'empty';
grid.hidden = true;
note.textContent = repo ? `Not a git repository: ${repo.dir}` : 'Open a session to see its repository.';
return;
}
root.dataset.state = 'live';
grid.hidden = false;
set('repo', repo.url ? link(repo.url, repo.repo) : (repo.remote || 'no remote'));
const sync = repo.upstream
? [repo.ahead ? `↑${repo.ahead}` : '', repo.behind ? `↓${repo.behind}` : ''].filter(Boolean).join(' ') || 'in sync'
: 'no upstream';
set('branch', `${repo.branch} @ ${repo.sha || '—'} · ${sync}`);
if (repo.dirty) {
const all = document.createElement('button');
all.type = 'button';
all.className = 'dd-link-btn';
all.textContent = `${repo.dirty} changed`;
all.title = 'Review all changes';
all.addEventListener('click', () => hooks.openChanges?.());
set('dirty', all);
} else {
set('dirty', 'clean');
}
// The first changed files: click one for its diff against HEAD.
const files = (repo.changes ?? []).filter((f) => f.path !== null && f.path !== undefined);
if (files.length) {
changes.hidden = false;
changes.replaceChildren(...files.map((f) => {
const diffable = f.x !== '?' && f.y !== '?';
const code = f.x === '?' ? 'new' : (f.x !== ' ' ? f.x : f.y);
const li = document.createElement('li');
const b = document.createElement('button');
b.type = 'button';
b.className = 'dd-repo-change';
b.title = `${f.path}${diffable ? ' · diff against HEAD' : ' · new file'}${f.claude ? ' · edited by Claude' : ''}`;
const name = document.createElement('span');
name.className = 'dd-repo-change-name';
name.textContent = f.path;
const stat = document.createElement('span');
stat.className = 'dd-repo-change-stat';
stat.textContent = [f.add ? `+${f.add}` : '', f.del ? `−${f.del}` : '', code === 'new' ? 'new' : code.trim()]
.filter(Boolean).join(' ');
b.append(name, stat);
b.addEventListener('click', () => hooks.openDiff?.(f.path, diffable));
li.append(b);
return li;
}));
if (repo.dirty > files.length) {
const more = document.createElement('li');
more.className = 'dd-muted';
more.textContent = `+${repo.dirty - files.length} more`;
changes.append(more);
}
}
const ci = field('ci');
const [label, cls] = repo.ci?.total ? (CI[repo.ci.state] || [repo.ci.state, 'lt-status-pending'])
: [repo.ahead ? 'not pushed' : 'no CI', ''];
ci.className = `lt-status ${cls}`;
ci.replaceChildren(repo.ci?.url ? link(repo.ci.url, label) : label);
set('prs', repo.url && repo.prs != null ? link(`${repo.url}/pulls`, `${repo.prs} open`) : '—');
note.textContent = repo.repo ? '' : 'Not a Gitea repository; CI status unavailable.';
if (repo.ci?.total && ['failure', 'error'].includes(repo.ci.state)) {
fix.hidden = false;
fix.dataset.askClaude = `CI is failing for ${repo.repo} on ${repo.branch} at ${repo.sha}`
+ ` (${repo.ci.url || 'see Gitea Actions'}). Use the gitea MCP server to read the failed job's log,`
+ ' find the cause, and fix it. Tell me the plan before pushing anything.';
}
}
+318 -53
View File
@@ -1,7 +1,13 @@
// Sessions panel: server state (via `devdesk attach --list`) plus recently used
// sessions remembered in this browser.
// Sessions panel: server state (via `devdesk attach --list`), recently used
// sessions remembered in this browser, and session actions (new/rename/end).
import { fetchSessionList } from './connection.js';
import { fetchSessionList, sessionAction } from './connection.js';
import { slugify } from './protocol.js';
import { share } from './shared.js';
import { el } from './dom.js';
import { confirmDialog, openDialog, toast } from './ui.js';
export { el };
const RECENTS_KEY = 'devdesk.recents';
const MAX_RECENTS = 12;
@@ -15,18 +21,48 @@ function readRecents() {
}
}
export function rememberSession(name) {
if (!name) return;
function writeRecents(recents) {
try {
const recents = readRecents().filter((r) => r.name !== name);
recents.unshift({ name, at: Date.now() });
localStorage.setItem(RECENTS_KEY, JSON.stringify(recents.slice(0, MAX_RECENTS)));
} catch {
// Storage unavailable (private mode): recents are a convenience only.
}
}
function ago(epochSeconds) {
// Session names this browser used most recently, newest first.
export function recentNames() {
return readRecents().map((r) => r.name);
}
export function rememberSession(name) {
if (name) writeRecents([{ name, at: Date.now() }, ...readRecents().filter((r) => r.name !== name)]);
}
function forgetRecent(name) {
writeRecents(readRecents().filter((r) => r.name !== name));
}
function renameRecent(from, to) {
writeRecents(readRecents().map((r) => (r.name === from ? { ...r, name: to } : r)));
}
// How long a turn has been running: "45s", "3m", "1h 05m" (devdesk#48).
export function elapsed(sinceSeconds) {
const s = Math.max(0, Math.floor(Date.now() / 1000 - sinceSeconds));
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m`;
return `${Math.floor(s / 3600)}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`;
}
// "ctx 34% · 346k/1M": context use as a share of the window and in tokens.
const tokens = (n) => (n >= 1e6 ? `${+(n / 1e6).toFixed(n % 1e6 ? 1 : 0)}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
export function ctxLabel(c) {
if (c?.used_pct == null) return '';
const count = c.used_tokens != null ? ` · ${tokens(c.used_tokens)}${c.window ? `/${tokens(c.window)}` : ''}` : '';
return `ctx ${Math.floor(c.used_pct)}%${count}`;
}
export function ago(epochSeconds) {
if (!epochSeconds) return '';
const s = Math.max(0, Date.now() / 1000 - epochSeconds);
if (s < 90) return 'now';
@@ -35,84 +71,313 @@ function ago(epochSeconds) {
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);
// What Claude is doing in a session, from the devdesk-state-hook snapshot.
// Returns { key, label, dot } for display; key is one of
// waiting | working | idle | exited | running | dormant.
export function sessionStatus(s) {
if (!s?.running) return { key: 'dormant', label: 'dormant', dot: 'lt-dot-idle' };
switch (s.status?.state) {
case 'waiting': return { key: 'waiting', label: 'needs you', dot: 'lt-dot-down' };
case 'working': return { key: 'working', label: 'working', dot: 'lt-dot-warn' };
case 'idle': return { key: 'idle', label: 'idle', dot: 'lt-dot-up' };
case 'exited': return { key: 'exited', label: 'Claude exited', dot: 'lt-dot-idle' };
default: return { key: 'running', label: 'running', dot: 'lt-dot-up' };
}
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 });
// hooks: onOpen(name), onEnding(name), onEnded(name), onEndAborted(), onRenamed(from, to), onData(data),
// onCompact(name), onCopyReply(name)
constructor({ list, form, nameInput, select, worktree, refreshButton, newToggle, status, hooks }) {
Object.assign(this, { list, form, nameInput, select, worktree, newToggle, status, hooks });
select.addEventListener('change', () => this.worktreeOption(select, worktree));
this.current = null;
this.data = null;
this.pending = null;
refreshButton.addEventListener('click', () => this.refresh());
pickerButton.addEventListener('click', () => onPicker());
openButton.addEventListener('click', () => {
if (select.value) onOpen(select.value);
newToggle.addEventListener('click', () => this.toggleForm());
form.addEventListener('submit', (event) => {
event.preventDefault();
this.createFromForm();
});
}
toggleForm(open = this.form.hidden) {
this.form.hidden = !open;
this.newToggle.setAttribute('aria-expanded', String(open));
if (open) this.nameInput.focus();
}
setCurrent(name) {
this.current = name;
if (this.data) this.render();
}
async refresh() {
this.status.textContent = 'refreshing…';
setStatus(text, isError = false) {
this.status.textContent = text;
this.status.classList.toggle('is-error', isError);
}
// Concurrent callers share the request in flight (boot awaits the first list).
refresh() {
this.pending ??= this.load().finally(() => { this.pending = null; });
return this.pending;
}
// After an action: a poll already in flight may predate it, so fetch again.
async reload() {
await this.pending;
return this.refresh();
}
async load() {
let fetched = false;
try {
this.data = await fetchSessionList();
this.status.textContent = '';
fetched = true;
share('sessions', this.data);
if (!this.status.classList.contains('is-error')) this.setStatus('');
} catch (err) {
this.status.textContent = `list unavailable: ${err.message}`;
this.setStatus(`list unavailable: ${err.message}`, true);
}
this.render();
this.hooks.onData?.(this.data, { fetched });
}
// A list another tab fetched (devdesk#8).
accept(data) {
this.data = data;
this.render();
this.hooks.onData?.(this.data, { fetched: false });
}
// Side-channel action; failures become an error toast (devdesk#34).
async act(action, ...args) {
try {
await sessionAction(action, ...args);
return true;
} catch (err) {
toast.error(err.message);
return false;
}
}
// The worktree choice only makes sense for a project that is a git repository (devdesk#44).
worktreeOption(select, box) {
if (!box) return;
const git = Boolean(this.data?.projects?.find((p) => p.name === select.value && p.git && select.value));
box.closest('label').hidden = !git;
if (!git) box.checked = false;
}
async createFromForm() {
const project = this.select.value;
const name = slugify(this.nameInput.value) || project;
if (!name) {
toast.warning('Type a session name or pick a project.');
this.nameInput.focus();
return;
}
if (!(await this.create(name, project, this.worktree?.checked))) return;
this.nameInput.value = '';
this.select.value = '';
this.worktreeOption(this.select, this.worktree);
this.toggleForm(false);
}
// With a project the session starts in that folder; without one, opening it
// creates it in the default folder (or its own project folder, by name).
async create(name, project, worktree = false) {
if (project && !(await this.act('new', name, project, ...(worktree ? ['--worktree'] : [])))) return false;
this.hooks.onOpen(name);
return true;
}
projectOptions(select) {
const projects = this.data?.projects ?? [];
const chosen = select.value;
select.replaceChildren(
el('option', { value: '' }, `No project (${this.data?.root ?? 'default folder'})`),
...projects.filter((p) => p.name !== this.data?.default)
.map((p) => el('option', { value: p.name, title: p.dir }, p.name)));
select.value = chosen;
}
// "+ New session…" from the switcher or the command palette (devdesk#33).
async newDialog() {
const name = el('input', { class: 'lt-input', maxlength: '64', placeholder: 'e.g. pbs-upgrade', spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-new-hint' });
const project = el('select', { class: 'lt-select' });
this.projectOptions(project);
const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-new-hint' }, 'Letters, numbers, - and _. Any name works; pick a project to start in its folder.');
const worktree = el('input', { type: 'checkbox' });
const worktreeLabel = el('label', { class: 'dd-check', hidden: '' }, worktree,
el('span', {}, 'Own git worktree: a separate checkout on branch devdesk/NAME, so this Claude never edits the same files as another session'));
project.addEventListener('change', () => {
if (!name.value.trim()) name.placeholder = project.value || 'e.g. pbs-upgrade';
this.worktreeOption(project, worktree);
});
const result = await openDialog({
title: 'New session',
body: el('div', { class: 'dd-dialog-fields' },
el('label', { class: 'dd-field' }, el('span', {}, 'Name'), name),
el('label', { class: 'dd-field' }, el('span', {}, 'Project folder'), project),
worktreeLabel,
hint),
actions: [{ label: 'Cancel' }, { label: 'Open', primary: true }],
submit: () => {
const value = slugify(name.value) || project.value;
if (!value) {
hint.textContent = 'Type a name or pick a project.';
hint.classList.add('is-error');
name.focus();
return null;
}
return { name: value, project: project.value, worktree: worktree.checked };
},
});
if (result) await this.create(result.name, result.project, result.worktree);
}
async rename(name) {
const input = el('input', { class: 'lt-input', maxlength: '64', value: name, spellcheck: 'false', autocomplete: 'off', 'aria-describedby': 'dd-rename-hint' });
const hint = el('p', { class: 'dd-dialog-hint', id: 'dd-rename-hint' }, 'Claude keeps running; the conversation moves with the session.');
const next = await openDialog({
title: `Rename ${name}`,
size: 'sm',
body: el('div', { class: 'dd-dialog-fields' }, el('label', { class: 'dd-field' }, el('span', {}, 'New name'), input), hint),
actions: [{ label: 'Cancel' }, { label: 'Rename', primary: true }],
onOpen: () => {
input.select();
return input;
},
submit: () => {
const value = slugify(input.value);
if (!value) {
hint.textContent = 'Names use letters, numbers, - and _.';
hint.classList.add('is-error');
return null;
}
if (value !== name && this.data?.sessions?.some((x) => x.name === value)) {
hint.textContent = `A session called ${value} already exists.`;
hint.classList.add('is-error');
return null;
}
return value;
},
});
if (!next || next === name) return;
if (await this.act('rename', name, next)) {
renameRecent(name, next);
this.hooks.onRenamed(name, next);
toast.success(`Renamed ${name} → ${next}`);
await this.reload();
}
}
async end(session) {
const ok = await confirmDialog({
title: session.running ? `End ${session.name}?` : `Forget ${session.name}?`,
text: (session.running
? 'Claude in this session stops and the session closes. The conversation stays in Claude\'s history (claude --resume).'
: 'This dormant session is removed from the list. Its conversation stays in Claude\'s history.')
+ (session.worktree ? ` Its worktree is removed if nothing in it is uncommitted; branch ${session.worktree.branch} is deleted only if it is merged.` : ''),
confirmLabel: session.running ? 'End session' : 'Forget',
danger: true,
});
if (!ok) return;
if (session.name === this.current) this.hooks.onEnding?.(session.name);
if (await this.act('end', session.name)) {
forgetRecent(session.name);
this.hooks.onEnded(session.name);
toast.success(session.running ? `Ended ${session.name}` : `Forgot ${session.name}`);
await this.reload();
} else if (session.name === this.current) {
this.hooks.onEndAborted?.();
}
}
renderSession(s) {
const isCurrent = s.name === this.current;
const status = sessionStatus(s);
const ctx = ctxLabel(s.context);
const since = status.key === 'working' && s.status?.since ? elapsed(s.status.since)
: s.status?.since ? ago(s.status.since) : ago(s.activity);
const model = isCurrent && s.context?.model ? s.context.model.replace(/^Claude\s+/i, '') : '';
const meta = s.running
? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, model, s.attached ? `${s.attached} open` : '']
.filter(Boolean).join(' · ')
: 'dormant · resumes Claude';
const pct = s.context?.used_pct;
const title = [
s.dir,
status.key === 'waiting' && s.status?.message,
// rate_limits means a Pro/Max plan, where cost_usd is only an API-price estimate
s.context?.model && `${s.context.model}${s.context.rate_limits ? '' : s.context.cost_usd != null ? ` · $${s.context.cost_usd.toFixed(2)}` : ''}`,
].filter(Boolean).join('\n');
const iconButton = (label, glyph, onclick, extra = '') =>
el('button', { class: `dd-icon-btn ${extra}`, type: 'button', title: label, 'aria-label': `${label} ${s.name}`, onclick }, glyph);
return el('li', { class: `dd-session-item is-${status.key}${isCurrent ? ' is-current' : ''}` },
el('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
el('span', { class: `lt-dot ${status.dot}` }),
el('span', { class: 'dd-session-name' }, s.name,
// Remote Control on: synced to claude.ai and the Claude app (devdesk#61).
s.rc_active ? el('span', { class: 'dd-tag dd-rc', title: 'Remote Control is on: this session syncs to claude.ai and the Claude app. Change it in Settings.' }, 'RC') : null,
s.worktree ? el('span', { class: 'dd-tag dd-wt', title: `Own worktree on ${s.worktree.branch}\n${s.worktree.path}` }, `⑂ ${s.worktree.branch.replace(/^devdesk\//, '')}`) : null,
s.tags?.length ? el('span', { class: 'dd-tags', title: 'Hosts this session worked on' }, ...s.tags.slice(0, 3).map((t) => el('span', { class: 'dd-tag' }, t))) : null,
// The pull request of this branch (devdesk#25)
s.context?.pr?.number ? el('a', {
class: `dd-pr is-${s.context.pr.review_state || 'open'}`, href: s.context.pr.url, target: '_blank', rel: 'noopener',
title: `PR #${s.context.pr.number}${s.context.pr.review_state ? ` · ${s.context.pr.review_state.replace(/_/g, ' ')}` : ''}`,
onclick: (event) => event.stopPropagation(),
}, `PR #${s.context.pr.number}${{ approved: ' ✓', changes_requested: ' ✗' }[s.context.pr.review_state] ?? ''}`) : null),
el('span', { class: 'dd-session-meta' }, meta),
pct != null ? el('span', { class: `dd-ctx${pct >= 90 ? ' is-full' : pct >= 70 ? ' is-high' : ''}`, 'aria-hidden': 'true' },
el('span', { class: 'dd-ctx-fill', style: `width:${Math.min(100, pct)}%` })) : null),
el('span', { class: 'dd-session-actions' },
// Context nearly full and Claude idle: /compact from here (devdesk#49).
s.running && pct >= 80 && status.key === 'idle'
? iconButton(`Compact (context ${Math.floor(pct)}% full)`, '⇲', () => this.hooks.onCompact?.(s.name), 'dd-compact') : null,
s.running || s.claude_session_id ? iconButton('Copy Claude\'s last reply', '⧉', () => this.hooks.onCopyReply?.(s.name)) : null,
iconButton('Rename', '✎', () => this.rename(s.name)),
el('a', {
class: 'dd-icon-btn', href: `?arg=${encodeURIComponent(s.name)}`, target: '_blank', rel: 'noopener',
title: 'Open in a new tab', 'aria-label': `Open ${s.name} in a new tab`,
}, '↗'),
iconButton(s.running ? 'End session' : 'Forget', '×', () => this.end(s), 'dd-danger')));
}
render() {
const sessions = this.data?.sessions ?? [];
const known = new Set(sessions.map((s) => s.name));
const recentsOnly = readRecents().filter((r) => !known.has(r.name)).slice(0, 5);
this.list.replaceChildren();
const ordered = [...sessions].sort((a, b) =>
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)));
for (const s of ordered) {
const isCurrent = s.name === this.current;
const meta = s.running
? `${s.attached} attached · ${ago(s.activity)}`
: 'dormant · resumes Claude';
this.list.append(el('li', { class: `dd-session-item${isCurrent ? ' is-current' : ''}` },
el('button', {
class: 'dd-session-open', type: 'button', title: s.dir || '',
onclick: () => this.onOpen(s.name),
},
el('span', { class: `lt-dot ${s.running ? 'lt-dot-up' : 'lt-dot-idle'}` }),
el('span', { class: 'dd-session-name' }, s.name),
el('span', { class: 'dd-session-meta' }, meta)),
el('a', {
class: 'dd-session-tab', href: `?arg=${encodeURIComponent(s.name)}`,
target: '_blank', rel: 'noopener', title: 'Open in a new tab', 'aria-label': `Open ${s.name} in a new tab`,
}, '↗')));
}
(b.running - a.running) || ((b.activity || 0) - (a.activity || 0)) || a.name.localeCompare(b.name));
const items = ordered.map((s) => this.renderSession(s));
for (const r of recentsOnly) {
this.list.append(el('li', { class: 'dd-session-item is-recent' },
el('button', { class: 'dd-session-open', type: 'button', onclick: () => this.onOpen(r.name) },
items.push(el('li', { class: 'dd-session-item is-recent' },
el('button', { class: 'dd-session-open', type: 'button', onclick: () => this.hooks.onOpen(r.name) },
el('span', { class: 'lt-dot lt-dot-idle' }),
el('span', { class: 'dd-session-name' }, r.name),
el('span', { class: 'dd-session-meta' }, 'recent'))));
el('span', { class: 'dd-session-meta' }, 'recent in this browser')),
el('span', { class: 'dd-session-actions' },
el('button', {
class: 'dd-icon-btn dd-danger', type: 'button', title: 'Remove from recents',
onclick: () => { forgetRecent(r.name); this.render(); },
}, '×'))));
}
if (!this.list.children.length) {
this.list.append(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
if (!items.length) items.push(el('li', { class: 'dd-empty' }, 'No sessions yet.'));
const scroll = this.list.scrollTop;
this.list.replaceChildren(...items);
// Keep the scroll position across polls; bring the current session into view
// when it changes.
this.list.scrollTop = scroll;
if (this.current !== this.shownCurrent) {
this.shownCurrent = this.current;
this.list.querySelector('.is-current')?.scrollIntoView({ block: 'nearest' });
}
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));
this.projectOptions(this.select);
}
}
+134
View File
@@ -0,0 +1,134 @@
// Settings (devdesk#61): the things you set once and leave, in one window
// instead of the ⋯ menu. Appearance (theme, terminal text size, interface size)
// and notifications are per browser; Remote Control is per session, with a
// default for new sessions, kept on the server.
import { el } from './dom.js';
import { openDialog, toast } from './ui.js';
import { sessionAction } from './connection.js';
const SCALES = [['Small', 0.9], ['Normal', 1], ['Large', 1.12], ['Larger', 1.25]];
// Interface size: the page is sized in rem off the root font size (14px).
export function applyUiScale(scale) {
document.documentElement.style.fontSize = scale === 1 ? '' : `${Math.round(14 * scale * 10) / 10}px`;
}
function segmented(label, options, current, onPick) {
const buttons = options.map(([text, value]) => el('button', {
type: 'button', class: 'dd-seg-btn', 'aria-pressed': String(value === current),
onclick: (event) => {
for (const b of buttons) b.setAttribute('aria-pressed', 'false');
event.currentTarget.setAttribute('aria-pressed', 'true');
onPick(value);
},
}, text));
return el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, label), el('span', { class: 'dd-seg', role: 'group', 'aria-label': label }, ...buttons));
}
function toggle(label, checked, onChange, hint) {
const input = el('input', { type: 'checkbox', role: 'switch', checked: checked ? true : null });
input.addEventListener('change', () => onChange(input.checked, input));
return el('label', { class: 'dd-setting dd-switch-row' },
el('span', { class: 'dd-setting-label' }, label, hint ? el('span', { class: 'dd-setting-hint' }, hint) : null),
el('span', { class: 'dd-switch' }, input, el('span', { class: 'dd-switch-track', 'aria-hidden': 'true' })));
}
// hooks: getTheme(), setTheme(t), fontSize(), setFontSize(n), uiScale(), setUiScale(s),
// notifications() -> { label, disabled, run }, sessions() -> list data, current(),
// refresh(), openHistory()
export async function openSettings(hooks) {
const data = hooks.sessions();
const current = hooks.current();
const size = el('span', { class: 'dd-setting-value' }, String(hooks.fontSize()));
const setSize = (n) => {
hooks.setFontSize(n);
size.textContent = String(hooks.fontSize());
};
const notif = hooks.notifications();
const notifyButton = el('button', { type: 'button', class: 'lt-btn lt-btn-sm', disabled: notif.disabled ? true : null }, notif.label);
notifyButton.addEventListener('click', async () => {
await hooks.notifications().run();
const next = hooks.notifications();
notifyButton.textContent = next.label;
notifyButton.disabled = next.disabled;
});
// Remote Control, per session.
const running = (data?.sessions ?? []).filter((s) => s.running).sort((a, b) => (a.name === current ? -1 : b.name === current ? 1 : a.name.localeCompare(b.name)));
const rcRows = running.map((s) => toggle(s.name, s.remote_control, async (on, input) => {
input.disabled = true;
try {
const result = await sessionAction('remote-control', s.name, on ? 'on' : 'off');
const what = `Remote Control ${on ? 'on' : 'off'}`;
if (result?.applied === 'now') toast.success(`${s.name}: restarting Claude with ${what} (same conversation)`, 6000);
else if (result?.applied === 'next-start') toast.info(`${s.name} is busy: ${what} when Claude next starts`, 7000);
else toast.success(`${s.name}: ${what}`);
hooks.refresh();
} catch (error) {
input.checked = !on;
toast.error(`${s.name}: ${error.message}`);
} finally {
input.disabled = false;
}
}, s.rc_active ? 'syncing to claude.ai now' : s.remote_control ? 'on at the next Claude start' : ''));
const defaultRow = toggle('New sessions', Boolean(data?.remote_control_default), async (on, input) => {
input.disabled = true;
try {
await sessionAction('settings', 'remote-control-default', on ? 'on' : 'off');
toast.success(`New sessions: Remote Control ${on ? 'on' : 'off'}`);
hooks.refresh();
} catch (error) {
input.checked = !on;
toast.error(error.message);
} finally {
input.disabled = false;
}
}, 'sessions you create from now on');
const link = (href, text) => el('a', { href, target: '_blank', rel: 'noopener' }, text);
const body = el('div', { class: 'dd-settings' },
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Appearance'),
segmented('Theme', [['Dark', 'dark'], ['Light', 'light']], hooks.getTheme(), (t) => hooks.setTheme(t)),
el('div', { class: 'dd-setting' },
el('span', { class: 'dd-setting-label' }, 'Terminal text size'),
el('span', { class: 'dd-stepper' },
el('button', { type: 'button', class: 'dd-icon-btn', 'aria-label': 'Smaller terminal text', onclick: () => setSize(hooks.fontSize() - 1) }, 'A−'),
size,
el('button', { type: 'button', class: 'dd-icon-btn', 'aria-label': 'Larger terminal text', onclick: () => setSize(hooks.fontSize() + 1) }, 'A+'),
el('button', { type: 'button', class: 'lt-btn lt-btn-ghost lt-btn-sm', onclick: () => setSize(14) }, 'Reset'))),
segmented('Interface size', SCALES, hooks.uiScale(), (s) => hooks.setUiScale(s)),
el('p', { class: 'dd-setting-note' }, 'Appearance is remembered in this browser.')),
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Notifications'),
el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, 'Desktop notifications', el('span', { class: 'dd-setting-hint' }, 'when another session needs you')), notifyButton)),
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Remote Control & privacy'),
el('p', { class: 'dd-setting-note' },
'With Remote Control on, a session also appears in the Claude app and at claude.ai/code, and Anthropic stores its transcript to keep your devices in sync (30 days, or 5 years if "Help improve Claude" is on in ',
link('https://claude.ai/settings/data-privacy-controls', 'your privacy settings'), '). Turning it off stops syncing from then on; what was already synced stays until you delete it at ',
link('https://claude.ai/code', 'claude.ai/code'), '. Switching a running session restarts its Claude on the same conversation.'),
defaultRow,
running.length ? el('div', { class: 'dd-setting-sub' }, 'Running sessions') : null,
...rcRows),
el('section', { class: 'dd-settings-group' },
el('h3', {}, 'Conversations'),
el('p', { class: 'dd-setting-note' }, 'Every conversation is also kept on this machine in ~/.claude/projects (plaintext, 30 days). Delete one for good from Search past conversations.'),
el('div', { class: 'dd-setting' }, el('span', { class: 'dd-setting-label' }, 'Find or delete a conversation'),
el('button', { type: 'button', class: 'lt-btn lt-btn-sm', 'data-close': 'history' }, 'Search past conversations…'))));
const choice = await openDialog({
title: 'Settings',
className: 'dd-settings-dialog',
body,
onOpen: () => {
body.querySelector('[data-close="history"]').addEventListener('click', () => {
body.closest('.lt-modal-overlay, .lt-modal')?.querySelector('[aria-label="Close"], .lt-modal-close')?.click();
setTimeout(() => hooks.openHistory(), 50);
});
return body.querySelector('button, input');
},
actions: [{ label: 'Done', primary: true }],
});
return choice;
}
+31
View File
@@ -0,0 +1,31 @@
// One tab polls for every open DevDesk tab (devdesk#8): whoever fetches the
// session list or the alerts broadcasts the result, and a tab that heard a fresh
// result recently skips its own poll. Each poll costs a ttyd client and a PTY.
const channel = typeof BroadcastChannel === 'function' ? new BroadcastChannel('devdesk') : null;
const heard = new Map();
const handlers = new Map();
channel?.addEventListener('message', (event) => {
const { key, data } = event.data ?? {};
const handler = handlers.get(key);
if (!handler) return;
heard.set(key, Date.now());
handler(data);
});
export function onShared(key, handler) {
handlers.set(key, handler);
}
export function share(key, data) {
try {
channel?.postMessage({ key, data });
} catch {
// Not cloneable or channel closed: the other tabs just poll themselves.
}
}
export function heardWithin(key, ms) {
return Date.now() - (heard.get(key) ?? 0) < ms;
}
+96 -8
View File
@@ -3,10 +3,30 @@ import { FitAddon } from '@xterm/addon-fit';
import { WebglAddon } from '@xterm/addon-webgl';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { ClipboardAddon } from '@xterm/addon-clipboard';
import { Base64, ClipboardAddon } from '@xterm/addon-clipboard';
// LotusGuild TDS tokens (web_template base.css :root) mapped onto the ANSI palette.
export const THEME = Object.freeze({
// Terminal output (tmux copy-mode, Claude) may *write* the browser clipboard
// via OSC 52, but must never read it: anything printed could exfiltrate it (devdesk#7).
const writeOnlyClipboard = {
readText: () => '',
writeText: (selection, text) => {
if (selection === 'c') return navigator.clipboard?.writeText(text).catch(() => {});
return undefined;
},
};
// Terminal palettes from the LotusGuild TDS tokens (devdesk#31).
//
// DevDesk runs Claude with its `dark-ansi` theme, so Claude draws with these 16
// colours instead of fixed RGB: body text is bright white, secondary text is
// white, the band behind your messages is bright black, and text on coloured
// backgrounds is black. Claude's theme is per process and printed text keeps its
// colours, so instead of switching Claude, the light palette inverts the grey
// ramp: bright white is ink, white is muted ink, bright black a light band and
// black the page. Anything written for dark terminals, Claude's scrollback
// included, stays readable, and toggling is instant.
export const THEMES = Object.freeze({
dark: Object.freeze({
background: '#010304',
foreground: '#c4d9ee',
cursor: '#FF6B00',
@@ -20,7 +40,7 @@ export const THEME = Object.freeze({
blue: '#3A8DFF',
magenta: '#BF5FFF',
cyan: '#00D4FF',
white: '#c4d9ee',
white: '#9fb6cc',
brightBlack: '#3e607a',
brightRed: '#FF5C7A',
brightGreen: '#33FFAA',
@@ -29,6 +49,31 @@ export const THEME = Object.freeze({
brightMagenta: '#D08CFF',
brightCyan: '#33DFFF',
brightWhite: '#FFFFFF',
}),
light: Object.freeze({
background: '#f4f6fa',
foreground: '#16202e',
cursor: '#c44e00',
cursorAccent: '#f4f6fa',
selectionBackground: 'rgba(0, 98, 184, 0.22)',
selectionInactiveBackground: 'rgba(0, 98, 184, 0.12)',
black: '#eef1f6',
red: '#b5001f',
green: '#006d35',
yellow: '#8a5a00',
blue: '#1f4fbf',
magenta: '#6b2fb8',
cyan: '#006b86',
white: '#556578',
brightBlack: '#c9d1dc',
brightRed: '#cc1f3d',
brightGreen: '#00803e',
brightYellow: '#9a6400',
brightBlue: '#0062b8',
brightMagenta: '#7e3fd0',
brightCyan: '#007f9e',
brightWhite: '#16202e',
}),
});
export const FONT_FAMILY = '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace';
@@ -45,7 +90,7 @@ export async function loadFonts(size) {
}
}
export function createTerminal(container, { fontSize, onShiftEnter }) {
export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter, onCopied }) {
const term = new Terminal({
fontFamily: FONT_FAMILY,
fontSize,
@@ -55,29 +100,41 @@ export function createTerminal(container, { fontSize, onShiftEnter }) {
allowProposedApi: true, // unicode11
macOptionIsMeta: true,
drawBoldTextInBrightColors: false,
theme: THEME,
theme: THEMES[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 ClipboardAddon(new Base64(), writeOnlyClipboard));
term.loadAddon(new WebLinksAddon((event, uri) => {
event.preventDefault();
window.open(uri, '_blank', 'noopener');
}));
const copySelection = () => {
const text = term.getSelection();
navigator.clipboard?.writeText(text).then(() => onCopied?.(text), () => {});
};
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+C with text selected copies it instead of interrupting (devdesk#51).
if (event.type === 'keydown' && event.ctrlKey && !event.shiftKey && !event.altKey && !event.metaKey
&& event.code === 'KeyC' && term.hasSelection()) {
copySelection();
term.clearSelection();
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(() => {});
copySelection();
return false;
}
if (event.code === 'KeyV') {
@@ -90,6 +147,37 @@ export function createTerminal(container, { fontSize, onShiftEnter }) {
term.open(container);
// devdesk#51: tmux runs with the mouse on (wheel scrolls its history), so a
// drag would select inside tmux, which copies and then drops the highlight on
// release. A left-button drag in the terminal is made a browser selection
// instead (what Shift+drag does): it stays highlighted and is copied on
// release. Clicks on tmux's status line (the window tabs) still go to tmux.
const screen = () => container.querySelector('.xterm-screen');
container.addEventListener('mousedown', (event) => {
if (event.button !== 0 || event.shiftKey || event.ddForced || term.modes.mouseTrackingMode === 'none') return;
const rect = screen()?.getBoundingClientRect();
if (!rect) return;
const row = Math.floor(((event.clientY - rect.top) / rect.height) * term.rows);
if (row >= term.rows - 1) return; // status line
event.preventDefault();
event.stopImmediatePropagation();
const forced = new MouseEvent('mousedown', {
bubbles: true, cancelable: true, view: window, detail: event.detail, button: 0, buttons: event.buttons,
clientX: event.clientX, clientY: event.clientY, screenX: event.screenX, screenY: event.screenY,
shiftKey: true, ctrlKey: event.ctrlKey, altKey: event.altKey, metaKey: event.metaKey,
});
forced.ddForced = true;
event.target.dispatchEvent(forced);
}, true);
// Copy when the drag (or double/triple click) ends with something selected.
container.addEventListener('mouseup', () => {
setTimeout(() => {
if (term.hasSelection() && term.getSelection().trim()) copySelection();
}, 0);
});
// Touch devices: the DOM renderer is more reliable at high DPR than WebGL.
if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit };
try {
const webgl = new WebglAddon();
webgl.onContextLoss(() => webgl.dispose());
+62
View File
@@ -0,0 +1,62 @@
// Dark / light theme, interchangeable with web_template's lt.theme (devdesk#31):
// same localStorage key (lt_theme), same data-theme attribute on <html>, the OS
// preference while nothing is saved, and tabs follow each other via `storage`.
// The inline script in index.template.html applies it before first paint.
const KEY = 'lt_theme';
const prefersLight = window.matchMedia('(prefers-color-scheme: light)');
const listeners = new Set();
function saved() {
try {
const value = localStorage.getItem(KEY);
return value === 'light' || value === 'dark' ? value : null;
} catch {
return null;
}
}
export function getTheme() {
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
function apply(theme) {
const root = document.documentElement;
root.setAttribute('data-theme', theme);
root.style.colorScheme = theme;
document.querySelector('meta[name="theme-color"]')?.setAttribute('content', theme === 'light' ? '#edf0f5' : '#030508');
const next = theme === 'light' ? 'dark' : 'light';
for (const button of document.querySelectorAll('.lt-theme-btn')) {
button.textContent = theme === 'light' ? '◐' : '☀';
button.setAttribute('aria-label', `Switch to ${next} mode`);
button.title = `Switch to ${next} mode`;
}
for (const listener of listeners) listener(theme);
}
export function setTheme(theme) {
try {
localStorage.setItem(KEY, theme);
} catch {
// Private mode: the choice lasts until the tab closes.
}
apply(theme);
}
export function toggleTheme() {
setTheme(getTheme() === 'light' ? 'dark' : 'light');
}
export function onThemeChange(listener) {
listeners.add(listener);
}
export function initTheme() {
apply(saved() ?? (prefersLight.matches ? 'light' : 'dark'));
prefersLight.addEventListener('change', (event) => {
if (!saved()) apply(event.matches ? 'light' : 'dark');
});
window.addEventListener('storage', (event) => {
if (event.key === KEY) apply(saved() ?? (prefersLight.matches ? 'light' : 'dark'));
});
}
+420
View File
@@ -0,0 +1,420 @@
// House UI pieces built on web_template's base.css classes, without base.js
// (its global key handler swallows Esc, which interrupts Claude, and every toast
// beeps): toasts (devdesk#34), dialogs (#33) and the command palette (#32).
import { el } from './dom.js';
// ---------------------------------------------------------------- toasts
const ICONS = { success: '✓', error: '✗', warning: '!', info: 'i' };
function toastContainer() {
let box = document.getElementById('lt-toast-container');
if (!box) {
box = el('div', { id: 'lt-toast-container', 'aria-live': 'polite' });
document.body.append(box);
}
return box;
}
function dismiss(node) {
if (!node.isConnected) return;
clearTimeout(node.dismissTimer);
node.classList.add('is-leaving');
setTimeout(() => node.remove(), 250);
}
// `actions`: [{ label, run }] buttons (e.g. Undo); clicking one dismisses the toast.
export function toast(message, type = 'info', duration = 4000, actions = []) {
const box = toastContainer();
// At most four on screen: the oldest goes first.
while (box.children.length >= 4) box.firstElementChild.remove();
const node = el('div', { class: `lt-toast lt-toast-${type}`, role: type === 'error' ? 'alert' : 'status' },
el('span', { class: 'lt-toast-icon', 'aria-hidden': 'true' }, `[${ICONS[type] || 'i'}]`),
el('span', { class: 'lt-toast-msg' }, message),
...actions.map(({ label, run }) => el('button', {
type: 'button', class: 'lt-toast-action',
onclick: () => {
dismiss(node);
run();
},
}, label)),
el('button', { type: 'button', class: 'lt-toast-close', 'aria-label': 'Dismiss', onclick: () => dismiss(node) }, '✕'),
el('div', { class: 'lt-toast-progress', style: `animation-duration:${duration}ms` }));
box.append(node);
node.dismissTimer = setTimeout(() => dismiss(node), duration);
node.addEventListener('mouseenter', () => clearTimeout(node.dismissTimer));
node.addEventListener('mouseleave', () => { node.dismissTimer = setTimeout(() => dismiss(node), 1500); });
}
toast.success = (message, duration, actions) => toast(message, 'success', duration, actions);
toast.error = (message, duration = 8000, actions) => toast(message, 'error', duration, actions);
toast.warning = (message, duration, actions) => toast(message, 'warning', duration, actions);
toast.info = (message, duration, actions) => toast(message, 'info', duration, actions);
// ---------------------------------------------------------------- context menu
// A right-click menu at (x, y) (devdesk#54). items: { label, kbd, run, disabled,
// danger } or 'sep'. Arrow keys move, Enter runs, Esc / a click elsewhere /
// scrolling / resizing closes it. Returns the close function.
let closeOpenMenu = null;
export function contextMenu(x, y, items) {
closeOpenMenu?.();
const opener = document.activeElement;
const buttons = [];
const menu = el('div', { class: 'dd-menu dd-ctx-menu', role: 'menu' }, ...items.filter(Boolean).map((item) => {
if (item === 'sep') return el('div', { class: 'dd-menu-sep', role: 'separator' });
const button = el('button', {
type: 'button', role: 'menuitem', class: `dd-menu-item${item.danger ? ' is-danger' : ''}`, disabled: item.disabled ? true : null,
onclick: () => {
close(false);
item.run();
},
}, el('span', { class: 'dd-ctx-label' }, item.label), item.kbd ? el('kbd', { class: 'dd-ctx-kbd' }, item.kbd) : null);
if (!item.disabled) buttons.push(button);
return button;
}));
const close = (refocus = true) => {
if (!menu.isConnected) return;
menu.remove();
closeOpenMenu = null;
document.removeEventListener('pointerdown', outside, true);
window.removeEventListener('blur', onBlur);
window.removeEventListener('resize', onBlur);
document.removeEventListener('scroll', onBlur, true);
if (refocus && opener?.isConnected) opener.focus?.();
};
const outside = (event) => {
if (!menu.contains(event.target)) close(false);
};
const opened = Date.now();
const onBlur = (event) => {
// Scrolling closes it, but not the layout settling right after it opened.
if (event?.type === 'scroll' && (menu.contains(event.target) || Date.now() - opened < 400)) return;
close(false);
};
menu.addEventListener('keydown', (event) => {
const i = buttons.indexOf(document.activeElement);
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
const next = buttons[(i + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length];
next?.focus();
} else if (event.key === 'Home' || event.key === 'End') {
event.preventDefault();
buttons[event.key === 'Home' ? 0 : buttons.length - 1]?.focus();
} else if (event.key === 'Escape' || event.key === 'Tab') {
event.preventDefault();
event.stopPropagation();
close();
}
});
menu.addEventListener('contextmenu', (event) => event.preventDefault());
document.body.append(menu);
// Keep it on screen.
const { width, height } = menu.getBoundingClientRect();
menu.style.left = `${Math.max(4, Math.min(x, window.innerWidth - width - 4))}px`;
menu.style.top = `${Math.max(4, Math.min(y, window.innerHeight - height - 4))}px`;
buttons[0]?.focus();
setTimeout(() => {
document.addEventListener('pointerdown', outside, true);
window.addEventListener('blur', onBlur);
window.addEventListener('resize', onBlur);
document.addEventListener('scroll', onBlur, true);
}, 0);
closeOpenMenu = close;
return close;
}
// ---------------------------------------------------------------- dialogs
let openDialogs = 0;
export const dialogOpen = () => openDialogs > 0 || !!document.querySelector('.lt-cmd-overlay.is-open');
// A .lt-modal dialog. `body` is a node; `actions` are { label, value, primary,
// danger }. Resolves with the chosen action's value (or `submit()`'s value when
// a form is submitted), or null when dismissed (✕, Esc, backdrop).
export function openDialog({ title, body, actions = [], danger = false, size = '', className = '', onOpen, submit }) {
return new Promise((resolve) => {
const opener = document.activeElement;
let done = false;
const close = (value) => {
if (done) return;
done = true;
openDialogs -= 1;
overlay.remove();
if (opener?.isConnected) opener.focus();
resolve(value);
};
const titleId = `dd-dialog-${Date.now()}`;
const form = el('form', { class: 'lt-modal-body dd-dialog-body', novalidate: '' }, body);
form.addEventListener('submit', (event) => {
event.preventDefault();
const value = submit ? submit() : true;
if (value !== undefined && value !== null && value !== false) close(value);
});
const buttons = actions.map((action) => el('button', {
type: action.primary ? 'submit' : 'button',
class: `lt-btn lt-btn-sm${action.primary ? (action.danger ? ' lt-btn-danger' : ' lt-btn-primary') : ' lt-btn-ghost'}`,
onclick: action.primary ? null : () => close(action.value ?? null),
}, action.label));
// The primary button submits the form, so Enter in a field does the same.
for (const button of buttons) {
if (button.type === 'submit') button.setAttribute('form', titleId);
}
form.id = titleId;
const overlay = el('div', { class: `lt-modal-overlay is-open dd-dialog${className ? ` ${className}` : ''}`, role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': `${titleId}-title` },
el('div', { class: `lt-modal${size ? ` lt-modal-${size}` : ''}` },
el('div', { class: `lt-modal-header${danger ? ' lt-modal-header--danger' : ''}` },
el('h2', { class: 'lt-modal-title', id: `${titleId}-title` }, title),
el('button', { type: 'button', class: 'lt-modal-close', 'aria-label': 'Close', onclick: () => close(null) }, '✕')),
form,
buttons.length ? el('div', { class: 'lt-modal-footer' }, ...buttons) : null));
overlay.addEventListener('mousedown', (event) => {
if (event.target === overlay) close(null);
});
overlay.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
close(null);
return;
}
if (event.key === 'Tab') trapFocus(overlay, event);
});
openDialogs += 1;
document.body.append(overlay);
(onOpen?.(overlay) ?? overlay.querySelector('input, select, textarea, .lt-btn-primary, .lt-btn-danger, .lt-modal-close'))?.focus?.();
});
}
function trapFocus(root, event) {
const items = [...root.querySelectorAll('button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])')]
.filter((node) => !node.disabled && node.offsetParent !== null);
if (!items.length) return;
const first = items[0];
const last = items[items.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
export function confirmDialog({ title, text, confirmLabel = 'OK', danger = false }) {
return openDialog({
title,
danger,
size: 'sm',
body: el('p', { class: 'dd-dialog-text' }, text),
actions: [{ label: 'Cancel', value: false }, { label: confirmLabel, primary: true, danger }],
}).then(Boolean);
}
// ---------------------------------------------------------------- command palette
const RECENT_KEY = 'lt_cmd_recent';
function readRecent() {
try {
const list = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]');
return Array.isArray(list) ? list : [];
} catch {
return [];
}
}
function pushRecent(id) {
try {
localStorage.setItem(RECENT_KEY, JSON.stringify([id, ...readRecent().filter((x) => x !== id)].slice(0, 5)));
} catch {
// Private mode: no recents.
}
}
// Commands: { id, label, group, icon?, hint?, kbd?, keywords?, run }. `getCommands`
// (may be async) is called on every open, so the list reflects the current state.
// Options: id (DOM prefix), placeholder, search(commands, query) -> ranked list
// (replaces the default word filter, recents and grouping; used by Go to file).
export class CommandPalette {
constructor(getCommands, options = {}) {
this.options = { id: 'lt-cmd', placeholder: 'Type a command or a session name…', ...options };
const id = this.options.id;
this.getCommands = getCommands;
this.commands = [];
this.visible = [];
this.selected = 0;
this.opener = null;
this.input = el('input', {
class: 'lt-cmd-input', type: 'text', placeholder: this.options.placeholder,
autocomplete: 'off', spellcheck: 'false', 'aria-label': this.options.placeholder, role: 'combobox',
'aria-controls': `${id}-results`, 'aria-expanded': 'true',
});
this.results = el('div', { class: 'lt-cmd-results', id: `${id}-results`, role: 'listbox' });
this.overlay = el('div', { class: 'lt-cmd-overlay', id: `${id}-overlay`, role: 'dialog', 'aria-modal': 'true', 'aria-label': this.options.placeholder },
el('div', { class: 'lt-cmd-palette', id: `${id}-palette` },
el('div', { class: 'lt-cmd-input-wrap' }, el('span', { class: 'lt-cmd-prompt', 'aria-hidden': 'true' }, '>'), this.input),
this.results,
el('div', { class: 'lt-cmd-footer' },
el('span', {}, el('kbd', {}, '↑'), el('kbd', {}, '↓'), ' move'),
el('span', {}, el('kbd', {}, '⏎'), ' run'),
el('span', {}, el('kbd', {}, 'Esc'), ' close'))));
document.body.append(this.overlay);
this.input.addEventListener('input', () => this.render());
this.input.addEventListener('keydown', (event) => this.onKey(event));
this.overlay.addEventListener('mousedown', (event) => {
if (event.target === this.overlay) this.close();
});
}
get isOpen() {
return this.overlay.classList.contains('is-open');
}
async open(query = '') {
this.opener = document.activeElement;
this.overlay.classList.add('is-open');
this.input.value = query;
this.input.focus();
const pending = this.getCommands();
if (pending instanceof Promise) {
this.commands = [];
this.loading = true;
this.render();
try {
this.commands = await pending;
} catch (error) {
this.commands = [];
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, error.message));
return;
} finally {
this.loading = false;
}
} else {
this.commands = pending;
}
if (this.isOpen) this.render();
}
close({ restoreFocus = true } = {}) {
if (!this.isOpen) return;
this.overlay.classList.remove('is-open');
if (restoreFocus && this.opener?.isConnected) this.opener.focus();
}
onKey(event) {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
this.move(event.key === 'ArrowDown' ? 1 : -1);
} else if (event.key === 'Enter') {
event.preventDefault();
this.run(this.visible[this.selected]);
} else if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
this.close();
} else if (event.key === 'Tab') {
event.preventDefault();
}
}
move(step) {
if (!this.visible.length) return;
this.selected = (this.selected + step + this.visible.length) % this.visible.length;
this.highlight();
}
highlight() {
const items = this.results.querySelectorAll('.lt-cmd-item');
items.forEach((item, i) => {
item.classList.toggle('is-selected', i === this.selected);
item.setAttribute('aria-selected', String(i === this.selected));
});
const current = items[this.selected];
if (current) {
current.scrollIntoView({ block: 'nearest' });
this.input.setAttribute('aria-activedescendant', current.id);
}
}
run(command) {
if (!command) return;
pushRecent(command.id);
// The command decides where focus goes (usually the terminal).
this.close({ restoreFocus: false });
command.run();
}
render() {
if (this.loading) {
this.visible = [];
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, 'Loading…'));
return;
}
if (this.options.search) {
this.renderList(this.options.search(this.commands, this.input.value.trim()));
return;
}
const words = this.input.value.trim().toLowerCase().split(/\s+/).filter(Boolean);
const matches = this.commands.filter((c) => {
const text = `${c.label} ${c.group} ${c.keywords ?? ''} ${c.hint ?? ''}`.toLowerCase();
return words.every((w) => text.includes(w));
});
const groups = new Map();
if (!words.length) {
const recent = readRecent().map((id) => matches.find((c) => c.id === id)).filter(Boolean).slice(0, 3);
if (recent.length) groups.set('Recent', recent);
}
for (const command of matches) {
if (!groups.has(command.group)) groups.set(command.group, []);
groups.get(command.group).push(command);
}
this.visible = [...groups.values()].flat();
this.selected = 0;
if (!this.visible.length) {
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, `No command matches “${this.input.value.trim()}”`));
return;
}
this.renderGroups(groups);
}
// A ranked list without group headers (custom search).
renderList(list) {
this.visible = list;
this.selected = 0;
if (!list.length) {
this.results.replaceChildren(el('div', { class: 'lt-cmd-empty' }, this.input.value.trim() ? `Nothing matches “${this.input.value.trim()}”` : 'Nothing here'));
return;
}
this.renderGroups(new Map([['', list]]));
}
renderGroups(groups) {
let index = 0;
const nodes = [];
for (const [group, commands] of groups) {
if (group) nodes.push(el('div', { class: 'lt-cmd-group-label', role: 'presentation' }, group));
for (const command of commands) {
const i = index;
nodes.push(el('div', {
class: 'lt-cmd-item', id: `${this.options.id}-item-${i}`, role: 'option',
onclick: () => this.run(command),
onmousemove: () => {
if (this.selected !== i) {
this.selected = i;
this.highlight();
}
},
},
el('span', { class: 'lt-cmd-item-icon', 'aria-hidden': 'true' }, command.icon || '◦'),
el('span', { class: 'lt-cmd-item-label' }, command.label),
command.hint ? el('span', { class: 'dd-cmd-hint' }, command.hint) : null,
command.kbd ? el('span', { class: 'lt-cmd-item-kbd' }, command.kbd) : null));
index += 1;
}
}
this.results.replaceChildren(...nodes);
this.highlight();
}
}
+3 -4
View File
@@ -1,7 +1,6 @@
// Sidebar widgets. In v1 only Sessions is live; Repo/CI and Claude Context get
// data from devdesk-api in v1.1, Ops Alerts in v2. The "Ask Claude" pattern is
// already wired: any [data-ask-claude] button types its prompt into the current
// session without pressing Enter, so you can review it before sending.
// "Ask Claude": any [data-ask-claude] button (Repo/CI) types its prompt into the
// current session without pressing Enter, so you can review it before sending.
// (Ops Alerts start their own session instead: devdesk#39.)
export function bindAskClaude(root, send) {
root.addEventListener('click', (event) => {
+892 -48
View File
File diff suppressed because it is too large Load Diff