Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20134c1e58 | ||
|
|
65114165e7 | ||
|
|
e60f1ad066 | ||
|
|
ab7e127f70 | ||
|
|
24d71131e8 | ||
|
|
3a0b099f02 | ||
|
|
a2edec966a | ||
|
|
bf8821f3f9 | ||
|
|
fe4df10210 | ||
|
|
963d2f5413 | ||
|
|
c0f475bffe | ||
|
|
afdbf6eaec | ||
|
|
74927a7640 | ||
|
|
73e77aca73 | ||
|
|
f82c13612d | ||
|
|
a92d10ace3 | ||
|
|
66321f4748 | ||
|
|
927920c162 |
@@ -157,6 +157,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
# CT 126's webhook listener runs /usr/lib/devdesk/devdesk-deploy, which
|
# CT 126's webhook listener runs /usr/lib/devdesk/devdesk-deploy, which
|
||||||
# waits for this version in the apt registry and upgrades the package.
|
# 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
|
- name: Trigger devdesk-deploy webhook
|
||||||
env:
|
env:
|
||||||
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
|
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
|
||||||
@@ -164,12 +166,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
PAYLOAD="{\"ref\":\"${GIT_REF}\"}"
|
PAYLOAD="{\"ref\":\"${GIT_REF}\"}"
|
||||||
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
|
SIG=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | awk '{print $2}')
|
||||||
curl -sf --connect-timeout 10 \
|
code=$(curl -sS --connect-timeout 10 --max-time 900 -o /tmp/deploy.out -w '%{http_code}' \
|
||||||
-X POST \
|
-X POST \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
-H "X-Gitea-Signature: ${SIG}" \
|
-H "X-Gitea-Signature: ${SIG}" \
|
||||||
-d "$PAYLOAD" \
|
-d "$PAYLOAD" \
|
||||||
"http://10.10.10.58:9000/hooks/devdesk-deploy"
|
"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:
|
notify-failure:
|
||||||
name: Notify on failure
|
name: Notify on failure
|
||||||
|
|||||||
@@ -40,8 +40,9 @@ install: all
|
|||||||
install -m 0644 libexec/devdesk-lib.sh $(DESTDIR)$(LIBDIR)/devdesk-lib.sh
|
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 \
|
install -m 0755 libexec/devdesk-picker libexec/devdesk-claude-run libexec/devdesk-session-hook \
|
||||||
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
|
libexec/devdesk-tmux-ready libexec/devdesk-serve libexec/devdesk-statusline \
|
||||||
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix $(DESTDIR)$(LIBDIR)/
|
libexec/devdesk-deploy libexec/devdesk-state-hook libexec/devdesk-notify-matrix libexec/devdesk-guard $(DESTDIR)$(LIBDIR)/
|
||||||
install -m 0644 libexec/devdesk-files.sh $(DESTDIR)$(LIBDIR)/devdesk-files.sh
|
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 0755 build/ttyd $(DESTDIR)$(LIBDIR)/bin/ttyd
|
||||||
install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
|
install -m 0644 dist/index.html share/tmux.conf share/claude-settings.json $(DESTDIR)$(SHAREDIR)/
|
||||||
install -d $(DESTDIR)$(SHAREDIR)/assets
|
install -d $(DESTDIR)$(SHAREDIR)/assets
|
||||||
|
|||||||
@@ -31,26 +31,47 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
|||||||
- 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.
|
- 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.
|
- **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.
|
- **+ 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.
|
- **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).
|
- **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.
|
- **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.
|
||||||
- **Session status:** Claude hooks (`devdesk-state-hook`) mark each session **working**, **waiting on you** (with Claude's message) or **idle**.
|
- **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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
- **Matrix "Claude needs you":** when a session has waited for `DEVDESK_MATRIX_DELAY` seconds (30) and no browser tab or SSH client is attached to it, `devdesk-notify-matrix` posts one message per wait, with a link, to the hookshot webhook URL in `/etc/devdesk/matrix-webhook` (mode 600; no file = off).
|
- **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.
|
- **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.
|
- 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.
|
- For images, Claude attaches them itself.
|
||||||
- **Ops Alerts panel:** firing and pending Grafana alert rules, each with a link to the rule.
|
- **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** types an investigation prompt into the current session without sending it.
|
- **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.
|
||||||
- Configure `DEVDESK_GRAFANA_URL`, plus a Viewer token in `DEVDESK_GRAFANA_TOKEN_FILE`.
|
- 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.
|
||||||
- **Repo / CI panel:** branch, ahead/behind, uncommitted changes, HEAD's Gitea CI status and open PRs for the session's folder. When CI fails, **Ask Claude to fix CI** types a prompt to read the failed job's log.
|
- **`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.
|
||||||
- **Files** (Ctrl+Shift+E), laid out like VS Code: an explorer tree (git colours, gitignored entries dimmed) and an editor with tabs (a click opens a preview tab, a double-click keeps it), **Go to file** (Ctrl+P in the pane), markdown previews (sanitized, with relative images), images, and a Changes view of uncommitted files with line counts, a **C** badge on files Claude edited, and diffs against HEAD. **@Claude** types `@path` into the prompt. Tabs and open folders are remembered per session. Read-only for now. Paths can't leave the session folder. The editor bundle (`/usr/share/devdesk/assets/editor.js`) loads over ttyd the first time and is cached by the browser.
|
- 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.
|
||||||
- **Context:** a Claude `statusLine` command (`devdesk-statusline`) snapshots each session's model, context-window use and cost to `/var/lib/devdesk/context/<session>.json`. The Sessions panel shows it as a bar (cost in the tooltip), and Claude's own footer shows a one-line summary.
|
- **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).
|
- **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.
|
- **Phone:** a key bar replaces the shortcut hints (Esc, Tab, ⇧Tab, ^C, ↑, ↓, ⏎, Claude/Shell window), and the panels open as a drawer.
|
||||||
- **SSH:** `devdesk attach <name>`, `devdesk new <name> [project]`, `devdesk rename <old> <new>`, `devdesk end <name>`, `devdesk ls`, `devdesk pick`, `devdesk repo <name>`, `devdesk alerts`.
|
- **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
|
## Keys
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
@@ -60,7 +81,11 @@ 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 0 / 1 | Claude window / shell window |
|
||||||
| Ctrl-b s | Switch session (the URL follows) |
|
| Ctrl-b s | Switch session (the URL follows) |
|
||||||
| Ctrl-b d | Detach (the session keeps running) |
|
| 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+C / V | Copy selection / paste |
|
||||||
| Ctrl+Shift+P | Command palette (also the ⌕ button) |
|
| Ctrl+Shift+P | Command palette (also the ⌕ button) |
|
||||||
| Ctrl+Shift+E | Files pane (Esc: back, then close) |
|
| Ctrl+Shift+E | Files pane (Esc: back, then close) |
|
||||||
@@ -68,13 +93,13 @@ The tmux server runs in its own unit, so every Claude process lives in **that**
|
|||||||
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.
|
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
|
## Configuration
|
||||||
- **Put your changes in `/etc/devdesk/env.local`.** It's read after the packaged `env` and never shipped, so `apt upgrade` never asks about it. Example: `DEVDESK_REMOTE_CONTROL=1`.
|
- **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:
|
- `/etc/devdesk/env` holds the packaged defaults; don't edit it. Its keys:
|
||||||
- port, bind address, projects root and `DEVDESK_DEFAULT_DIR`
|
- 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`
|
- `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`
|
- `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` and `DEVDESK_MATRIX_DELAY` for the Matrix notifications
|
- `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/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`.
|
- `/etc/devdesk/tmux.local.conf` is optional and is sourced after the packaged `tmux.conf`.
|
||||||
|
|
||||||
|
|||||||
+217
-53
@@ -2,16 +2,20 @@
|
|||||||
# devdesk — persistent Claude Code sessions in tmux, shared by the browser UI and SSH.
|
# devdesk — persistent Claude Code sessions in tmux, shared by the browser UI and SSH.
|
||||||
#
|
#
|
||||||
# devdesk attach [NAME] attach to session NAME, creating it if needed; no NAME opens the picker
|
# devdesk attach [NAME] attach to session NAME, creating it if needed; no NAME opens the picker
|
||||||
# devdesk new NAME [PROJECT] create session NAME (detached) in PROJECT's folder, or the default folder
|
# devdesk 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 rename OLD NEW rename a session (running or dormant)
|
||||||
# devdesk end NAME stop session NAME and forget it
|
# devdesk end NAME stop session NAME and forget it
|
||||||
# devdesk repo NAME git + Gitea CI status of the session's folder (JSON)
|
# devdesk repo NAME git + Gitea CI status of the session's folder (JSON)
|
||||||
# devdesk alerts firing/pending Grafana alerts (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 ls list sessions
|
||||||
# devdesk pick interactive picker
|
# devdesk pick interactive picker
|
||||||
#
|
#
|
||||||
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --upload SESSION FILE BYTES |
|
# The web UI calls `devdesk attach --list | --repo NAME | --alerts | --investigate SESSION UID |
|
||||||
# --new NAME [PROJECT] | --rename OLD NEW | --end NAME | --existing NAME`
|
# --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.
|
# through ttyd; those print JSON.
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
@@ -19,9 +23,17 @@ set -euo pipefail
|
|||||||
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
|
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh"
|
||||||
# shellcheck source=libexec/devdesk-files.sh
|
# shellcheck source=libexec/devdesk-files.sh
|
||||||
. "$DEVDESK_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() {
|
usage() {
|
||||||
sed -n '2,12s/^# \{0,1\}//p' "$0"
|
sed -n '2,13s/^# \{0,1\}//p' "$0"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Tab-separated running sessions: name, dir, attached clients, last activity, windows.
|
# Tab-separated running sessions: name, dir, attached clients, last activity, windows.
|
||||||
@@ -42,7 +54,7 @@ projects_tsv() {
|
|||||||
# Only offer directories that attach would accept (no symlinks out of the root).
|
# Only offer directories that attach would accept (no symlinks out of the root).
|
||||||
real=$(realpath -e -- "$d" 2>/dev/null) || continue
|
real=$(realpath -e -- "$d" 2>/dev/null) || continue
|
||||||
case "$real/" in "$root"/*) ;; *) continue ;; esac
|
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
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +79,9 @@ cmd_list() {
|
|||||||
--arg proj "$(projects_tsv)" \
|
--arg proj "$(projects_tsv)" \
|
||||||
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
|
--argjson ctx "$(per_session_json "$DEVDESK_CONTEXT_DIR")" \
|
||||||
--argjson st "$(per_session_json "$DEVDESK_STATUS_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 dflt "$DEVDESK_DEFAULT_SESSION" \
|
||||||
--arg root "$DEVDESK_PROJECTS_ROOT" '
|
--arg root "$DEVDESK_PROJECTS_ROOT" '
|
||||||
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
|
def rows($s): $s | split("\n") | map(select(length > 0) | split("\t"));
|
||||||
@@ -77,15 +92,20 @@ cmd_list() {
|
|||||||
| ($live | map(.name)) as $liveNames
|
| ($live | map(.name)) as $liveNames
|
||||||
| {default: $dflt, root: $root,
|
| {default: $dflt, root: $root,
|
||||||
sessions: (
|
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
|
+ ($known | to_entries
|
||||||
| map(select(.key as $k | $liveNames | index($k) | not))
|
| map(select(.key as $k | $liveNames | index($k) | not))
|
||||||
| map({name: .key, dir: .value.dir, running: false, attached: 0,
|
| map({name: .key, dir: .value.dir, running: false, attached: 0,
|
||||||
activity: null, windows: 0,
|
activity: null, windows: 0,
|
||||||
claude_session_id: (.value.claude_session_id // null)}))
|
claude_session_id: (.value.claude_session_id // null),
|
||||||
) | map(. + {context: ($ctx[.name] // null), status: ($st[.name] // 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}]
|
projects: ([{name: $dflt, dir: $root}]
|
||||||
+ [rows($proj)[] | {name: .[0], dir: (.[1:] | join("\t"))}
|
+ [rows($proj)[] | {name: .[0], git: (.[1] == "1"), dir: (.[2:] | join("\t"))}
|
||||||
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
|
| select(.name | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))])}'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +133,12 @@ create_detached() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cmd_new() {
|
cmd_new() {
|
||||||
{ [ $# -ge 1 ] && [ $# -le 2 ]; } || dd_die "usage: devdesk new NAME [PROJECT]"
|
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_valid_name "$1" || dd_die "invalid session name: $1"
|
||||||
local dir
|
local dir
|
||||||
if [ $# -eq 2 ]; then
|
if [ $# -eq 2 ]; then
|
||||||
@@ -121,12 +146,140 @@ cmd_new() {
|
|||||||
dir=$(dd_project_dir "$2") || dd_die "no project '$2' under $DEVDESK_PROJECTS_ROOT"
|
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"
|
! session_exists "$1" || dd_die "a session named '$1' already exists"
|
||||||
else
|
else
|
||||||
|
[ "$worktree" = 0 ] || dd_die "--worktree needs a project"
|
||||||
dir=$(dd_resolve_dir "$1")
|
dir=$(dd_resolve_dir "$1")
|
||||||
fi
|
fi
|
||||||
|
[ "$worktree" = 0 ] || dir=$(dd_worktree_add "$1" "$2" "$dir")
|
||||||
dd_ensure_server
|
dd_ensure_server
|
||||||
create_detached "$1" "$dir"
|
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() {
|
cmd_rename() {
|
||||||
[ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW"
|
[ $# -eq 2 ] || dd_die "usage: devdesk rename OLD NEW"
|
||||||
local old=$1 new=$2 running=0
|
local old=$1 new=$2 running=0
|
||||||
@@ -136,18 +289,30 @@ cmd_rename() {
|
|||||||
if dd_server_up && dd_tmux has-session -t "=$old" 2>/dev/null; then running=1; fi
|
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'"
|
[ "$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"
|
! session_exists "$new" || dd_die "a session named '$new' already exists"
|
||||||
if [ "$running" = 1 ]; then
|
dd_registry_locked rename_locked "$old" "$new" "$running" || dd_die "could not rename '$old'"
|
||||||
dd_tmux rename-session -t "=$old" "$new"
|
}
|
||||||
dd_tmux set-environment -t "=$new" DEVDESK_SESSION "$new"
|
|
||||||
|
# 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
|
fi
|
||||||
dd_registry_rename "$old" "$new"
|
dd_registry_rename "$1" "$2"
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd_end() {
|
cmd_end() {
|
||||||
[ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
|
[ $# -eq 1 ] || dd_die "usage: devdesk end NAME"
|
||||||
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
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.
|
# Forget first: killing the session SIGHUPs us when run from inside it.
|
||||||
dd_registry_remove "$1"
|
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"
|
rm -rf "${DEVDESK_STATE_DIR:?}/uploads/$1"
|
||||||
if dd_server_up; then
|
if dd_server_up; then
|
||||||
dd_tmux kill-session -t "=$1" 2>/dev/null || true
|
dd_tmux kill-session -t "=$1" 2>/dev/null || true
|
||||||
@@ -182,7 +347,7 @@ gitea_slug() {
|
|||||||
cmd_repo() {
|
cmd_repo() {
|
||||||
[ $# -eq 1 ] || dd_die "usage: devdesk repo NAME"
|
[ $# -eq 1 ] || dd_die "usage: devdesk repo NAME"
|
||||||
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
dd_valid_name "$1" || dd_die "invalid session name: $1"
|
||||||
local dir g branch sha="" dirty ahead=null behind=null upstream=false remote slug="" ci=null prs=null
|
local dir g branch sha="" dirty changes ahead=null behind=null upstream=false remote slug="" ci=null prs=null
|
||||||
dir=$(dd_resolve_dir "$1")
|
dir=$(dd_resolve_dir "$1")
|
||||||
# --no-optional-locks: never take .git/index.lock (Claude's own git would collide).
|
# --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.
|
# No safe.directory override: repos owned by other users stay untrusted.
|
||||||
@@ -194,6 +359,9 @@ cmd_repo() {
|
|||||||
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
|
branch=$("${g[@]}" symbolic-ref --short -q HEAD || echo "(detached)")
|
||||||
sha=$("${g[@]}" rev-parse -q --verify HEAD 2>/dev/null || true)
|
sha=$("${g[@]}" rev-parse -q --verify HEAD 2>/dev/null || true)
|
||||||
dirty=$("${g[@]}" status --porcelain 2>/dev/null | wc -l)
|
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
|
if "${g[@]}" rev-parse -q --verify '@{u}' >/dev/null 2>&1; then
|
||||||
upstream=true
|
upstream=true
|
||||||
read -r behind ahead < <("${g[@]}" rev-list --left-right --count '@{u}...HEAD')
|
read -r behind ahead < <("${g[@]}" rev-list --left-right --count '@{u}...HEAD')
|
||||||
@@ -208,10 +376,10 @@ cmd_repo() {
|
|||||||
prs=$(gitea_get "/repos/$slug/pulls?state=open&limit=50" | jq -M 'length' 2>/dev/null) || prs=null
|
prs=$(gitea_get "/repos/$slug/pulls?state=open&limit=50" | jq -M 'length' 2>/dev/null) || prs=null
|
||||||
fi
|
fi
|
||||||
jq -cMn --arg s "$1" --arg dir "$dir" --arg branch "$branch" --arg sha "${sha:0:7}" \
|
jq -cMn --arg s "$1" --arg dir "$dir" --arg branch "$branch" --arg sha "${sha:0:7}" \
|
||||||
--argjson dirty "$dirty" --argjson ahead "$ahead" --argjson behind "$behind" \
|
--argjson dirty "$dirty" --argjson changes "${changes:-[]}" --argjson ahead "$ahead" --argjson behind "$behind" \
|
||||||
--argjson upstream "$upstream" --arg remote "$remote" --arg slug "$slug" \
|
--argjson upstream "$upstream" --arg remote "$remote" --arg slug "$slug" \
|
||||||
--arg web "$DEVDESK_GITEA_URL" --argjson ci "${ci:-null}" --argjson prs "${prs:-null}" '
|
--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,
|
{session: $s, dir: $dir, git: true, branch: $branch, sha: $sha, dirty: $dirty, changes: $changes,
|
||||||
ahead: $ahead, behind: $behind, upstream: $upstream, remote: $remote,
|
ahead: $ahead, behind: $behind, upstream: $upstream, remote: $remote,
|
||||||
repo: (if $slug == "" then null else $slug end),
|
repo: (if $slug == "" then null else $slug end),
|
||||||
url: (if $slug == "" then null else "\($web)/\($slug)" end),
|
url: (if $slug == "" then null else "\($web)/\($slug)" end),
|
||||||
@@ -219,41 +387,6 @@ cmd_repo() {
|
|||||||
prs: $prs}'
|
prs: $prs}'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Firing and pending Grafana alert rules, most severe first.
|
|
||||||
cmd_alerts() {
|
|
||||||
if [ -z "$DEVDESK_GRAFANA_URL" ]; then
|
|
||||||
jq -cMn '{configured: false, alerts: []}'
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
local raw
|
|
||||||
if ! raw=$({
|
|
||||||
if [ -r "$DEVDESK_GRAFANA_TOKEN_FILE" ]; then
|
|
||||||
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the token
|
|
||||||
printf 'header = "Authorization: Bearer %s"\n' "$(tr -d '[:space:]"\\' <"$DEVDESK_GRAFANA_TOKEN_FILE")"
|
|
||||||
fi
|
|
||||||
} | curl -fsS --max-time 6 -K - "$DEVDESK_GRAFANA_URL/api/prometheus/grafana/api/v1/rules" 2>&1); then
|
|
||||||
jq -cMn --arg e "${raw:-no response}" '{configured: true, error: $e, alerts: []}'
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
jq -cM --arg pub "$DEVDESK_GRAFANA_PUBLIC_URL" '
|
|
||||||
def num: (tonumber? // .) | tostring;
|
|
||||||
{configured: true, checked: (now | floor), alerts: [
|
|
||||||
.data.groups[] | .name as $group | .file as $folder | .rules[]
|
|
||||||
| select(.state == "firing" or .state == "pending")
|
|
||||||
| . as $r
|
|
||||||
| ([.alerts[]? | select(.state == "Alerting" or .state == "Pending")]) as $active
|
|
||||||
| {name, state, severity: (.labels.severity // null), group: $group, folder: $folder,
|
|
||||||
since: (.activeAt // $active[0].activeAt // null),
|
|
||||||
summary: ((.annotations.summary // .annotations.description // "")
|
|
||||||
| gsub("\\{\\{ *\\$value *\\}\\}"; ($active[0].value // "?" | num))),
|
|
||||||
instances: [$active[] | .labels
|
|
||||||
| del(.__alert_rule_uid__, .__grafana_origin__, .alertname, .grafana_folder, .__name__, .severity)
|
|
||||||
| to_entries | map("\(.key)=\(.value)") | join(" ")
|
|
||||||
| select(. != "")],
|
|
||||||
url: "\($pub)/alerting/grafana/\(.uid)/view"}
|
|
||||||
] | sort_by(if .severity == "critical" then 0 elif .severity == "warning" then 1 else 2 end)}' <<<"$raw"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Receive a file for SESSION over the terminal: the browser waits for the ready
|
# 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.
|
# line, then sends exactly ceil(BYTES/3)*4 bytes of base64 as input.
|
||||||
cmd_upload() {
|
cmd_upload() {
|
||||||
@@ -312,19 +445,49 @@ cmd_attach() {
|
|||||||
dd_linger
|
dd_linger
|
||||||
return
|
return
|
||||||
;;
|
;;
|
||||||
|
--investigate)
|
||||||
|
shift
|
||||||
|
cmd_investigate "$@"
|
||||||
|
dd_linger
|
||||||
|
return
|
||||||
|
;;
|
||||||
--upload)
|
--upload)
|
||||||
shift
|
shift
|
||||||
cmd_upload "$@"
|
cmd_upload "$@"
|
||||||
dd_linger
|
dd_linger
|
||||||
return
|
return
|
||||||
;;
|
;;
|
||||||
--fs-list | --fs-find | --fs-read | --git-status | --git-show | --asset)
|
--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#--}
|
local action=${1#--}
|
||||||
shift
|
shift
|
||||||
"cmd_${action//-/_}" "$@"
|
"cmd_${action//-/_}" "$@"
|
||||||
dd_linger
|
dd_linger
|
||||||
return
|
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)
|
--new | --rename | --end)
|
||||||
local action=${1#--}
|
local action=${1#--}
|
||||||
shift
|
shift
|
||||||
@@ -374,6 +537,7 @@ new) cmd_new "$@" ;;
|
|||||||
rename | mv) cmd_rename "$@" ;;
|
rename | mv) cmd_rename "$@" ;;
|
||||||
repo) cmd_repo "$@" ;;
|
repo) cmd_repo "$@" ;;
|
||||||
alerts) cmd_alerts ;;
|
alerts) cmd_alerts ;;
|
||||||
|
guests) cmd_guests "$@" ;;
|
||||||
ls | list) cmd_ls ;;
|
ls | list) cmd_ls ;;
|
||||||
--list) cmd_list ;;
|
--list) cmd_list ;;
|
||||||
end) cmd_end "$@" ;;
|
end) cmd_end "$@" ;;
|
||||||
|
|||||||
Vendored
+291
@@ -1,3 +1,294 @@
|
|||||||
|
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
|
devdesk (0.7.2) bookworm; urgency=medium
|
||||||
|
|
||||||
* Fix: Go to file failed in big non-git folders such as /root/code: the
|
* Fix: Go to file failed in big non-git folders such as /root/code: the
|
||||||
|
|||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
/var/log/devdesk-deploy.log {
|
||||||
|
monthly
|
||||||
|
rotate 6
|
||||||
|
compress
|
||||||
|
delaycompress
|
||||||
|
missingok
|
||||||
|
notifempty
|
||||||
|
}
|
||||||
Vendored
+7
@@ -13,6 +13,13 @@ configure)
|
|||||||
# Apply tmux.conf changes to a running server without restarting it.
|
# Apply tmux.conf changes to a running server without restarting it.
|
||||||
if [ -S /run/devdesk/tmux.sock ]; then
|
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
|
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
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+5
-1
@@ -37,7 +37,9 @@ Then run `authelia validate-config --config /etc/authelia/configuration.yml && s
|
|||||||
- **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.
|
- **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. Auto-deploy on release
|
## 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`.
|
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:**
|
**Setup, done once:**
|
||||||
1. Generate a secret.
|
1. Generate a secret.
|
||||||
@@ -48,6 +50,8 @@ A `v*` tag makes CI publish the package, then send a signed webhook (the house H
|
|||||||
"execute-command": "/usr/lib/devdesk/devdesk-deploy",
|
"execute-command": "/usr/lib/devdesk/devdesk-deploy",
|
||||||
"command-working-directory": "/",
|
"command-working-directory": "/",
|
||||||
"pass-arguments-to-command": [{"source": "payload", "name": "ref"}],
|
"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": [
|
"trigger-rule": {"and": [
|
||||||
{"match": {"type": "payload-hash-sha256", "secret": "<WEBHOOK_SECRET>",
|
{"match": {"type": "payload-hash-sha256", "secret": "<WEBHOOK_SECRET>",
|
||||||
"parameter": {"source": "header", "name": "X-Gitea-Signature"}}},
|
"parameter": {"source": "header", "name": "X-Gitea-Signature"}}},
|
||||||
|
|||||||
@@ -9,7 +9,12 @@
|
|||||||
include /snippets/authelia-location.conf;
|
include /snippets/authelia-location.conf;
|
||||||
|
|
||||||
location / {
|
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;
|
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 /authelia;
|
||||||
auth_request_set $user $upstream_http_remote_user;
|
auth_request_set $user $upstream_http_remote_user;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
# DevDesk defaults, shipped by the package. Don't edit: put your changes in
|
# 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
|
# /etc/devdesk/env.local (same KEY=value format, read after this file), so
|
||||||
# package upgrades never prompt about this file.
|
# package upgrades never prompt about this file.
|
||||||
# Read by systemd (EnvironmentFile=) and by the devdesk scripts. Plain KEY=value
|
# Read by systemd (EnvironmentFile=) and by the devdesk scripts, the same way:
|
||||||
# lines only: systemd does not support inline comments.
|
# 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).
|
# The ttyd credential lives in /etc/devdesk/secret.env (generated on install).
|
||||||
|
|
||||||
# Port ttyd listens on (NPM proxies dev.lotusguild.org here).
|
# Port ttyd listens on (NPM proxies dev.lotusguild.org here).
|
||||||
@@ -29,6 +30,13 @@ DEVDESK_GRAFANA_URL=http://10.10.10.49:3000
|
|||||||
DEVDESK_GRAFANA_PUBLIC_URL=https://dashboard.lotusguild.org
|
DEVDESK_GRAFANA_PUBLIC_URL=https://dashboard.lotusguild.org
|
||||||
DEVDESK_GRAFANA_TOKEN_FILE=/etc/devdesk/grafana-token
|
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 Code binary and extra arguments for every session.
|
||||||
CLAUDE_BIN=/root/.local/bin/claude
|
CLAUDE_BIN=/root/.local/bin/claude
|
||||||
CLAUDE_ARGS=
|
CLAUDE_ARGS=
|
||||||
@@ -38,9 +46,23 @@ DEVDESK_PUBLIC_URL=https://dev.lotusguild.org
|
|||||||
|
|
||||||
# "Claude needs you" in Matrix when a session waits and no tab or SSH client is
|
# "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.
|
# 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_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_WEBHOOK_FILE=/etc/devdesk/matrix-webhook
|
||||||
DEVDESK_MATRIX_DELAY=30
|
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
|
||||||
|
|
||||||
# 1 = also enable Remote Control so sessions can be continued from the Claude app.
|
# 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
|
DEVDESK_REMOTE_CONTROL=0
|
||||||
|
|||||||
+43
-12
@@ -27,7 +27,8 @@ RESET=$'\033[0m'
|
|||||||
|
|
||||||
common_args() {
|
common_args() {
|
||||||
printf '%s\0' --settings "$SETTINGS"
|
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"
|
printf '%s\0' --remote-control "devdesk-$NAME"
|
||||||
fi
|
fi
|
||||||
if [ ${#EXTRA[@]} -gt 0 ]; then
|
if [ ${#EXTRA[@]} -gt 0 ]; then
|
||||||
@@ -36,30 +37,43 @@ common_args() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
run_new() {
|
run_new() {
|
||||||
local args
|
local args prompt=() file="$DEVDESK_STATE_DIR/prompts/$NAME.txt"
|
||||||
mapfile -d '' args < <(common_args)
|
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() {
|
run_resume() {
|
||||||
local id args started rc
|
local id args fork=()
|
||||||
id=$(dd_registry_get "$NAME" claude_session_id)
|
id=$(dd_registry_get "$NAME" claude_session_id)
|
||||||
if [ -z "$id" ]; then
|
if [ -z "$id" ]; then
|
||||||
run_new
|
run_new
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
mapfile -d '' args < <(common_args)
|
# Start fresh only when the conversation's file is really gone. Any other
|
||||||
started=$SECONDS
|
# failure (an early Ctrl+C, a network error) leaves the recorded id alone
|
||||||
"$CLAUDE_BIN" --resume "$id" "${args[@]}"
|
# and ends at the menu, where ⏎ tries again (devdesk#12).
|
||||||
rc=$?
|
if [ -z "$(dd_convo_file "$id")" ]; then
|
||||||
# A resume that fails straight away means the transcript is gone; start fresh.
|
printf "%sConversation %s is no longer in Claude's history — starting a new one.%s\n" "$MUTED" "$id" "$RESET"
|
||||||
if [ "$rc" -ne 0 ] && [ $((SECONDS - started)) -lt 10 ]; then
|
|
||||||
printf '%sCould not resume conversation %s — starting a new one.%s\n' "$MUTED" "$id" "$RESET"
|
|
||||||
sleep 1
|
sleep 1
|
||||||
run_new
|
run_new
|
||||||
return
|
return
|
||||||
fi
|
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() {
|
menu() {
|
||||||
@@ -69,6 +83,14 @@ menu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
printf '\033]2;%s\033\134' "$NAME" # pane title (ST = ESC \\)
|
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
|
mode=resume
|
||||||
while :; do
|
while :; do
|
||||||
NAME=$(dd_current_session) # follow renames
|
NAME=$(dd_current_session) # follow renames
|
||||||
@@ -76,6 +98,15 @@ while :; do
|
|||||||
if [ "$mode" = new ]; then run_new; else run_resume; fi
|
if [ "$mode" = new ]; then run_new; else run_resume; fi
|
||||||
rc=$?
|
rc=$?
|
||||||
NAME=$(dd_current_session)
|
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
|
while :; do
|
||||||
menu "$rc"
|
menu "$rc"
|
||||||
IFS= read -rsn1 key || exit 0
|
IFS= read -rsn1 key || exit 0
|
||||||
|
|||||||
+32
-9
@@ -3,6 +3,9 @@
|
|||||||
# listener (hook "devdesk-deploy") when CI publishes a v* tag; the tag's ref is
|
# 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
|
# passed as $1. Safe for running sessions: package upgrades restart only
|
||||||
# devdesk.service (ttyd), never devdesk-tmux.service where Claude lives.
|
# 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
|
set -euo pipefail
|
||||||
|
|
||||||
LOG=${DEVDESK_DEPLOY_LOG:-/var/log/devdesk-deploy.log}
|
LOG=${DEVDESK_DEPLOY_LOG:-/var/log/devdesk-deploy.log}
|
||||||
@@ -12,11 +15,28 @@ log() {
|
|||||||
printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$LOG"
|
printf '%s %s\n' "$(date '+%F %T')" "$*" >>"$LOG"
|
||||||
}
|
}
|
||||||
|
|
||||||
exec 9>/run/devdesk-deploy.lock
|
# The outcome goes to the log and to stdout (the webhook response).
|
||||||
flock -w 900 9 || {
|
say() {
|
||||||
log "another deploy is still running; giving up"
|
log "$*"
|
||||||
|
printf '%s\n' "$*"
|
||||||
|
}
|
||||||
|
fail() {
|
||||||
|
say "deploy FAILED: $*"
|
||||||
exit 1
|
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=${1:-}
|
||||||
want=${want#refs/tags/}
|
want=${want#refs/tags/}
|
||||||
@@ -25,7 +45,7 @@ before=$(dpkg-query -W -f='${Version}' devdesk 2>/dev/null || echo none)
|
|||||||
log "=== deploy: installed $before, release ${want:-latest}"
|
log "=== deploy: installed $before, release ${want:-latest}"
|
||||||
|
|
||||||
refresh() {
|
refresh() {
|
||||||
apt-get update -q -o Dir::Etc::sourcelist="$SOURCES" -o Dir::Etc::sourceparts=- \
|
apt-get update -q "${APT_OPTS[@]}" -o Dir::Etc::sourcelist="$SOURCES" -o Dir::Etc::sourceparts=- \
|
||||||
-o APT::Get::List-Cleanup=0 >>"$LOG" 2>&1
|
-o APT::Get::List-Cleanup=0 >>"$LOG" 2>&1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,14 +61,17 @@ for _ in $(seq 1 18); do
|
|||||||
done
|
done
|
||||||
|
|
||||||
if [ -n "$want" ] && [ "$candidate" != "$want" ]; then
|
if [ -n "$want" ] && [ "$candidate" != "$want" ]; then
|
||||||
log "registry still offers $candidate, not $want; giving up"
|
fail "the registry still offers ${candidate:-nothing}, not $want"
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
if [ "$candidate" = "$before" ]; then
|
if [ "$candidate" = "$before" ]; then
|
||||||
log "already at $before; nothing to do"
|
say "already at $before; nothing to do"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -q --only-upgrade \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -q --only-upgrade "${APT_OPTS[@]}" \
|
||||||
-o Dpkg::Options::=--force-confold devdesk </dev/null >>"$LOG" 2>&1
|
-o Dpkg::Options::=--force-confold devdesk </dev/null >>"$LOG" 2>&1
|
||||||
log "devdesk $before -> $(dpkg-query -W -f='${Version}' devdesk)"
|
after=$(dpkg-query -W -f='${Version}' devdesk)
|
||||||
|
if [ -n "$want" ] && [ "$after" != "$want" ]; then
|
||||||
|
fail "installed $after, not $want"
|
||||||
|
fi
|
||||||
|
say "devdesk $before -> $after"
|
||||||
|
|||||||
@@ -190,6 +190,81 @@ cmd_git_show() {
|
|||||||
rm -f "$tmp"
|
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),
|
# --asset NAME: a lazily loaded part of the web UI (the file viewer's editor),
|
||||||
# shipped in $DEVDESK_ASSETS and cached by the browser.
|
# shipped in $DEVDESK_ASSETS and cached by the browser.
|
||||||
cmd_asset() {
|
cmd_asset() {
|
||||||
|
|||||||
@@ -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]}'
|
||||||
|
}
|
||||||
Executable
+54
@@ -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
|
||||||
@@ -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}'
|
||||||
|
}
|
||||||
@@ -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'"
|
||||||
|
}
|
||||||
+101
-25
@@ -8,13 +8,34 @@ DEVDESK_ENV=${DEVDESK_ENV:-/etc/devdesk/env}
|
|||||||
# Local overrides go in env.local: it is never shipped, so upgrades don't prompt about it.
|
# 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}
|
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() {
|
dd_load_env() {
|
||||||
local f
|
local _dd_f _dd_line _dd_n _dd_key _dd_val
|
||||||
for f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do
|
for _dd_f in "$DEVDESK_ENV" "$DEVDESK_ENV_LOCAL"; do
|
||||||
if [ -r "$f" ]; then
|
[ -r "$_dd_f" ] || continue
|
||||||
# shellcheck source=/dev/null
|
_dd_n=0
|
||||||
. "$f"
|
while IFS= read -r _dd_line || [ -n "$_dd_line" ]; do
|
||||||
fi
|
_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
|
done
|
||||||
}
|
}
|
||||||
dd_load_env
|
dd_load_env
|
||||||
@@ -33,10 +54,14 @@ dd_load_env
|
|||||||
: "${DEVDESK_GRAFANA_URL:=}"
|
: "${DEVDESK_GRAFANA_URL:=}"
|
||||||
: "${DEVDESK_GRAFANA_PUBLIC_URL:=$DEVDESK_GRAFANA_URL}"
|
: "${DEVDESK_GRAFANA_PUBLIC_URL:=$DEVDESK_GRAFANA_URL}"
|
||||||
: "${DEVDESK_GRAFANA_TOKEN_FILE:=/etc/devdesk/grafana-token}"
|
: "${DEVDESK_GRAFANA_TOKEN_FILE:=/etc/devdesk/grafana-token}"
|
||||||
|
: "${DEVDESK_PVE_NODES:=}"
|
||||||
: "${DEVDESK_UPLOAD_MAX:=26214400}"
|
: "${DEVDESK_UPLOAD_MAX:=26214400}"
|
||||||
: "${DEVDESK_PUBLIC_URL:=}"
|
: "${DEVDESK_PUBLIC_URL:=}"
|
||||||
: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}"
|
: "${DEVDESK_MATRIX_WEBHOOK_FILE:=/etc/devdesk/matrix-webhook}"
|
||||||
: "${DEVDESK_MATRIX_DELAY:=30}"
|
: "${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_REGISTRY="$DEVDESK_STATE_DIR/registry.json"
|
||||||
DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context"
|
DEVDESK_CONTEXT_DIR="$DEVDESK_STATE_DIR/context"
|
||||||
@@ -100,31 +125,76 @@ dd_registry_get() {
|
|||||||
jq -Mr --arg n "$1" --arg f "$2" '.sessions[$n][$f] // empty' "$DEVDESK_REGISTRY" 2>/dev/null
|
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_locked CMD [ARGS...] — run CMD holding the registry lock, so a
|
||||||
dd_registry_update() {
|
# step that reads tmux and then writes the registry (the SessionStart hook,
|
||||||
local filter=$1
|
# rename) can't interleave with another. Nests: an update inside reuses it.
|
||||||
shift
|
dd_registry_locked() {
|
||||||
|
if [ -n "${DD_REGISTRY_LOCKED:-}" ]; then
|
||||||
|
"$@"
|
||||||
|
return
|
||||||
|
fi
|
||||||
mkdir -p "$DEVDESK_STATE_DIR"
|
mkdir -p "$DEVDESK_STATE_DIR"
|
||||||
(
|
(
|
||||||
flock -w 5 9 || exit 1
|
flock -w 5 9 || exit 1
|
||||||
[ -s "$DEVDESK_REGISTRY" ] || printf '{"sessions":{}}\n' >"$DEVDESK_REGISTRY"
|
DD_REGISTRY_LOCKED=1
|
||||||
local tmp
|
"$@"
|
||||||
tmp=$(mktemp "$DEVDESK_REGISTRY.XXXXXX") || exit 1
|
|
||||||
if jq "$@" "$filter" "$DEVDESK_REGISTRY" >"$tmp"; then
|
|
||||||
chmod 600 "$tmp"
|
|
||||||
mv -f "$tmp" "$DEVDESK_REGISTRY"
|
|
||||||
else
|
|
||||||
rm -f "$tmp"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
) 9>"$DEVDESK_REGISTRY.lock"
|
) 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"
|
||||||
|
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"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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() {
|
dd_registry_set_dir() {
|
||||||
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, updated: $t})' \
|
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {dir: $d, updated: $t})' \
|
||||||
--arg n "$1" --arg d "$2" --arg t "$(date -Is)"
|
--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_set_claude() {
|
||||||
dd_registry_update '.sessions[$n] = ((.sessions[$n] // {}) + {claude_session_id: $id, claude_source: $src, updated: $t})' \
|
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)"
|
--arg n "$1" --arg id "$2" --arg src "$3" --arg t "$(date -Is)"
|
||||||
@@ -132,7 +202,8 @@ dd_registry_set_claude() {
|
|||||||
|
|
||||||
dd_registry_remove() {
|
dd_registry_remove() {
|
||||||
dd_registry_update 'del(.sessions[$n])' --arg n "$1"
|
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"
|
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"
|
||||||
}
|
}
|
||||||
|
|
||||||
dd_registry_rename() {
|
dd_registry_rename() {
|
||||||
@@ -144,9 +215,14 @@ dd_registry_rename() {
|
|||||||
mv -f "$d/$1.json" "$d/$2.json"
|
mv -f "$d/$1.json" "$d/$2.json"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -f "$DEVDESK_STATE_DIR/edits/$1.txt" ]; then
|
local f
|
||||||
mv -f "$DEVDESK_STATE_DIR/edits/$1.txt" "$DEVDESK_STATE_DIR/edits/$2.txt"
|
for f in "edits/%s.txt" "commands/%s.jsonl" "tags/%s.json" "prompts/%s.txt"; do
|
||||||
fi
|
# 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.
|
# Project name (directory or its slug) -> absolute directory inside the projects root.
|
||||||
|
|||||||
@@ -1,15 +1,48 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# devdesk-notify-matrix SESSION SINCE
|
# 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.
|
# Started (detached) by devdesk-state-hook when a session starts waiting on you.
|
||||||
# After DEVDESK_MATRIX_DELAY seconds, if that same wait is still on and nobody is
|
# After DEVDESK_MATRIX_DELAY seconds, if that same wait is still on, no DevDesk
|
||||||
# attached to the session (no browser tab, no SSH client), post one "Claude needs
|
# tab is in view, and this session hasn't posted in DEVDESK_MATRIX_QUIET seconds,
|
||||||
# you" message to the hookshot webhook in DEVDESK_MATRIX_WEBHOOK_FILE (devdesk#28).
|
# post what Claude is asking to the hookshot webhook in DEVDESK_MATRIX_WEBHOOK_FILE
|
||||||
|
# (devdesk#28, #50).
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# shellcheck source=libexec/devdesk-lib.sh
|
# shellcheck source=libexec/devdesk-lib.sh
|
||||||
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/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:-}
|
session=${1:-}
|
||||||
since=${2:-}
|
since=${2:-}
|
||||||
dd_valid_name "$session" || exit 0
|
dd_valid_name "$session" || exit 0
|
||||||
@@ -21,21 +54,29 @@ sleep "$DEVDESK_MATRIX_DELAY"
|
|||||||
state_file="$DEVDESK_STATUS_DIR/$session.json"
|
state_file="$DEVDESK_STATUS_DIR/$session.json"
|
||||||
# Still the same wait? (Answering it moves the state on; a new wait has its own notifier.)
|
# 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
|
[ "$(jq -rM '"\(.state) \(.since)"' "$state_file" 2>/dev/null)" = "waiting $since" ] || exit 0
|
||||||
# Anyone looking at it already?
|
# You're at DevDesk if any tab has shown a session in the last ~2 minutes
|
||||||
attached=$(dd_tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null |
|
# (presence markers, devdesk#26): the browser tells you itself (devdesk#50).
|
||||||
awk -v s="$session" '$1 == s { print $2 }')
|
[ -z "$(find "$DEVDESK_PRESENCE_DIR" -type f 2>/dev/null | head -n 1)" ] || exit 0
|
||||||
[ "${attached:-0}" = 0 ] || 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"
|
||||||
|
|
||||||
message=$(jq -rM '.message // "Claude is waiting for your input"' "$state_file" 2>/dev/null)
|
|
||||||
url=""
|
url=""
|
||||||
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session"
|
# existing=1: if the session was ended by the time you tap, say so instead of
|
||||||
body=$(jq -cMn --arg s "$session" --arg m "$message" --arg u "$url" --arg h "$(hostname)" \
|
# starting a new Claude there (devdesk#38).
|
||||||
'{session: $s, message: $m, url: (if $u == "" then null else $u end), host: $h,
|
[ -z "$DEVDESK_PUBLIC_URL" ] || url="${DEVDESK_PUBLIC_URL%/}/?arg=$session&existing=1"
|
||||||
text: ("Claude needs you in " + $s + ": " + $m + (if $u == "" then "" else " — " + $u end))}')
|
# 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))}')
|
||||||
|
|
||||||
# The webhook URL is a secret (anyone holding it can post to the room): pass it
|
post "$body"
|
||||||
# to curl on stdin, never on the command line.
|
|
||||||
# shellcheck disable=SC1003 # tr deletes whitespace, quotes and backslashes from the URL
|
|
||||||
webhook=$(tr -d '[:space:]"\\' <"$DEVDESK_MATRIX_WEBHOOK_FILE")
|
|
||||||
printf 'url = "%s"\n' "$webhook" |
|
|
||||||
curl -fsS --max-time 10 --retry 2 -K - -H 'Content-Type: application/json' --data-binary "$body" >/dev/null
|
|
||||||
|
|||||||
@@ -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}'
|
||||||
|
}
|
||||||
@@ -7,14 +7,20 @@
|
|||||||
# shellcheck source=libexec/devdesk-lib.sh
|
# shellcheck source=libexec/devdesk-lib.sh
|
||||||
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" || exit 0
|
. "${DEVDESK_LIBEXEC:-/usr/lib/devdesk}/devdesk-lib.sh" || exit 0
|
||||||
|
|
||||||
session=$(dd_current_session)
|
|
||||||
[ -n "$session" ] || exit 0
|
|
||||||
dd_valid_name "$session" || exit 0
|
|
||||||
|
|
||||||
input=$(cat)
|
input=$(cat)
|
||||||
id=$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null)
|
id=$(jq -r '.session_id // empty' <<<"$input" 2>/dev/null)
|
||||||
source=$(jq -r '.source // empty' <<<"$input" 2>/dev/null)
|
source=$(jq -r '.source // empty' <<<"$input" 2>/dev/null)
|
||||||
[ -n "$id" ] || exit 0
|
[ -n "$id" ] || exit 0
|
||||||
|
|
||||||
dd_registry_set_claude "$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
|
exit 0
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
# Claude Code hook (wired in via claude-settings.json) that tracks what Claude is
|
# 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:
|
# doing in each DevDesk session, for the sidebar's at-a-glance status:
|
||||||
# UserPromptSubmit, PostToolUse -> working
|
# 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)
|
# Notification -> waiting (Claude needs input or a permission)
|
||||||
# Stop, SessionStart -> idle
|
# Stop, SessionStart -> idle
|
||||||
# SessionEnd -> exited
|
# SessionEnd -> exited
|
||||||
@@ -18,8 +21,17 @@ session=$(tmux display-message -p -t "$TMUX_PANE" '#S' 2>/dev/null)
|
|||||||
|
|
||||||
# Unit separator, not tab: read collapses runs of whitespace delimiters, so an
|
# Unit separator, not tab: read collapses runs of whitespace delimiters, so an
|
||||||
# empty field would shift the ones after it.
|
# empty field would shift the ones after it.
|
||||||
IFS=$'\x1f' read -r event message tool file < <(jq -rM '[.hook_event_name // "", (.message // "" | gsub("[\u001f\n]"; " ")),
|
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]"; " "))] | join("\u001f")' <<<"$input" 2>/dev/null)
|
.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).
|
# Files Claude edits, for the file viewer's "Claude" badge (devdesk#19).
|
||||||
case $event:$tool in
|
case $event:$tool in
|
||||||
@@ -33,9 +45,39 @@ PostToolUse:Edit | PostToolUse:Write | PostToolUse:MultiEdit | PostToolUse:Noteb
|
|||||||
fi
|
fi
|
||||||
;;
|
;;
|
||||||
esac
|
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
|
case $event in
|
||||||
UserPromptSubmit | PostToolUse) state=working ;;
|
UserPromptSubmit | PostToolUse) state=working ;;
|
||||||
Notification) state=waiting ;;
|
Notification | PermissionRequest) state=waiting ;;
|
||||||
Stop | SessionStart) state=idle ;;
|
Stop | SessionStart) state=idle ;;
|
||||||
SessionEnd) state=exited ;;
|
SessionEnd) state=exited ;;
|
||||||
*) exit 0 ;;
|
*) exit 0 ;;
|
||||||
@@ -47,19 +89,33 @@ file="$dir/$session.json"
|
|||||||
if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then
|
if [ "$state" != waiting ] && [ -f "$file" ] && grep -q "\"state\":\"$state\"" "$file"; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
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
|
# A second Notification during the same wait updates the message but keeps
|
||||||
# `since`, so the wait stays one wait (one Matrix message at most).
|
# `since` (and what the wait is about), so the wait stays one wait (one Matrix
|
||||||
|
# message at most).
|
||||||
since=$(date +%s)
|
since=$(date +%s)
|
||||||
was_waiting=0
|
was_waiting=0
|
||||||
if [ "$state" = waiting ] && grep -q '"state":"waiting"' "$file" 2>/dev/null; then
|
if [ "$state" = waiting ] && grep -q '"state":"waiting"' "$file" 2>/dev/null; then
|
||||||
was_waiting=1
|
was_waiting=1
|
||||||
since=$(jq -rM '.since // empty' "$file" 2>/dev/null) || since=$(date +%s)
|
since=$(jq -rM '.since // empty' "$file" 2>/dev/null) || since=$(date +%s)
|
||||||
[[ $since =~ ^[0-9]+$ ]] || 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
|
fi
|
||||||
mkdir -p "$dir" 2>/dev/null || exit 0
|
mkdir -p "$dir" 2>/dev/null || exit 0
|
||||||
tmp=$(mktemp "$dir/.$session.XXXXXX" 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" \
|
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)}' >"$tmp"; then
|
'{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"
|
mv -f "$tmp" "$file"
|
||||||
else
|
else
|
||||||
rm -f "$tmp"
|
rm -f "$tmp"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Claude Code statusLine command (wired in via claude-settings.json). Claude pipes
|
# 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
|
# its status JSON in on every update; we keep a snapshot per DevDesk session for
|
||||||
# the web UI's Claude Context panel and print a one-line status for Claude's footer.
|
# 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.
|
# Runs often while Claude streams, so it stays cheap and never fails loudly.
|
||||||
|
|
||||||
input=$(cat)
|
input=$(cat)
|
||||||
@@ -29,7 +29,12 @@ if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
|
|||||||
+ (.cache_read_input_tokens // 0)) else null end),
|
+ (.cache_read_input_tokens // 0)) else null end),
|
||||||
cost_usd: .cost.total_cost_usd,
|
cost_usd: .cost.total_cost_usd,
|
||||||
lines_added: .cost.total_lines_added,
|
lines_added: .cost.total_lines_added,
|
||||||
lines_removed: .cost.total_lines_removed
|
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
|
}' <<<"$input" >"$tmp" 2>/dev/null; then
|
||||||
mv -f "$tmp" "$dir/$session.json"
|
mv -f "$tmp" "$dir/$session.json"
|
||||||
else
|
else
|
||||||
@@ -38,13 +43,32 @@ if [[ $session =~ ^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$ ]]; then
|
|||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Footer line: session · model · context % · cost. ANSI colour numbers, not RGB,
|
# Plan usage past DEVDESK_USAGE_WARN: ping Matrix if you're away (devdesk#45).
|
||||||
# so the browser's dark or light palette applies (devdesk#31).
|
# 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" '
|
jq -rM --arg s "$session" '
|
||||||
def c($sgr; $t): "\u001b[\($sgr)m\($t)\u001b[0m";
|
def c($sgr; $t): "\u001b[\($sgr)m\($t)\u001b[0m";
|
||||||
def pct: if . == null then "—" else "\(. | floor)%" end;
|
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),
|
[ (if $s != "" then c("33"; "◈ \($s)") else empty end),
|
||||||
c("36"; .model.display_name // "Claude"),
|
c("36"; .model.display_name // "Claude"),
|
||||||
c("37"; "ctx \(.context_window.used_percentage | pct)"),
|
c("37"; "ctx \(.context_window.used_percentage | pct)\(ctxtok)"),
|
||||||
c("37"; "$\((.cost.total_cost_usd // 0) * 100 | round / 100)")
|
(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
|
] | join(c("90"; " · "))' <<<"$input" 2>/dev/null || true
|
||||||
|
|||||||
Generated
+1
@@ -7,6 +7,7 @@
|
|||||||
"name": "devdesk",
|
"name": "devdesk",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@codemirror/autocomplete": "6.20.3",
|
||||||
"@codemirror/commands": "6.11.1",
|
"@codemirror/commands": "6.11.1",
|
||||||
"@codemirror/lang-markdown": "6.5.2",
|
"@codemirror/lang-markdown": "6.5.2",
|
||||||
"@codemirror/language": "6.13.1",
|
"@codemirror/language": "6.13.1",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"test": "node --test tests/*.test.mjs"
|
"test": "node --test tests/*.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@codemirror/autocomplete": "6.20.3",
|
||||||
"@codemirror/commands": "6.11.1",
|
"@codemirror/commands": "6.11.1",
|
||||||
"@codemirror/lang-markdown": "6.5.2",
|
"@codemirror/lang-markdown": "6.5.2",
|
||||||
"@codemirror/language": "6.13.1",
|
"@codemirror/language": "6.13.1",
|
||||||
|
|||||||
@@ -73,6 +73,29 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
],
|
||||||
|
"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": {
|
"statusLine": {
|
||||||
|
|||||||
+7
-1
@@ -8,7 +8,13 @@ set -s set-clipboard on # tmux copy-mode -> OSC 52 -> browser cli
|
|||||||
set -s default-terminal "tmux-256color"
|
set -s default-terminal "tmux-256color"
|
||||||
set -s terminal-features[90] "xterm-256color:RGB" # fixed index: re-sourcing must not append
|
set -s terminal-features[90] "xterm-256color:RGB" # fixed index: re-sourcing must not append
|
||||||
set -g history-limit 50000
|
set -g history-limit 50000
|
||||||
set -g mouse on # wheel scrolls history; Shift+drag selects natively
|
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 allow-passthrough off # output may not bypass tmux (title/clipboard spoofing)
|
||||||
set -g renumber-windows on
|
set -g renumber-windows on
|
||||||
|
|
||||||
|
|||||||
+803
-15
@@ -5,6 +5,7 @@ REPO=$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)
|
|||||||
|
|
||||||
setup() {
|
setup() {
|
||||||
TMP=$(mktemp -d /tmp/devdesk-test.XXXXXX) # short path: unix socket limit
|
TMP=$(mktemp -d /tmp/devdesk-test.XXXXXX) # short path: unix socket limit
|
||||||
|
unset DEVDESK_REMOTE_CONTROL CLAUDE_ARGS DEVDESK_SESSION TMUX TMUX_PANE # when run from inside DevDesk
|
||||||
export DEVDESK_ENV=/dev/null
|
export DEVDESK_ENV=/dev/null
|
||||||
export DEVDESK_PROJECTS_ROOT="$TMP/projects"
|
export DEVDESK_PROJECTS_ROOT="$TMP/projects"
|
||||||
export DEVDESK_STATE_DIR="$TMP/state"
|
export DEVDESK_STATE_DIR="$TMP/state"
|
||||||
@@ -13,20 +14,26 @@ setup() {
|
|||||||
export DEVDESK_SHARE="$REPO/share"
|
export DEVDESK_SHARE="$REPO/share"
|
||||||
export DEVDESK_NO_SYSTEMD=1
|
export DEVDESK_NO_SYSTEMD=1
|
||||||
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room
|
export DEVDESK_MATRIX_WEBHOOK_FILE="$TMP/no-webhook" # never the real room
|
||||||
|
export DEVDESK_PRESENCE_DIR="$TMP/presence" # never the live tabs
|
||||||
export FAKE_LOG="$TMP/claude-args"
|
export FAKE_LOG="$TMP/claude-args"
|
||||||
export CLAUDE_BIN="$TMP/fake-claude"
|
export CLAUDE_BIN="$TMP/fake-claude"
|
||||||
|
export CLAUDE_CONFIG_DIR="$TMP/claude" # never the real conversation history
|
||||||
export PATH="$REPO/bin:$PATH"
|
export PATH="$REPO/bin:$PATH"
|
||||||
mkdir -p "$DEVDESK_PROJECTS_ROOT/alpha" "$DEVDESK_PROJECTS_ROOT/test code" "$TMP/outside" "$FAKE_LOG"
|
mkdir -p "$DEVDESK_PROJECTS_ROOT/alpha" "$DEVDESK_PROJECTS_ROOT/test code" "$TMP/outside" "$FAKE_LOG"
|
||||||
ln -s "$TMP/outside" "$DEVDESK_PROJECTS_ROOT/escape"
|
ln -s "$TMP/outside" "$DEVDESK_PROJECTS_ROOT/escape"
|
||||||
cat >"$CLAUDE_BIN" <<'EOF'
|
cat >"$CLAUDE_BIN" <<'EOF'
|
||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION"
|
printf '%s\n' "$*" >>"$FAKE_LOG/$DEVDESK_SESSION"
|
||||||
exec sleep 300
|
[ -z "${FAKE_EXIT:-}" ] || exit "$FAKE_EXIT"
|
||||||
|
# Keeps its arguments in /proc/PID/cmdline (Remote Control is read from there)
|
||||||
|
# and ends on SIGTERM, like Claude.
|
||||||
|
exec bash -c 'sleep 300 & wait' fake-claude "$@"
|
||||||
EOF
|
EOF
|
||||||
chmod +x "$CLAUDE_BIN"
|
chmod +x "$CLAUDE_BIN"
|
||||||
}
|
}
|
||||||
|
|
||||||
teardown() {
|
teardown() {
|
||||||
|
[ -z "${RECEIVER:-}" ] || kill "$RECEIVER" 2>/dev/null || true
|
||||||
tmux -S "$DEVDESK_SOCKET" kill-server 2>/dev/null || true
|
tmux -S "$DEVDESK_SOCKET" kill-server 2>/dev/null || true
|
||||||
rm -rf "$TMP"
|
rm -rf "$TMP"
|
||||||
}
|
}
|
||||||
@@ -192,12 +199,23 @@ claude_args() {
|
|||||||
"cost":{"total_cost_usd":1.5,"total_lines_added":10,"total_lines_removed":2}}'
|
"cost":{"total_cost_usd":1.5,"total_lines_added":10,"total_lines_removed":2}}'
|
||||||
run bash -c "echo '$status_json' | TMUX='$DEVDESK_SOCKET,1,0' TMUX_PANE='$pane' '$REPO/libexec/devdesk-statusline'"
|
run bash -c "echo '$status_json' | TMUX='$DEVDESK_SOCKET,1,0' TMUX_PANE='$pane' '$REPO/libexec/devdesk-statusline'"
|
||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
[[ $output == *"alpha"* && $output == *"ctx 34%"* ]]
|
[[ $output == *"alpha"* && $output == *"ctx 34% 346k/1M"* ]]
|
||||||
run devdesk attach --list
|
run devdesk attach --list
|
||||||
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.used_tokens' <<<"$output")" = 346000 ]
|
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.used_tokens' <<<"$output")" = 346000 ]
|
||||||
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.model' <<<"$output")" = Opus ]
|
[ "$(jq -r '.sessions[] | select(.name=="alpha") | .context.model' <<<"$output")" = Opus ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@test "statusline footer shows plan use on a subscription and dollars only on API billing" {
|
||||||
|
sub='{"model":{"display_name":"Opus"},"context_window":{"used_percentage":10},
|
||||||
|
"cost":{"total_cost_usd":1186.99},"rate_limits":{"five_hour":{"used_percentage":8.4}}}'
|
||||||
|
run bash -c "echo '$sub' | '$REPO/libexec/devdesk-statusline'"
|
||||||
|
[[ $output == *"5h 8%"* ]]
|
||||||
|
[[ $output != *'$'* ]]
|
||||||
|
api='{"model":{"display_name":"Opus"},"context_window":{"used_percentage":10},"cost":{"total_cost_usd":1.5}}'
|
||||||
|
run bash -c "echo '$api' | '$REPO/libexec/devdesk-statusline'"
|
||||||
|
[[ $output == *'$1.5'* ]]
|
||||||
|
}
|
||||||
|
|
||||||
@test "hooks ignore Claude running in some other tmux server" {
|
@test "hooks ignore Claude running in some other tmux server" {
|
||||||
start_server
|
start_server
|
||||||
other="$TMP/other.sock"
|
other="$TMP/other.sock"
|
||||||
@@ -296,21 +314,28 @@ PY
|
|||||||
|
|
||||||
posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
|
posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
|
||||||
|
|
||||||
@test "a wait nobody is attached to is posted to Matrix once (#28)" {
|
@test "a real wait is posted to Matrix once, saying what Claude asks (#28, #50)" {
|
||||||
start_server
|
start_server
|
||||||
start_receiver
|
start_receiver
|
||||||
export DEVDESK_MATRIX_DELAY=1 DEVDESK_PUBLIC_URL=https://dev.example.org
|
export DEVDESK_MATRIX_DELAY=1 DEVDESK_PUBLIC_URL=https://dev.example.org
|
||||||
devdesk new alpha
|
devdesk new alpha
|
||||||
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
|
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
|
||||||
hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
|
hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
|
||||||
|
st="$DEVDESK_STATE_DIR/state/alpha.json"
|
||||||
|
|
||||||
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash"}' | hook
|
# Claude's idle reminder after a turn is not a request: no red, no post.
|
||||||
# a second notification in the same wait: no second message
|
echo '{"hook_event_name":"Stop"}' | hook
|
||||||
|
echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input","notification_type":"idle_prompt"}' | hook
|
||||||
echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input"}' | hook
|
echo '{"hook_event_name":"Notification","message":"Claude is waiting for your input"}' | hook
|
||||||
|
[ "$(jq -r .state "$st")" = idle ]
|
||||||
|
|
||||||
|
echo '{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"systemctl restart nginx","description":"Restart nginx"}}' | hook
|
||||||
|
# a second notification in the same wait: no second message
|
||||||
|
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Bash","notification_type":"permission_prompt"}' | hook
|
||||||
sleep 2.5
|
sleep 2.5
|
||||||
[ "$(posts)" -eq 1 ]
|
[ "$(posts)" -eq 1 ]
|
||||||
run jq -r '[.session, .url, (.text | test("Claude needs you in alpha"))] | @tsv' "$TMP/posts"
|
run jq -r '.text' "$TMP/posts"
|
||||||
[ "$output" = "$(printf 'alpha\thttps://dev.example.org/?arg=alpha\ttrue')" ]
|
[ "$output" = "$(printf 'alpha: Claude wants to run Bash (Restart nginx)\nsystemctl restart nginx\nAnswer: https://dev.example.org/?arg=alpha&existing=1')" ]
|
||||||
|
|
||||||
# answered within the delay: nothing is posted
|
# answered within the delay: nothing is posted
|
||||||
echo '{"hook_event_name":"PostToolUse"}' | hook
|
echo '{"hook_event_name":"PostToolUse"}' | hook
|
||||||
@@ -319,12 +344,18 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
|
|||||||
sleep 2
|
sleep 2
|
||||||
[ "$(posts)" -eq 1 ]
|
[ "$(posts)" -eq 1 ]
|
||||||
|
|
||||||
# no webhook file: feature off
|
# another real wait soon after: quiet period, still one message
|
||||||
rm "$TMP/webhook"
|
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Edit"}' | hook
|
||||||
echo '{"hook_event_name":"Notification","message":"again"}' | hook
|
sleep 2.5
|
||||||
sleep 2
|
[ "$(posts)" -eq 1 ]
|
||||||
|
echo '{"hook_event_name":"PostToolUse"}' | hook
|
||||||
|
|
||||||
|
# past the quiet period, but a DevDesk tab is in view: nothing
|
||||||
|
rm -rf "$DEVDESK_STATE_DIR/matrix-sent"
|
||||||
|
mkdir -p "$DEVDESK_PRESENCE_DIR" && touch "$DEVDESK_PRESENCE_DIR/1"
|
||||||
|
echo '{"hook_event_name":"Notification","message":"Claude needs your permission to use Edit"}' | hook
|
||||||
|
sleep 2.5
|
||||||
[ "$(posts)" -eq 1 ]
|
[ "$(posts)" -eq 1 ]
|
||||||
kill "$RECEIVER"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "under ttyd, one-shot replies wait for the browser to hang up (#35)" {
|
@test "under ttyd, one-shot replies wait for the browser to hang up (#35)" {
|
||||||
@@ -440,12 +471,91 @@ posts() { [ -f "$TMP/posts" ] && wc -l <"$TMP/posts" || echo 0; }
|
|||||||
|
|
||||||
@test "session hook records the Claude session and the next start resumes it" {
|
@test "session hook records the Claude session and the next start resumes it" {
|
||||||
start_server
|
start_server
|
||||||
echo '{"session_id":"abc-123","source":"startup"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
|
id=abcabcab-1234-1234-1234-123412341234
|
||||||
|
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p" && touch "$CLAUDE_CONFIG_DIR/projects/-p/$id.jsonl"
|
||||||
|
echo "{\"session_id\":\"$id\",\"source\":\"startup\"}" | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
|
||||||
run jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json"
|
run jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json"
|
||||||
[ "$output" = "abc-123" ]
|
[ "$output" = "$id" ]
|
||||||
devdesk new alpha
|
devdesk new alpha
|
||||||
run claude_args alpha
|
run claude_args alpha
|
||||||
[ "$output" = "--resume abc-123 --settings $REPO/share/claude-settings.json" ]
|
[ "$output" = "--resume $id --settings $REPO/share/claude-settings.json" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "a conversation whose file is gone starts fresh; a failed resume keeps the id (#12)" {
|
||||||
|
start_server
|
||||||
|
id=abcabcab-1234-1234-1234-123412341234
|
||||||
|
echo "{\"session_id\":\"$id\"}" | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
|
||||||
|
devdesk new alpha
|
||||||
|
run claude_args alpha
|
||||||
|
[ "$output" = "-n alpha --settings $REPO/share/claude-settings.json" ]
|
||||||
|
tmux -S "$DEVDESK_SOCKET" kill-server
|
||||||
|
# The file exists, but Claude exits at once (Ctrl+C, network): the menu, not a new conversation.
|
||||||
|
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p" && touch "$CLAUDE_CONFIG_DIR/projects/-p/$id.jsonl"
|
||||||
|
rm -f "$FAKE_LOG/alpha"
|
||||||
|
FAKE_EXIT=1 start_server
|
||||||
|
devdesk new alpha
|
||||||
|
run claude_args alpha
|
||||||
|
[ "$output" = "--resume $id --settings $REPO/share/claude-settings.json" ]
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
tmux -S "$DEVDESK_SOCKET" capture-pane -p -t =alpha:claude | grep -q "Claude exited" && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
tmux -S "$DEVDESK_SOCKET" capture-pane -p -t =alpha:claude | grep -q "Claude exited"
|
||||||
|
[ "$(wc -l <"$FAKE_LOG/alpha")" = 1 ]
|
||||||
|
[ "$(jq -r '.sessions.alpha.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = "$id" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "env files are parsed like systemd does, never run (#12)" {
|
||||||
|
cat >"$TMP/env" <<'ENV'
|
||||||
|
# comment
|
||||||
|
; also a comment
|
||||||
|
DEVDESK_DEFAULT_DIR=/srv/ops
|
||||||
|
CLAUDE_ARGS=--model opus --verbose
|
||||||
|
DEVDESK_PUBLIC_URL="https://dev.example.org"
|
||||||
|
DEVDESK_GRAFANA_URL='http://g:3000'
|
||||||
|
DEVDESK_MATRIX_DELAY=$(touch /tmp/devdesk-env-pwned)
|
||||||
|
lowercase=x
|
||||||
|
not a line
|
||||||
|
DEVDESK_BIND="unterminated
|
||||||
|
ENV
|
||||||
|
run env DEVDESK_ENV="$TMP/env" DEVDESK_ENV_LOCAL=/dev/null bash -c '. "$1/libexec/devdesk-lib.sh"; printf "%s|" "$DEVDESK_DEFAULT_DIR" "$CLAUDE_ARGS" "$DEVDESK_PUBLIC_URL" "$DEVDESK_GRAFANA_URL" "$DEVDESK_MATRIX_DELAY" "${lowercase:-unset}" "${DEVDESK_BIND:-unset}"' _ "$REPO"
|
||||||
|
[ ! -e /tmp/devdesk-env-pwned ]
|
||||||
|
[[ $output == *'/srv/ops|--model opus --verbose|https://dev.example.org|http://g:3000|$(touch /tmp/devdesk-env-pwned)|unset|unset|'* ]]
|
||||||
|
[[ $output == *"env:8: not a KEY=value line"* ]]
|
||||||
|
[[ $output == *"env:10: unmatched quote"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the SessionStart hook reads the session name under the rename lock (#12)" {
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
claude_args alpha >/dev/null
|
||||||
|
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:claude '#{pane_id}')
|
||||||
|
# A rename holds the lock; the hook fires meanwhile, while tmux still says "alpha".
|
||||||
|
(
|
||||||
|
flock 9
|
||||||
|
sleep 0.5
|
||||||
|
tmux -S "$DEVDESK_SOCKET" rename-session -t =alpha beta
|
||||||
|
DEVDESK_ENV=/dev/null bash -c '. "$1/libexec/devdesk-lib.sh"; DD_REGISTRY_LOCKED=1 dd_registry_rename alpha beta' _ "$REPO"
|
||||||
|
) 9>"$DEVDESK_STATE_DIR/registry.json.lock" 3>&- &
|
||||||
|
renamer=$!
|
||||||
|
sleep 0.1
|
||||||
|
echo '{"session_id":"ffffffff-0000-0000-0000-000000000000","source":"clear"}' |
|
||||||
|
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE=$pane "$REPO/libexec/devdesk-session-hook"
|
||||||
|
wait "$renamer"
|
||||||
|
[ "$(jq -r '.sessions | keys | join(",")' "$DEVDESK_STATE_DIR/registry.json")" = beta ]
|
||||||
|
[ "$(jq -r '.sessions.beta.claude_session_id' "$DEVDESK_STATE_DIR/registry.json")" = ffffffff-0000-0000-0000-000000000000 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "leftover temp files of killed writers are swept (#12)" {
|
||||||
|
mkdir -p "$DEVDESK_STATE_DIR/state" "$DEVDESK_STATE_DIR/context"
|
||||||
|
touch -d '20 minutes ago' "$DEVDESK_STATE_DIR/state/.alpha.Ab12Cd" "$DEVDESK_STATE_DIR/context/.alpha.Xy34Zw" "$DEVDESK_STATE_DIR/registry.json.Qq11Rr"
|
||||||
|
touch "$DEVDESK_STATE_DIR/state/.beta.Ab12Cd" "$DEVDESK_STATE_DIR/state/alpha.json"
|
||||||
|
echo '{"session_id":"x"}' | DEVDESK_SESSION=alpha "$REPO/libexec/devdesk-session-hook"
|
||||||
|
[ ! -e "$DEVDESK_STATE_DIR/state/.alpha.Ab12Cd" ]
|
||||||
|
[ ! -e "$DEVDESK_STATE_DIR/context/.alpha.Xy34Zw" ]
|
||||||
|
[ ! -e "$DEVDESK_STATE_DIR/registry.json.Qq11Rr" ]
|
||||||
|
[ -e "$DEVDESK_STATE_DIR/state/.beta.Ab12Cd" ]
|
||||||
|
[ -e "$DEVDESK_STATE_DIR/state/alpha.json" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "session hook ignores sessions it does not own" {
|
@test "session hook ignores sessions it does not own" {
|
||||||
@@ -620,3 +730,681 @@ blob_body() {
|
|||||||
[ "$status" -eq 0 ]
|
[ "$status" -eq 0 ]
|
||||||
[ "$(jq -r '[(.entries | length), .truncated] | @tsv' <<<"$output")" = "$(printf '5\ttrue')" ]
|
[ "$(jq -r '[(.entries | length), .truncated] | @tsv' <<<"$output")" = "$(printf '5\ttrue')" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Save through --fs-write the way the browser does: content as base64 on stdin.
|
||||||
|
fs_write() { # SESSION PATH BASE CONTENT-FILE
|
||||||
|
local size
|
||||||
|
size=$(stat -c %s "$4")
|
||||||
|
base64 -w0 "$4" | devdesk attach --fs-write "$1" "$2" "$size" "$3"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "fs-write saves atomically, keeps the mode, and refuses stale bases (#20)" {
|
||||||
|
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
printf 'v1\n' >"$p/conf.sh"
|
||||||
|
chmod 0750 "$p/conf.sh"
|
||||||
|
base=$(sha256sum "$p/conf.sh" | cut -d' ' -f1)
|
||||||
|
printf 'v2\n' >"$TMP/new"
|
||||||
|
run fs_write alpha conf.sh "$base" "$TMP/new"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(tail -n1 <<<"$output" | jq -r '.ok')" = true ]
|
||||||
|
[ "$(cat "$p/conf.sh")" = v2 ]
|
||||||
|
[ "$(stat -c %a "$p/conf.sh")" = 750 ]
|
||||||
|
[ "$(tail -n1 <<<"$output" | jq -r '.sha')" = "$(sha256sum "$p/conf.sh" | cut -d' ' -f1)" ]
|
||||||
|
# Someone (Claude) changed it since: the old base no longer matches.
|
||||||
|
printf 'v3 from me\n' >"$TMP/mine"
|
||||||
|
run fs_write alpha conf.sh "$base" "$TMP/mine"
|
||||||
|
[ "$(tail -n1 <<<"$output" | jq -r '[.ok, .conflict] | @tsv')" = "$(printf 'false\ttrue')" ]
|
||||||
|
[ "$(cat "$p/conf.sh")" = v2 ]
|
||||||
|
# No temp files left behind
|
||||||
|
[ -z "$(find "$p" -name '*.devdesk-*')" ]
|
||||||
|
# Empty files are fine
|
||||||
|
: >"$TMP/empty"
|
||||||
|
base=$(sha256sum "$p/conf.sh" | cut -d' ' -f1)
|
||||||
|
run fs_write alpha conf.sh "$base" "$TMP/empty"
|
||||||
|
[ "$(tail -n1 <<<"$output" | jq -r '.ok')" = true ]
|
||||||
|
[ ! -s "$p/conf.sh" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "fs-write creates new files (and folders) but never overwrites or escapes (#20)" {
|
||||||
|
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
printf 'hello\n' >"$TMP/c"
|
||||||
|
run fs_write alpha docs/notes/new.md new "$TMP/c"
|
||||||
|
[ "$(tail -n1 <<<"$output" | jq -r '[.ok, .path] | @tsv')" = "$(printf 'true\tdocs/notes/new.md')" ]
|
||||||
|
[ "$(cat "$p/docs/notes/new.md")" = hello ]
|
||||||
|
run fs_write alpha docs/notes/new.md new "$TMP/c"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"already exists"* ]]
|
||||||
|
ln -s "$TMP/outside" "$p/out"
|
||||||
|
for bad in ../escape.txt /etc/x out/pwned.txt out/newdir/x.txt docs/ ""; do
|
||||||
|
run fs_write alpha "$bad" new "$TMP/c"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
done
|
||||||
|
[ ! -e "$TMP/outside/pwned.txt" ] && [ ! -e "$TMP/outside/newdir" ] && [ ! -e "$DEVDESK_PROJECTS_ROOT/escape.txt" ]
|
||||||
|
# A short stream fails without touching anything
|
||||||
|
run bash -c "printf aGVs | '$REPO/bin/devdesk' attach --fs-write alpha docs/notes/new.md 6 $(sha256sum "$p/docs/notes/new.md" | cut -d' ' -f1)"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[ "$(cat "$p/docs/notes/new.md")" = hello ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "permission requests are recorded and answered only while their prompt shows (#22)" {
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
claude_args alpha >/dev/null
|
||||||
|
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
|
||||||
|
hook() { TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
|
||||||
|
echo '{"hook_event_name":"PermissionRequest","tool_name":"Bash","tool_input":{"command":"rm -rf /tmp/x","description":"Remove x"}}' | hook
|
||||||
|
echo '{"hook_event_name":"Notification","message":"Claude needs your permission","notification_type":"permission_prompt"}' | hook
|
||||||
|
st="$DEVDESK_STATE_DIR/state/alpha.json"
|
||||||
|
[ "$(jq -r '[.state, .request.tool, .request.detail, .request.description, .message] | @tsv' "$st")" = \
|
||||||
|
"$(printf 'waiting\tBash\trm -rf /tmp/x\tRemove x\tClaude wants to use Bash')" ]
|
||||||
|
since=$(jq -r .since "$st")
|
||||||
|
# The fake Claude window shows no prompt: refused, nothing typed.
|
||||||
|
run devdesk attach --answer alpha "$since" allow
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"isn't on screen"* ]]
|
||||||
|
# Put a prompt on screen (what Claude draws), then allow.
|
||||||
|
tmux -S "$DEVDESK_SOCKET" respawn-window -k -t =alpha:claude "printf ' Do you want to proceed?\n ❯ 1. Yes\n 2. No\n'; read -r -n1 k; echo \"got:\$k\" > '$TMP/key'; sleep 30"
|
||||||
|
sleep 0.5
|
||||||
|
run devdesk attach --answer alpha 999 allow
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"already answered"* ]]
|
||||||
|
run devdesk attach --answer alpha "$since" allow
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
sleep 0.5
|
||||||
|
[ "$(cat "$TMP/key")" = "got:1" ]
|
||||||
|
[ "$(jq -r .state "$st")" = working ]
|
||||||
|
# Answered: a second click is refused.
|
||||||
|
run devdesk attach --answer alpha "$since" deny
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "peek shows the last exchange; reply pastes into the Claude window (#23)" {
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
claude_args alpha >/dev/null
|
||||||
|
export CLAUDE_CONFIG_DIR="$TMP/claude"
|
||||||
|
id=11111111-2222-3333-4444-555555555555
|
||||||
|
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-x"
|
||||||
|
cat >"$CLAUDE_CONFIG_DIR/projects/-x/$id.jsonl" <<'JSONL'
|
||||||
|
{"type":"user","message":{"role":"user","content":"Please upgrade PBS"},"timestamp":"2026-10-10T10:00:00Z"}
|
||||||
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{}}]},"timestamp":"2026-10-10T10:00:05Z"}
|
||||||
|
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]},"timestamp":"2026-10-10T10:00:06Z"}
|
||||||
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"PBS is on 4.1. Reboot now?"}]},"timestamp":"2026-10-10T10:00:09Z"}
|
||||||
|
JSONL
|
||||||
|
jq --arg id "$id" '.sessions.alpha.claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
|
||||||
|
run devdesk attach --peek alpha
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(jq -r '[.assistant.text, .user.text] | @tsv' <<<"$output")" = "$(printf 'PBS is on 4.1. Reboot now?\tPlease upgrade PBS')" ]
|
||||||
|
# reply: lands in the Claude window as one paste + Enter
|
||||||
|
tmux -S "$DEVDESK_SOCKET" respawn-window -k -t =alpha:claude "IFS= read -r line; printf '%s' \"\$line\" > '$TMP/got'; sleep 30"
|
||||||
|
sleep 0.3
|
||||||
|
printf 'yes, reboot it' >"$TMP/msg"
|
||||||
|
run bash -c "base64 -w0 '$TMP/msg' | '$REPO/bin/devdesk' attach --reply alpha 14"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
sleep 0.5
|
||||||
|
[ "$(cat "$TMP/got")" = "yes, reboot it" ]
|
||||||
|
# not while a permission prompt waits
|
||||||
|
mkdir -p "$DEVDESK_STATE_DIR/state"
|
||||||
|
printf '{"state":"waiting","since":1,"request":{"tool":"Bash"}}\n' >"$DEVDESK_STATE_DIR/state/alpha.json"
|
||||||
|
run bash -c "base64 -w0 '$TMP/msg' | '$REPO/bin/devdesk' attach --reply alpha 14"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"answer that first"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the guard asks before destructive commands and lets the rest through (#27)" {
|
||||||
|
guard() { jq -cn --arg c "$1" '{hook_event_name: "PreToolUse", tool_name: "Bash", tool_input: {command: $c}}' | "$REPO/libexec/devdesk-guard"; }
|
||||||
|
asks=("pct destroy 105" "ssh root@large1 'pct destroy 105 --purge'" "qm destroy 210" "zfs destroy rpool/data/vm-105-disk-0"
|
||||||
|
"zpool destroy tank" "ceph osd pool delete rbd rbd --yes-i-really-really-mean-it" "rbd rm vm-105-disk-0"
|
||||||
|
"rm -rf /" "rm -rf /etc" "sudo rm -r -f /var/" "dd if=/dev/zero of=/dev/sdb bs=1M" "mkfs.ext4 /dev/sdb1" "wipefs -a /dev/sdc"
|
||||||
|
"git push --force origin main" "git push -f" "psql -c 'drop table users'" "systemctl restart devdesk-tmux"
|
||||||
|
"ssh pve1 reboot" "systemctl poweroff" "cd /tmp && shutdown -h now")
|
||||||
|
for c in "${asks[@]}"; do
|
||||||
|
run guard "$c"
|
||||||
|
[ "$(jq -r '.hookSpecificOutput.permissionDecision' <<<"$output")" = ask ] || { echo "should ask: $c"; false; }
|
||||||
|
done
|
||||||
|
passes=("pct list" "pct exec 105 -- df -h" "zfs list" "rm -rf /tmp/build-cache" "rm -rf node_modules" "rm -rf ./dist"
|
||||||
|
"git push origin main" "dd if=a.img of=b.img" "echo reboot later" "systemctl restart devdesk" "ceph -s" "ls -la /")
|
||||||
|
for c in "${passes[@]}"; do
|
||||||
|
run guard "$c"
|
||||||
|
[ -z "$output" ] || { echo "should pass: $c -> $output"; false; }
|
||||||
|
done
|
||||||
|
# Local rules and the off switch
|
||||||
|
printf 'nuke-it\tis a local rule\n' >"$TMP/guard.local"
|
||||||
|
run env DEVDESK_GUARD_LOCAL="$TMP/guard.local" bash -c "jq -cn '{tool_input: {command: \"nuke-it now\"}}' | '$REPO/libexec/devdesk-guard'"
|
||||||
|
[[ $output == *"is a local rule"* ]]
|
||||||
|
run env DEVDESK_GUARD=0 bash -c "jq -cn '{tool_input: {command: \"pct destroy 1\"}}' | '$REPO/libexec/devdesk-guard'"
|
||||||
|
[ -z "$output" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "Bash commands are logged and tag the session with the hosts it works on (#27)" {
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
|
||||||
|
bash_cmd() { jq -cn --arg c "$1" '{hook_event_name: "PostToolUse", tool_name: "Bash", tool_input: {command: $c}}' |
|
||||||
|
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-state-hook"; }
|
||||||
|
bash_cmd "ssh -o BatchMode=yes root@large1 'pct exec 167 -- systemctl status authelia'"
|
||||||
|
bash_cmd "ls -la"
|
||||||
|
bash_cmd "ssh pve2 qm status 210"
|
||||||
|
[ "$(wc -l <"$DEVDESK_STATE_DIR/commands/alpha.jsonl")" -eq 3 ]
|
||||||
|
run devdesk attach --list
|
||||||
|
[ "$(jq -r '.sessions[] | select(.name == "alpha") | .tags | join(",")' <<<"$output")" = "pve2,VM 210,large1,CT 167" ]
|
||||||
|
run devdesk attach --peek alpha
|
||||||
|
[ "$(jq -r '.commands | map(.cmd) | .[1]' <<<"$output")" = "ls -la" ]
|
||||||
|
devdesk rename alpha beta
|
||||||
|
[ -f "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ -f "$DEVDESK_STATE_DIR/tags/beta.json" ]
|
||||||
|
devdesk end beta
|
||||||
|
[ ! -e "$DEVDESK_STATE_DIR/commands/beta.jsonl" ] && [ ! -e "$DEVDESK_STATE_DIR/tags/beta.json" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# A fake Claude history: one conversation in $1 with id $2.
|
||||||
|
fake_convo() {
|
||||||
|
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-p"
|
||||||
|
jq -cn --arg cwd "$1" --arg id "$2" '
|
||||||
|
{type: "user", cwd: $cwd, sessionId: $id, timestamp: "2026-08-01T10:00:00Z", message: {role: "user", content: "Fix the NFS mount on pbs"}},
|
||||||
|
{type: "assistant", timestamp: "2026-08-01T10:00:05Z", message: {role: "assistant", content: [{type: "tool_use", name: "Bash", input: {command: "mount -t nfs 10.10.10.9:/backup /mnt/pbs"}}]}},
|
||||||
|
{type: "assistant", timestamp: "2026-08-01T10:00:09Z", message: {role: "assistant", content: [{type: "text", text: "The NFS export needed no_root_squash."}]}}' \
|
||||||
|
>"$CLAUDE_CONFIG_DIR/projects/-p/$2.jsonl"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "search finds past conversations by what was said and run (#24)" {
|
||||||
|
export CLAUDE_CONFIG_DIR="$TMP/claude"
|
||||||
|
fake_convo "$DEVDESK_PROJECTS_ROOT/alpha" 11111111-1111-1111-1111-111111111111
|
||||||
|
run devdesk attach --search "no_root_squash"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(jq -r '.results[0] | [.id, .title, .cwd, .matches[0].role] | @tsv' <<<"$output")" = \
|
||||||
|
"$(printf '11111111-1111-1111-1111-111111111111\tFix the NFS mount on pbs\t%s\tclaude' "$DEVDESK_PROJECTS_ROOT/alpha")" ]
|
||||||
|
run devdesk attach --search "MOUNT -t NFS"
|
||||||
|
[ "$(jq -r '.results[0].matches[0].role' <<<"$output")" = command ]
|
||||||
|
run devdesk attach --search "nothing like this anywhere"
|
||||||
|
[ "$(jq -r '.results | length' <<<"$output")" = 0 ]
|
||||||
|
run devdesk attach --search "x"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "resume-convo opens a past conversation in its folder; fork is passed once (#24)" {
|
||||||
|
export CLAUDE_CONFIG_DIR="$TMP/claude"
|
||||||
|
id=22222222-2222-2222-2222-222222222222
|
||||||
|
fake_convo "$DEVDESK_PROJECTS_ROOT/alpha" "$id"
|
||||||
|
start_server
|
||||||
|
run devdesk attach --resume-convo nfs-fix "$id" fork
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
claude_args nfs-fix >/dev/null
|
||||||
|
[[ "$(claude_args nfs-fix)" == "--resume $id --fork-session --settings"* ]]
|
||||||
|
[ "$(tmux -S "$DEVDESK_SOCKET" display -p -t =nfs-fix:claude '#{pane_current_path}')" = "$DEVDESK_PROJECTS_ROOT/alpha" ]
|
||||||
|
[ "$(jq -r '.sessions["nfs-fix"].fork // "cleared"' "$DEVDESK_STATE_DIR/registry.json")" = cleared ]
|
||||||
|
# Resuming (not forking) the same conversation while it's open elsewhere is refused.
|
||||||
|
jq --arg id "$id" '.sessions["nfs-fix"].claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
|
||||||
|
run devdesk attach --resume-convo nfs-again "$id"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"open in session nfs-fix"* ]]
|
||||||
|
run devdesk attach --resume-convo nfs-fix "$id" fork
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"already running"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "presence marks a session while a tab shows it (#26)" {
|
||||||
|
export DEVDESK_PRESENCE_DIR="$TMP/presence"
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
claude_args alpha >/dev/null
|
||||||
|
sid=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha: '#{session_id}')
|
||||||
|
run devdesk attach --presence alpha on
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ -e "$TMP/presence/${sid#\$}" ]
|
||||||
|
# The Claude window was told where the marker lives.
|
||||||
|
[ "$(tmux -S "$DEVDESK_SOCKET" show-environment -t =alpha CLAUDE_CLIENT_PRESENCE_FILE 2>/dev/null || true)" = "" ] # set per process, not per session
|
||||||
|
run devdesk attach --presence alpha off
|
||||||
|
[ ! -e "$TMP/presence/${sid#\$}" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# A fake Proxmox cluster for `devdesk guests`: DEVDESK_PVE_SSH answers the
|
||||||
|
# inventory (pvesh) from $FAKE_PVE/inventory and each node's scan from node-<name>.
|
||||||
|
fake_pve() {
|
||||||
|
export FAKE_PVE="$TMP/pve" DEVDESK_PVE_SSH="$TMP/fake-ssh" DEVDESK_PVE_NODES="down-node large1"
|
||||||
|
mkdir -p "$FAKE_PVE"
|
||||||
|
cat >"$DEVDESK_PVE_SSH" <<'EOF'
|
||||||
|
#!/bin/bash
|
||||||
|
while [ "${1:-}" = -o ]; do shift 2; done
|
||||||
|
case $1:$2 in
|
||||||
|
down-node:*) exit 255 ;;
|
||||||
|
*:*pvesh*) cat "$FAKE_PVE/inventory" ;;
|
||||||
|
*) cat "$FAKE_PVE/node-$1" 2>/dev/null || exit 255 ;;
|
||||||
|
esac
|
||||||
|
EOF
|
||||||
|
chmod +x "$DEVDESK_PVE_SSH"
|
||||||
|
pve_state running
|
||||||
|
printf 'large1\t10.10.10.2 10.10.90.10 fe80::1\n139\t10.10.10.27 \n102\t127.0.0.1 10.10.10.23 \n' >"$FAKE_PVE/node-large1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Draupnir (CT 110) running on monitor-02, or stopped (and so absent from the node scan).
|
||||||
|
pve_state() {
|
||||||
|
printf '%s\n' \
|
||||||
|
'[{"vmid":110,"name":"draupnir","node":"monitor-02","status":"'"$1"'","type":"lxc","hastate":"'"${1/running/started}"'"},' \
|
||||||
|
' {"vmid":139,"name":"nginxproxymanager","node":"large1","status":"running","type":"lxc","hastate":"started"},' \
|
||||||
|
' {"vmid":102,"name":"homeAssistant","node":"large1","status":"running","type":"qemu"}]' \
|
||||||
|
'[{"id":"cluster","type":"cluster"},{"name":"large1","ip":"10.10.90.10","online":1,"type":"node"},' \
|
||||||
|
' {"name":"monitor-02","ip":"10.10.10.9","online":1,"type":"node"}]' >"$FAKE_PVE/inventory"
|
||||||
|
if [ "$1" = running ]; then
|
||||||
|
printf 'monitor-02\t10.10.10.9\n110\t10.10.10.24\n' >"$FAKE_PVE/node-monitor-02"
|
||||||
|
else
|
||||||
|
printf 'monitor-02\t10.10.10.9\n' >"$FAKE_PVE/node-monitor-02"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Grafana's rules API, served from a file.
|
||||||
|
fake_grafana() {
|
||||||
|
export DEVDESK_GRAFANA_URL="file://$TMP/grafana" DEVDESK_GRAFANA_PUBLIC_URL=https://grafana.example
|
||||||
|
mkdir -p "$TMP/grafana/api/prometheus/grafana/api/v1"
|
||||||
|
jq -n '{data: {groups: [{name: "Bots", file: "Matrix", rules: [
|
||||||
|
{uid: "u-draupnir", name: "Draupnir Down", state: "firing", query: "up{job=\"draupnir-node\"}", labels: {severity: "critical"},
|
||||||
|
annotations: {summary: "Draupnir is unreachable", description: "Cannot scrape 10.10.10.24:9100"},
|
||||||
|
alerts: [{state: "Alerting", activeAt: "2026-10-11T07:10:00Z", value: "0", labels: {instance: "10.10.10.24:9100", job: "draupnir-node", alertname: "Draupnir Down"}}]},
|
||||||
|
{uid: "u-ceph", name: "Ceph Unhealthy", state: "firing", query: "ceph_health_status", labels: {severity: "warning"},
|
||||||
|
annotations: {summary: "status={{ $value }}"},
|
||||||
|
alerts: [{state: "Alerting", value: "1e+00", labels: {instance: "http://10.10.10.2:9283/metrics", job: "ceph"}}]},
|
||||||
|
{uid: "u-count", name: "Exporter Down", state: "firing", query: "count(up == 0)", labels: {},
|
||||||
|
annotations: {summary: "{{ $value }} down"}, alerts: [{state: "Alerting", value: "2", labels: {alertname: "Exporter Down"}}]},
|
||||||
|
{uid: "u-lan", name: "Printer Offline", state: "pending", query: "up", labels: {},
|
||||||
|
annotations: {}, alerts: [{state: "Pending", value: "0", labels: {instance: "10.99.0.1:9100"}}]},
|
||||||
|
{uid: "u-ok", name: "Fine", state: "inactive", alerts: []}]}]}}' >"$TMP/grafana/api/prometheus/grafana/api/v1/rules"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "guests: where each CT/VM runs, from any node that answers; stopped ones keep their last IP (#39)" {
|
||||||
|
fake_pve
|
||||||
|
run devdesk guests
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ $output == *"110"*"draupnir"*"monitor-02"*"running"*"started"*"10.10.10.24"* ]]
|
||||||
|
[[ $output == *"102"*"vm"*"homeAssistant"*"large1"*"10.10.10.23"* ]]
|
||||||
|
[[ $output != *"127.0.0.1"* ]]
|
||||||
|
run devdesk guests --json 10.10.10.27
|
||||||
|
[ "$(jq -r '[.guests[].id] | join(",")' <<<"$output")" = 139 ]
|
||||||
|
[ "$(jq -r '.nodes[] | select(.name == "large1") | .ips | join(",")' <<<"$output")" = 10.10.10.2,10.10.90.10 ]
|
||||||
|
# Draupnir stops: its IP is remembered, marked as last seen.
|
||||||
|
pve_state stopped
|
||||||
|
run devdesk guests drau
|
||||||
|
[[ $output == *"stopped"*"10.10.10.24 (last seen)"* ]]
|
||||||
|
# No node answers: the cache, with a warning.
|
||||||
|
run env DEVDESK_PVE_NODES=down-node devdesk guests draupnir
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[[ $output == *"no Proxmox node answered"*"10.10.10.24"* ]]
|
||||||
|
run env DEVDESK_PVE_NODES= devdesk guests
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"DEVDESK_PVE_NODES"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "alerts name the guest or node each one is about (#39)" {
|
||||||
|
fake_pve
|
||||||
|
fake_grafana
|
||||||
|
devdesk guests >/dev/null
|
||||||
|
pve_state stopped
|
||||||
|
devdesk guests >/dev/null
|
||||||
|
run devdesk alerts
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
t() { jq -c --arg u "$1" '.alerts[] | select(.uid == $u) | .targets | map({kind, id, name, node, by})' <<<"$output"; }
|
||||||
|
[ "$(t u-draupnir)" = '[{"kind":"ct","id":110,"name":"draupnir","node":"monitor-02","by":"ip"}]' ]
|
||||||
|
[ "$(t u-ceph)" = '[{"kind":"node","id":null,"name":"large1","node":"large1","by":"ip"}]' ]
|
||||||
|
[ "$(t u-count)" = '[]' ]
|
||||||
|
[ "$(t u-lan)" = '[{"kind":"unknown","id":null,"name":null,"node":null,"by":null}]' ]
|
||||||
|
[ "$(jq -r '.alerts[] | select(.uid == "u-ceph") | .summary' <<<"$output")" = "status=1" ]
|
||||||
|
[ "$(jq -r '[.alerts[].uid] | join(",")' <<<"$output")" = u-draupnir,u-ceph,u-count,u-lan ]
|
||||||
|
# Never seen running: matched by the name in its job label instead.
|
||||||
|
rm "$DEVDESK_STATE_DIR/guests.json"
|
||||||
|
devdesk guests >/dev/null
|
||||||
|
run devdesk alerts
|
||||||
|
[ "$(t u-draupnir)" = '[{"kind":"ct","id":110,"name":"draupnir","node":"monitor-02","by":"name"}]' ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "investigate starts Claude in its own session with the alert and where it runs (#39)" {
|
||||||
|
fake_pve
|
||||||
|
fake_grafana
|
||||||
|
devdesk guests >/dev/null
|
||||||
|
start_server
|
||||||
|
run devdesk attach --investigate alert-draupnir-down u-draupnir
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(jq -c . <<<"$output")" = '{"ok":true,"session":"alert-draupnir-down","started":true}' ]
|
||||||
|
claude_args alert-draupnir-down >/dev/null
|
||||||
|
log="$FAKE_LOG/alert-draupnir-down"
|
||||||
|
grep -q '^-n alert-draupnir-down Investigate this Grafana alert' "$log"
|
||||||
|
grep -q 'Alert: Draupnir Down (critical, firing since' "$log"
|
||||||
|
grep -q 'Query: `up{job="draupnir-node"}`' "$log"
|
||||||
|
grep -q '10.10.10.24 is CT 110 "draupnir" on node monitor-02, running, HA state started; commands inside it: `ssh monitor-02 pct exec 110 -- <command>`' "$log"
|
||||||
|
grep -q '`devdesk guests 110` shows where it is now' "$log"
|
||||||
|
# The prompt is sent once: not again when Claude is resumed.
|
||||||
|
[ ! -e "$DEVDESK_STATE_DIR/prompts/alert-draupnir-down.txt" ]
|
||||||
|
run devdesk attach --investigate alert-draupnir-down u-draupnir
|
||||||
|
[ "$(jq -r .started <<<"$output")" = false ]
|
||||||
|
# Aggregates get a hint instead; alerts that resolved meanwhile are refused.
|
||||||
|
run devdesk attach --investigate exporters u-count
|
||||||
|
claude_args exporters >/dev/null
|
||||||
|
grep -q 'The rule aggregates' "$FAKE_LOG/exporters"
|
||||||
|
run grep -c 'Where it runs' "$FAKE_LOG/exporters"
|
||||||
|
[ "$output" = 0 ]
|
||||||
|
run devdesk attach --investigate gone u-ok
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"no longer firing"* ]]
|
||||||
|
run devdesk attach --investigate 'bad name' u-ok
|
||||||
|
[[ $output == *"invalid session name"* ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "last-reply returns every text part of the answer to your last message (#47)" {
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
export CLAUDE_CONFIG_DIR="$TMP/claude"
|
||||||
|
id=11111111-2222-3333-4444-555555555555
|
||||||
|
mkdir -p "$CLAUDE_CONFIG_DIR/projects/-x"
|
||||||
|
cat >"$CLAUDE_CONFIG_DIR/projects/-x/$id.jsonl" <<'JSONL'
|
||||||
|
{"type":"user","message":{"role":"user","content":"First question"},"timestamp":"2026-10-10T09:00:00Z"}
|
||||||
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Old answer"}]},"timestamp":"2026-10-10T09:00:01Z"}
|
||||||
|
{"type":"user","message":{"role":"user","content":"Please upgrade PBS"},"timestamp":"2026-10-10T10:00:00Z"}
|
||||||
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"Checking the version."}]},"timestamp":"2026-10-10T10:00:04Z"}
|
||||||
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Bash","input":{}}]},"timestamp":"2026-10-10T10:00:05Z"}
|
||||||
|
{"type":"user","message":{"role":"user","content":[{"type":"tool_result","content":"ok"}]},"timestamp":"2026-10-10T10:00:06Z"}
|
||||||
|
{"type":"assistant","message":{"role":"assistant","content":[{"type":"text","text":"## Done\n\nPBS is on **4.1**."}]},"timestamp":"2026-10-10T10:00:09Z"}
|
||||||
|
JSONL
|
||||||
|
run devdesk attach --last-reply alpha
|
||||||
|
[ "$status" -ne 0 ] # no conversation recorded yet
|
||||||
|
jq --arg id "$id" '.sessions.alpha.claude_session_id = $id' "$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
|
||||||
|
run devdesk attach --last-reply alpha
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(jq -r .text <<<"$output")" = "$(printf 'Checking the version.\n\n## Done\n\nPBS is on **4.1**.')" ]
|
||||||
|
[ "$(jq -r .at <<<"$output")" = 2026-10-10T10:00:09Z ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "worktree sessions get their own checkout; ending removes it only when clean (#44)" {
|
||||||
|
start_server
|
||||||
|
repo="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
git -C "$repo" init -q -b main
|
||||||
|
git -C "$repo" -c user.name=t -c user.email=t@t commit -q --allow-empty -m init
|
||||||
|
mkdir -p "$DEVDESK_PROJECTS_ROOT/plain"
|
||||||
|
run devdesk new solo --worktree
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
run devdesk new p1 plain --worktree
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
[[ $output == *"not a git repository"* ]]
|
||||||
|
|
||||||
|
devdesk new wt1 alpha --worktree
|
||||||
|
wt="$DEVDESK_PROJECTS_ROOT/.worktrees/alpha--wt1"
|
||||||
|
[ "$(git -C "$wt" symbolic-ref --short HEAD)" = devdesk/wt1 ]
|
||||||
|
run tmux -S "$DEVDESK_SOCKET" display -p -t =wt1:claude '#{pane_current_path}'
|
||||||
|
[ "$output" = "$wt" ]
|
||||||
|
run devdesk attach --list
|
||||||
|
[ "$(jq -r '.sessions[] | select(.name=="wt1") | .worktree.branch' <<<"$output")" = devdesk/wt1 ]
|
||||||
|
[ "$(jq -r '.projects[] | select(.name=="alpha") | .git' <<<"$output")" = true ]
|
||||||
|
[ "$(jq -r '.projects[] | select(.name=="plain") | .git' <<<"$output")" = false ]
|
||||||
|
[ "$(jq -r '[.projects[].name] | index(".worktrees")' <<<"$output")" = null ]
|
||||||
|
# clean and merged: worktree and branch go
|
||||||
|
devdesk end wt1
|
||||||
|
[ ! -e "$wt" ]
|
||||||
|
run git -C "$repo" show-ref --verify refs/heads/devdesk/wt1
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
|
||||||
|
# uncommitted work: everything stays
|
||||||
|
devdesk new wt2 alpha --worktree
|
||||||
|
wt="$DEVDESK_PROJECTS_ROOT/.worktrees/alpha--wt2"
|
||||||
|
echo work >"$wt/file"
|
||||||
|
devdesk end wt2
|
||||||
|
[ -f "$wt/file" ]
|
||||||
|
git -C "$repo" show-ref -q --verify refs/heads/devdesk/wt2
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "repo lists the first changed files for the panel (#46)" {
|
||||||
|
export DEVDESK_GITEA_URL=http://127.0.0.1:9
|
||||||
|
repo="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
git -C "$repo" init -q -b main
|
||||||
|
printf 'a\n' >"$repo/tracked"
|
||||||
|
git -C "$repo" add tracked
|
||||||
|
git -C "$repo" -c user.name=t -c user.email=t@t commit -q -m init
|
||||||
|
printf 'a\nb\n' >"$repo/tracked"
|
||||||
|
touch "$repo/new-file"
|
||||||
|
run devdesk attach --repo alpha
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(jq -c '[.changes[] | [.path, .x, .y, .add]]' <<<"$output")" = '[["tracked"," ","M",1],["new-file","?","?",null]]' ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "plan usage past the threshold pings Matrix once per window, only when away (#45)" {
|
||||||
|
start_server
|
||||||
|
start_receiver
|
||||||
|
devdesk new alpha
|
||||||
|
pane=$(tmux -S "$DEVDESK_SOCKET" display -p -t =alpha:shell '#{pane_id}')
|
||||||
|
sl() { echo "{\"model\":{\"display_name\":\"Opus\"},\"context_window\":{},\"rate_limits\":{\"five_hour\":{\"used_percentage\":$1,\"resets_at\":$2}}}" |
|
||||||
|
TMUX="$DEVDESK_SOCKET,1,0" TMUX_PANE="$pane" "$REPO/libexec/devdesk-statusline" >/dev/null; }
|
||||||
|
sl 50 2000000000
|
||||||
|
sl 91 2000000000
|
||||||
|
sl 93 2000000000
|
||||||
|
for _ in $(seq 1 30); do [ "$(posts)" -ge 1 ] && break; sleep 0.1; done
|
||||||
|
sleep 0.5
|
||||||
|
[ "$(posts)" -eq 1 ]
|
||||||
|
[[ "$(jq -r .text "$TMP/posts")" == *"91% of the 5-hour window"* ]]
|
||||||
|
# next window, but a DevDesk tab is in view: no post, and that window is done
|
||||||
|
mkdir -p "$DEVDESK_PRESENCE_DIR" && touch "$DEVDESK_PRESENCE_DIR/1"
|
||||||
|
sl 95 2000018000
|
||||||
|
for _ in $(seq 1 30); do [ "$(cat "$DEVDESK_STATE_DIR/usage-warned")" = 2000018000 ] && break; sleep 0.1; done
|
||||||
|
sleep 0.5
|
||||||
|
[ "$(posts)" -eq 1 ]
|
||||||
|
[ "$(cat "$DEVDESK_STATE_DIR/usage-warned")" = 2000018000 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "unsplit closes extra panes and keeps Claude's (#52)" {
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
tmux -S "$DEVDESK_SOCKET" split-window -h -t =alpha:claude
|
||||||
|
tmux -S "$DEVDESK_SOCKET" split-window -v -t =alpha:shell
|
||||||
|
run devdesk attach --unsplit alpha
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(jq -r .closed <<<"$output")" = 2 ]
|
||||||
|
[ "$(tmux -S "$DEVDESK_SOCKET" list-panes -t =alpha:claude -F '#{pane_start_command}')" = "$REPO/libexec/devdesk-claude-run" ]
|
||||||
|
[ "$(tmux -S "$DEVDESK_SOCKET" list-panes -t =alpha:shell | wc -l)" = 1 ]
|
||||||
|
run devdesk attach --unsplit alpha
|
||||||
|
[ "$(jq -r .closed <<<"$output")" = 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
fs_put() { # SESSION PATH MODE CONTENT-FILE
|
||||||
|
local size
|
||||||
|
size=$(stat -c %s "$4")
|
||||||
|
base64 -w0 "$4" | devdesk attach --fs-put "$1" "$2" "$size" "$3"
|
||||||
|
}
|
||||||
|
last_json() { tail -n1 <<<"$output"; }
|
||||||
|
|
||||||
|
@test "fs-mkdir, fs-move and fs-copy stay inside the folder and never overwrite (#54)" {
|
||||||
|
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
mkdir -p "$p/src" && printf 'a\n' >"$p/src/a.txt" && printf 'b\n' >"$p/b.txt"
|
||||||
|
ln -s "$TMP/outside" "$p/out"
|
||||||
|
ln -s b.txt "$p/link"
|
||||||
|
run devdesk attach --fs-mkdir alpha docs/guides
|
||||||
|
[ "$status" -eq 0 ] && [ -d "$p/docs/guides" ]
|
||||||
|
[ "$(last_json | jq -r .path)" = docs/guides ]
|
||||||
|
run devdesk attach --fs-mkdir alpha docs
|
||||||
|
[[ $output == *"already exists"* ]]
|
||||||
|
for bad in ../x /abs out/x 'a/../b' . '' docs/; do
|
||||||
|
run devdesk attach --fs-mkdir alpha "$bad"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
done
|
||||||
|
[ -z "$(ls -A "$TMP/outside")" ]
|
||||||
|
# out/x once escaped to /x: set -e doesn't reach into $(...) (#54)
|
||||||
|
[ ! -e /x ] && [ ! -e /abs ]
|
||||||
|
|
||||||
|
# rename a file, move a folder into another, never onto something
|
||||||
|
run devdesk attach --fs-move alpha src/a.txt src/renamed.txt
|
||||||
|
[ "$status" -eq 0 ] && [ -f "$p/src/renamed.txt" ] && [ ! -e "$p/src/a.txt" ]
|
||||||
|
run devdesk attach --fs-move alpha b.txt src/renamed.txt
|
||||||
|
[ "$status" -ne 0 ] && [[ $output == *"already exists"* ]]
|
||||||
|
[ "$(cat "$p/b.txt")" = b ]
|
||||||
|
run devdesk attach --fs-move alpha src docs/src
|
||||||
|
[ "$status" -eq 0 ] && [ -f "$p/docs/src/renamed.txt" ]
|
||||||
|
run devdesk attach --fs-move alpha docs docs/guides/docs
|
||||||
|
[ "$status" -ne 0 ] && [[ $output == *"inside itself"* ]]
|
||||||
|
run devdesk attach --fs-move alpha b.txt out/b.txt
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
run devdesk attach --fs-move alpha ../outside x
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
# a symlink moves as a link
|
||||||
|
run devdesk attach --fs-move alpha link link2
|
||||||
|
[ "$status" -eq 0 ] && [ -L "$p/link2" ] && [ -f "$p/b.txt" ]
|
||||||
|
# copy: folders recursively, missing parents created
|
||||||
|
run devdesk attach --fs-copy alpha docs/src new/place/src
|
||||||
|
[ "$status" -eq 0 ] && [ -f "$p/new/place/src/renamed.txt" ] && [ -f "$p/docs/src/renamed.txt" ]
|
||||||
|
run devdesk attach --fs-copy alpha b.txt docs/src/renamed.txt
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "fs-delete moves to the trash and fs-restore puts it back (#54)" {
|
||||||
|
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
mkdir -p "$p/dir/sub" && printf 'x\n' >"$p/dir/sub/x" && printf 'y\n' >"$p/y.txt"
|
||||||
|
ln -s "$TMP/outside" "$p/out"
|
||||||
|
touch "$TMP/outside/keep"
|
||||||
|
run devdesk attach --fs-delete alpha dir y.txt out
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
id=$(last_json | jq -r .trash)
|
||||||
|
[ "$(last_json | jq -c .paths)" = '["dir","y.txt","out"]' ]
|
||||||
|
[ ! -e "$p/dir" ] && [ ! -e "$p/y.txt" ] && [ ! -L "$p/out" ] && [ -f "$TMP/outside/keep" ]
|
||||||
|
# nothing deleted when one path is bad
|
||||||
|
printf 'z\n' >"$p/z.txt"
|
||||||
|
run devdesk attach --fs-delete alpha z.txt ../outside
|
||||||
|
[ "$status" -ne 0 ] && [ -f "$p/z.txt" ]
|
||||||
|
run devdesk attach --fs-delete alpha .
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
# restore; a path taken meanwhile is refused
|
||||||
|
printf 'new\n' >"$p/y.txt"
|
||||||
|
run devdesk attach --fs-restore alpha "$id"
|
||||||
|
[ "$status" -ne 0 ] && [[ $output == *"already exists: y.txt"* ]]
|
||||||
|
rm "$p/y.txt"
|
||||||
|
run devdesk attach --fs-restore alpha "$id"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(cat "$p/dir/sub/x" "$p/y.txt")" = "$(printf 'x\ny')" ] && [ -L "$p/out" ]
|
||||||
|
[ ! -e "$DEVDESK_STATE_DIR/trash/alpha/$id" ]
|
||||||
|
run devdesk attach --fs-restore alpha "../../etc"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "fs-put uploads any file into a folder; fs-download returns it (#54)" {
|
||||||
|
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
head -c 3000 /dev/urandom >"$TMP/bin"
|
||||||
|
run fs_put alpha assets/img/logo.bin new "$TMP/bin"
|
||||||
|
[ "$status" -eq 0 ] && cmp -s "$TMP/bin" "$p/assets/img/logo.bin"
|
||||||
|
[ "$(stat -c %a "$p/assets/img/logo.bin")" = 644 ]
|
||||||
|
run fs_put alpha assets/img/logo.bin new "$TMP/bin"
|
||||||
|
[ "$status" -ne 0 ] && [[ $output == *"already exists"* ]]
|
||||||
|
printf 'v2' >"$TMP/v2"
|
||||||
|
chmod 600 "$p/assets/img/logo.bin"
|
||||||
|
run fs_put alpha assets/img/logo.bin replace "$TMP/v2"
|
||||||
|
[ "$status" -eq 0 ] && [ "$(cat "$p/assets/img/logo.bin")" = v2 ] && [ "$(stat -c %a "$p/assets/img/logo.bin")" = 600 ]
|
||||||
|
: >"$TMP/empty"
|
||||||
|
run fs_put alpha empty.txt new "$TMP/empty"
|
||||||
|
[ "$status" -eq 0 ] && [ -f "$p/empty.txt" ] && [ ! -s "$p/empty.txt" ]
|
||||||
|
ln -s "$TMP/outside" "$p/out"
|
||||||
|
for bad in out/x ../x /x assets; do
|
||||||
|
run fs_put alpha "$bad" new "$TMP/v2"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
done
|
||||||
|
[ -z "$(ls -A "$TMP/outside")" ]
|
||||||
|
run fs_put alpha assets replace "$TMP/v2"
|
||||||
|
[ "$status" -ne 0 ] && [ -d "$p/assets" ]
|
||||||
|
# short stream: nothing left behind
|
||||||
|
run bash -c "printf aGVs | '$REPO/bin/devdesk' attach --fs-put alpha short.txt 6 new"
|
||||||
|
[ "$status" -ne 0 ] && [ ! -e "$p/short.txt" ] && [ -z "$(find "$p" -name '.*devdesk-*')" ]
|
||||||
|
# download: binary body comes back whole
|
||||||
|
run devdesk attach --fs-download alpha assets/img/logo.bin
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ "$(sed -n '/DEVDESK-BLOB-BEGIN/{n;p}' <<<"$output" | base64 -d)" = v2 ]
|
||||||
|
head -c 3000 /dev/urandom >"$p/rand"
|
||||||
|
run devdesk attach --fs-download alpha rand
|
||||||
|
sed -n '/DEVDESK-BLOB-BEGIN/{n;p}' <<<"$output" | base64 -d | cmp -s - "$p/rand"
|
||||||
|
run devdesk attach --fs-download alpha out
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "fs-grep searches files, literally or by regex, git or not (#56)" {
|
||||||
|
p="$DEVDESK_PROJECTS_ROOT/alpha"
|
||||||
|
mkdir -p "$p/src" "$p/node_modules/x"
|
||||||
|
printf 'const Answer = 42;\nlet b = "a:b";\n' >"$p/src/a.js"
|
||||||
|
printf 'answer here\n' >"$p/node_modules/x/i.js"
|
||||||
|
printf '\x00\x01answer' >"$p/bin.dat"
|
||||||
|
run devdesk attach --fs-grep alpha - 'Answer'
|
||||||
|
[ "$(jq -c '[.matches[] | [.p, .l, .t]]' <<<"$output")" = '[["src/a.js",1,"const Answer = 42;"]]' ]
|
||||||
|
run devdesk attach --fs-grep alpha i 'answer'
|
||||||
|
[ "$(jq -r '[.matches[].p] | join(",")' <<<"$output")" = src/a.js ]
|
||||||
|
run devdesk attach --fs-grep alpha - '"a:b"'
|
||||||
|
[ "$(jq -r '.matches[0].t' <<<"$output")" = 'let b = "a:b";' ]
|
||||||
|
run devdesk attach --fs-grep alpha r 'A[n]swer = [0-9]+'
|
||||||
|
[ "$(jq -r '.matches | length' <<<"$output")" = 1 ]
|
||||||
|
run devdesk attach --fs-grep alpha - '[n]'
|
||||||
|
[ "$(jq -r '.matches | length' <<<"$output")" = 0 ]
|
||||||
|
# in a repository: git grep, .gitignore honoured, untracked files included
|
||||||
|
git -C "$p" init -q
|
||||||
|
printf 'node_modules/\n' >"$p/.gitignore"
|
||||||
|
printf 'Answer again\n' >"$p/untracked.txt"
|
||||||
|
run devdesk attach --fs-grep alpha w 'Answer'
|
||||||
|
[ "$(jq -c '[.matches[] | [.p, .l]] | sort' <<<"$output")" = '[["src/a.js",1],["untracked.txt",1]]' ]
|
||||||
|
run devdesk attach --fs-grep alpha x 'Answer'
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
run devdesk attach --fs-grep alpha - ''
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "Remote Control is per session: Settings default, live state, restart when idle (#61)" {
|
||||||
|
start_server
|
||||||
|
devdesk new alpha
|
||||||
|
run claude_args alpha
|
||||||
|
[[ $output != *remote-control* ]]
|
||||||
|
run devdesk attach --list
|
||||||
|
[ "$(jq -c '[.remote_control_default, (.sessions[] | select(.name=="alpha") | .remote_control, .rc_active)]' <<<"$output")" = '[false,false,false]' ]
|
||||||
|
|
||||||
|
# Default on for new sessions
|
||||||
|
run devdesk attach --settings remote-control-default on
|
||||||
|
[ "$(jq -r .remote_control_default <<<"$output")" = true ]
|
||||||
|
devdesk new beta
|
||||||
|
run claude_args beta
|
||||||
|
[[ $output == *"--remote-control devdesk-beta"* ]]
|
||||||
|
for _ in $(seq 1 30); do
|
||||||
|
[ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="beta") | .rc_active')" = true ] && break
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
[ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="beta") | .rc_active')" = true ]
|
||||||
|
|
||||||
|
# alpha (idle): switching restarts Claude with the flag, no exit menu
|
||||||
|
run devdesk attach --remote-control alpha on
|
||||||
|
[ "$(jq -r .applied <<<"$output")" = now ]
|
||||||
|
for _ in $(seq 1 50); do [ "$(wc -l <"$FAKE_LOG/alpha")" -ge 2 ] && break; sleep 0.1; done
|
||||||
|
[[ "$(tail -n1 "$FAKE_LOG/alpha")" == *"--remote-control devdesk-alpha"* ]]
|
||||||
|
[ ! -e "$DEVDESK_STATE_DIR/restart/alpha" ]
|
||||||
|
|
||||||
|
# working: saved for the next start, Claude untouched
|
||||||
|
mkdir -p "$DEVDESK_STATE_DIR/state"
|
||||||
|
printf '{"state":"working","since":1}' >"$DEVDESK_STATE_DIR/state/alpha.json"
|
||||||
|
run devdesk attach --remote-control alpha off
|
||||||
|
[ "$(jq -r .applied <<<"$output")" = next-start ]
|
||||||
|
sleep 0.5
|
||||||
|
[ "$(wc -l <"$FAKE_LOG/alpha")" -eq 2 ]
|
||||||
|
[ "$(jq -r .sessions.alpha.remote_control "$DEVDESK_STATE_DIR/registry.json")" = false ]
|
||||||
|
# an explicit off beats the default on
|
||||||
|
[ "$(devdesk attach --list | jq -r '.sessions[] | select(.name=="alpha") | .remote_control')" = false ]
|
||||||
|
|
||||||
|
run devdesk attach --remote-control nope on
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
run devdesk attach --remote-control alpha maybe
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "delete-convo removes a conversation's local files, never one in use (#61)" {
|
||||||
|
start_server
|
||||||
|
export CLAUDE_CONFIG_DIR="$TMP/claude"
|
||||||
|
id=11111111-2222-3333-4444-555555555555
|
||||||
|
other=99999999-2222-3333-4444-555555555555
|
||||||
|
d="$CLAUDE_CONFIG_DIR/projects/-x"
|
||||||
|
mkdir -p "$d/$id/subagents" "$d/$other"
|
||||||
|
echo '{}' >"$d/$id.jsonl" && echo '{}' >"$d/$id/subagents/a.jsonl" && echo '{}' >"$d/$other.jsonl"
|
||||||
|
devdesk new alpha
|
||||||
|
jq --arg id "$id" '.sessions.alpha.claude_session_id = $id | .sessions.dormant = {dir: "/tmp", claude_session_id: $id}' \
|
||||||
|
"$DEVDESK_STATE_DIR/registry.json" >"$TMP/r" && mv "$TMP/r" "$DEVDESK_STATE_DIR/registry.json"
|
||||||
|
run devdesk attach --delete-convo "$id"
|
||||||
|
[ "$status" -ne 0 ] && [[ $output == *"session alpha is using"* ]] && [ -f "$d/$id.jsonl" ]
|
||||||
|
devdesk end alpha
|
||||||
|
run devdesk attach --delete-convo "$id"
|
||||||
|
[ "$status" -eq 0 ]
|
||||||
|
[ ! -e "$d/$id.jsonl" ] && [ ! -e "$d/$id" ] && [ -f "$d/$other.jsonl" ]
|
||||||
|
[ "$(jq -r '.sessions.dormant.claude_session_id // "none"' "$DEVDESK_STATE_DIR/registry.json")" = none ]
|
||||||
|
run devdesk attach --delete-convo "$id"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
run devdesk attach --delete-convo "../../etc/passwd"
|
||||||
|
[ "$status" -ne 0 ]
|
||||||
|
}
|
||||||
|
|||||||
@@ -86,3 +86,15 @@ test('untrusted text loses control characters before it is typed (#2)', () => {
|
|||||||
assert.equal(pasteSafe('plain text'), 'plain text');
|
assert.equal(pasteSafe('plain text'), 'plain text');
|
||||||
assert.equal(pasteSafe(null), '');
|
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']);
|
||||||
|
});
|
||||||
|
|||||||
+192
-15
@@ -2,11 +2,16 @@
|
|||||||
// languages, a unified diff view, and markdown rendering. Built separately to
|
// languages, a unified diff view, and markdown rendering. Built separately to
|
||||||
// dist/assets/editor.js (window.DevDeskEditor) and loaded over the ttyd channel
|
// 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.
|
// the first time the Files pane opens, so the page itself stays small.
|
||||||
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars } from '@codemirror/view';
|
import {
|
||||||
|
EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, highlightSpecialChars,
|
||||||
|
rectangularSelection, crosshairCursor, dropCursor,
|
||||||
|
} from '@codemirror/view';
|
||||||
import { EditorState } from '@codemirror/state';
|
import { EditorState } from '@codemirror/state';
|
||||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
|
||||||
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
|
import { syntaxHighlighting, HighlightStyle, StreamLanguage, bracketMatching, foldGutter, foldKeymap, indentOnInput } from '@codemirror/language';
|
||||||
import { search, searchKeymap, highlightSelectionMatches } from '@codemirror/search';
|
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 { markdownLanguage } from '@codemirror/lang-markdown';
|
||||||
import { unifiedMergeView } from '@codemirror/merge';
|
import { unifiedMergeView } from '@codemirror/merge';
|
||||||
import { tags as t } from '@lezer/highlight';
|
import { tags as t } from '@lezer/highlight';
|
||||||
@@ -27,7 +32,7 @@ import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile';
|
|||||||
import { lua } from '@codemirror/legacy-modes/mode/lua';
|
import { lua } from '@codemirror/legacy-modes/mode/lua';
|
||||||
import { perl } from '@codemirror/legacy-modes/mode/perl';
|
import { perl } from '@codemirror/legacy-modes/mode/perl';
|
||||||
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
|
import { ruby } from '@codemirror/legacy-modes/mode/ruby';
|
||||||
import { marked } from 'marked';
|
import { Marked } from 'marked';
|
||||||
import DOMPurify from 'dompurify';
|
import DOMPurify from 'dompurify';
|
||||||
|
|
||||||
const MODES = {
|
const MODES = {
|
||||||
@@ -121,24 +126,100 @@ const theme = EditorView.theme({
|
|||||||
'.cm-deletedLineGutter': { backgroundColor: 'var(--accent-red)' },
|
'.cm-deletedLineGutter': { backgroundColor: 'var(--accent-red)' },
|
||||||
});
|
});
|
||||||
|
|
||||||
function baseExtensions(path, { readOnly = true, wrap = false } = {}) {
|
function baseExtensions(path, { readOnly = true, wrap = false, onCursor } = {}) {
|
||||||
return [
|
return [
|
||||||
lineNumbers(), foldGutter(), highlightSpecialChars(), highlightActiveLine(), highlightActiveLineGutter(),
|
lineNumbers(), foldGutter(), highlightSpecialChars(), highlightActiveLine(), highlightActiveLineGutter(),
|
||||||
drawSelection(), history(), search({ top: true }), highlightSelectionMatches(), bracketMatching(),
|
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)),
|
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
|
// 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.
|
// and Ctrl+F work, typing doesn't; no on-screen keyboard on phones.
|
||||||
EditorState.readOnly.of(readOnly),
|
EditorState.readOnly.of(readOnly),
|
||||||
readOnly ? EditorView.contentAttributes.of({ inputmode: 'none' }) : [],
|
readOnly ? EditorView.contentAttributes.of({ inputmode: 'none' }) : [],
|
||||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap]),
|
keymap.of([
|
||||||
|
{ key: 'Mod-g', run: gotoLineDialog, preventDefault: true },
|
||||||
|
...(readOnly ? [] : closeBracketsKeymap), ...defaultKeymap, ...historyKeymap, ...searchKeymap, ...foldKeymap,
|
||||||
|
]),
|
||||||
wrap ? EditorView.lineWrapping : [],
|
wrap ? EditorView.lineWrapping : [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read-only (v1) view of a file.
|
// Where the cursor is, for the status bar: line, column, selections, selected
|
||||||
export function viewer(parent, { doc, path, wrap }) {
|
// characters; plus the document's line endings and indentation.
|
||||||
const view = new EditorView({ parent, state: EditorState.create({ doc, extensions: baseExtensions(path, { wrap }) }) });
|
function cursorInfo(state) {
|
||||||
return { view, destroy: () => view.destroy() };
|
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.
|
// Unified diff of the HEAD version against the working copy.
|
||||||
@@ -153,7 +234,103 @@ export function diff(parent, { original, modified, path, wrap }) {
|
|||||||
return { view, destroy: () => view.destroy() };
|
return { view, destroy: () => view.destroy() };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Markdown to sanitized HTML (shown in a sandboxed iframe on top of this).
|
// ---------------------------------------------------------------- markdown (devdesk#55)
|
||||||
export function markdown(text) {
|
|
||||||
return DOMPurify.sanitize(marked.parse(text, { gfm: true, breaks: false }), { USE_PROFILES: { html: true } });
|
// 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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;
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-13
@@ -28,7 +28,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="dd-session">
|
<div class="dd-session">
|
||||||
<button type="button" class="dd-session-chip" id="session-chip" data-state="" aria-haspopup="menu" aria-expanded="false" title="Switch session">
|
<button type="button" class="dd-session-chip" id="session-chip" data-state="" aria-haspopup="menu" aria-expanded="false" title="Switch session">
|
||||||
<span class="dd-state-dot" aria-hidden="true"></span><span class="dd-session-label" id="session-name">—</span><span class="dd-caret" aria-hidden="true">▾</span>
|
<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>
|
</button>
|
||||||
<span class="dd-pane-title" id="pane-title"></span>
|
<span class="dd-pane-title" id="pane-title"></span>
|
||||||
<div class="dd-menu dd-switcher" id="switcher" role="menu" hidden></div>
|
<div class="dd-menu dd-switcher" id="switcher" role="menu" hidden></div>
|
||||||
@@ -53,16 +53,9 @@
|
|||||||
<div class="dd-more">
|
<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>
|
<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm dd-more-toggle" id="more-toggle" aria-haspopup="menu" aria-expanded="false" title="Settings" aria-label="Settings">⋯</button>
|
||||||
<div class="dd-menu dd-more-menu" id="more-menu" role="menu" hidden>
|
<div class="dd-menu dd-more-menu" id="more-menu" role="menu" hidden>
|
||||||
<div class="dd-menu-row">
|
<button type="button" class="dd-menu-item" id="settings-menu" role="menuitem">Settings…</button>
|
||||||
<span>Text size</span>
|
<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>
|
||||||
<span class="dd-menu-inline">
|
<button type="button" class="dd-menu-item" id="history-menu" role="menuitem">Search past conversations</button>
|
||||||
<button type="button" class="dd-icon-btn" id="font-down" aria-label="Smaller text">A−</button>
|
|
||||||
<span id="font-size">14</span>
|
|
||||||
<button type="button" class="dd-icon-btn" id="font-up" aria-label="Larger text">A+</button>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="dd-menu-item" id="theme-menu" role="menuitem">Light mode</button>
|
|
||||||
<button type="button" class="dd-menu-item" id="notify-toggle" role="menuitem">Desktop notifications</button>
|
|
||||||
<button type="button" class="dd-menu-item" id="palette-menu" role="menuitem">Command palette</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="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>
|
<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>
|
||||||
@@ -96,6 +89,7 @@
|
|||||||
<button type="button" class="dd-icon-btn dd-explorer-toggle" title="Show / hide the explorer" aria-label="Toggle explorer">☰</button>
|
<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>
|
<div class="dd-tabs" role="tablist" aria-label="Open files"></div>
|
||||||
<span class="dd-files-actions">
|
<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-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-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>
|
<button type="button" class="dd-icon-btn dd-files-close" title="Close (Esc)" aria-label="Close files">✕</button>
|
||||||
@@ -106,8 +100,17 @@
|
|||||||
<div class="dd-explorer-views" role="tablist">
|
<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="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="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>
|
||||||
<div class="dd-explorer-body" role="tree" aria-label="Files"></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>
|
||||||
<div class="dd-editor-area"></div>
|
<div class="dd-editor-area"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -128,6 +131,7 @@
|
|||||||
aria-label="New session name" spellcheck="false">
|
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>
|
<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>
|
<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>
|
</form>
|
||||||
<ul class="dd-session-list" id="sessions-list"></ul>
|
<ul class="dd-session-list" id="sessions-list"></ul>
|
||||||
<span class="dd-muted dd-status" id="sessions-status" role="status"></span>
|
<span class="dd-muted dd-status" id="sessions-status" role="status"></span>
|
||||||
@@ -152,6 +156,7 @@
|
|||||||
<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">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>
|
<span class="lt-kv-key">Open PRs</span><span class="lt-kv-val" data-field="prs">—</span>
|
||||||
</div>
|
</div>
|
||||||
|
<ul class="dd-repo-changes" hidden></ul>
|
||||||
<p class="dd-muted dd-repo-note"></p>
|
<p class="dd-muted dd-repo-note"></p>
|
||||||
<button type="button" class="lt-btn lt-btn-sm dd-ask dd-fix-ci" hidden>Ask Claude to fix CI</button>
|
<button type="button" class="lt-btn lt-btn-sm dd-ask dd-fix-ci" hidden>Ask Claude to fix CI</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -171,13 +176,14 @@
|
|||||||
<span class="lt-footer-sep">│</span>
|
<span class="lt-footer-sep">│</span>
|
||||||
<span class="lt-footer-hint"><span class="lt-footer-key">^b d</span>detach</span>
|
<span class="lt-footer-hint"><span class="lt-footer-key">^b d</span>detach</span>
|
||||||
<span class="lt-footer-sep">│</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-sep">│</span>
|
||||||
<span class="lt-footer-hint"><span class="lt-footer-key">drop / paste</span>attach files</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-sep">│</span>
|
||||||
<span class="lt-footer-hint"><span class="lt-footer-key">^⇧P</span>commands</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>
|
<button type="button" class="dd-footer-help" id="footer-help">all keys</button>
|
||||||
</div>
|
</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>
|
<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="esc">Esc</button>
|
||||||
<button type="button" data-keys="tab">Tab</button>
|
<button type="button" data-keys="tab">Tab</button>
|
||||||
|
|||||||
+29
-9
@@ -1,8 +1,10 @@
|
|||||||
// Ops Alerts panel: firing/pending Grafana alert rules (`devdesk attach --alerts`),
|
// Ops Alerts panel: firing/pending Grafana alert rules (`devdesk attach --alerts`),
|
||||||
// each with an "Ask Claude" button that types an investigation prompt into the
|
// the Proxmox guest or node each is about (CT id, the node it runs on now), and
|
||||||
// current session (without pressing Enter).
|
// an "Ask Claude" button that starts an investigation in a session of its own
|
||||||
|
// (devdesk#39).
|
||||||
|
|
||||||
import { el } from './dom.js';
|
import { el } from './dom.js';
|
||||||
|
import { slugify } from './protocol.js';
|
||||||
|
|
||||||
const SEVERITY = {
|
const SEVERITY = {
|
||||||
critical: 'lt-chip-critical',
|
critical: 'lt-chip-critical',
|
||||||
@@ -18,14 +20,24 @@ function age(iso) {
|
|||||||
return `${Math.floor(s / 86400)}d`;
|
return `${Math.floor(s / 86400)}d`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function investigatePrompt(alert) {
|
// "CT 110 draupnir · monitor-02 · stopped", "node compute-storage-01".
|
||||||
const where = alert.instances?.length ? ` on ${alert.instances.join('; ')}` : '';
|
function targetLabel(t) {
|
||||||
return `Investigate the ${alert.state} Grafana alert "${alert.name}"`
|
if (t.kind === 'unknown') return null;
|
||||||
+ ` (${alert.severity || 'no severity'}, ${alert.folder} / ${alert.group}${where}, since ${alert.since}).`
|
const what = t.kind === 'node' ? `node ${t.name}` : `${t.kind.toUpperCase()} ${t.id} ${t.name}`;
|
||||||
+ ` Summary: ${alert.summary || 'n/a'}. Use the grafana MCP server to look at the rule and the related`
|
const parts = [what, t.kind === 'node' ? null : t.node, t.status === 'running' || t.status === 'online' ? null : t.status];
|
||||||
+ ' metrics, find the cause, and tell me what you would change before changing anything.';
|
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) {
|
export function renderAlerts(root, data) {
|
||||||
const list = root.querySelector('.dd-alert-list');
|
const list = root.querySelector('.dd-alert-list');
|
||||||
const note = root.querySelector('.dd-alerts-note');
|
const note = root.querySelector('.dd-alerts-note');
|
||||||
@@ -59,6 +71,14 @@ export function renderAlerts(root, data) {
|
|||||||
el('a', { class: 'dd-alert-name', href: a.url, target: '_blank', rel: 'noopener', title: `${a.folder} / ${a.group}` }, a.name),
|
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))),
|
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.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,
|
a.summary ? el('div', { class: 'dd-alert-summary' }, a.summary) : null,
|
||||||
el('button', { type: 'button', class: 'lt-btn lt-btn-sm dd-ask', 'data-ask-claude': investigatePrompt(a) }, 'Ask Claude'))));
|
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')))));
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-15
@@ -209,11 +209,69 @@ export function uploadFile(session, file, onProgress) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function uploadNow(session, file, onProgress) {
|
async function uploadNow(session, file, onProgress) {
|
||||||
const token = await fetchToken();
|
|
||||||
const name = (file.name || 'pasted-image.png').slice(-100);
|
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
|
const slice = 3 * 16 * 1024; // multiple of 3: base64 of slices concatenates cleanly
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const socket = new WebSocket(wsUrl(window.location, ['--upload', session, name, String(file.size)]), ['tty']);
|
const socket = new WebSocket(wsUrl(window.location, args), ['tty']);
|
||||||
socket.binaryType = 'arraybuffer';
|
socket.binaryType = 'arraybuffer';
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let output = '';
|
let output = '';
|
||||||
@@ -224,15 +282,16 @@ async function uploadNow(session, file, onProgress) {
|
|||||||
idle = setTimeout(() => socket.close(), 20000);
|
idle = setTimeout(() => socket.close(), 20000);
|
||||||
};
|
};
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
for (let offset = 0; offset < file.size; offset += slice) {
|
for (let offset = 0; offset < blob.size; offset += slice) {
|
||||||
while (socket.bufferedAmount > 1 << 20) await sleep(20);
|
while (socket.bufferedAmount > 1 << 20) await sleep(20);
|
||||||
if (socket.readyState !== WebSocket.OPEN) return;
|
if (socket.readyState !== WebSocket.OPEN) return;
|
||||||
const bytes = new Uint8Array(await file.slice(offset, offset + slice).arrayBuffer());
|
const bytes = new Uint8Array(await blob.slice(offset, offset + slice).arrayBuffer());
|
||||||
socket.send(encodeInput(toBase64(bytes)));
|
socket.send(encodeInput(toBase64(bytes)));
|
||||||
touch();
|
touch();
|
||||||
onProgress?.(Math.min(1, (offset + slice) / file.size));
|
onProgress?.(Math.min(1, (offset + slice) / blob.size));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
const after = () => output.slice(output.indexOf(ready) + ready.length);
|
||||||
touch();
|
touch();
|
||||||
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
|
socket.onopen = () => socket.send(encodeInit(token, 240, 24));
|
||||||
socket.onmessage = (event) => {
|
socket.onmessage = (event) => {
|
||||||
@@ -240,28 +299,26 @@ async function uploadNow(session, file, onProgress) {
|
|||||||
const { type, payload } = decodeServer(event.data);
|
const { type, payload } = decodeServer(event.data);
|
||||||
if (type !== Server.OUTPUT) return;
|
if (type !== Server.OUTPUT) return;
|
||||||
output += decoder.decode(payload, { stream: true });
|
output += decoder.decode(payload, { stream: true });
|
||||||
if (!sending && output.includes('DEVDESK-UPLOAD-READY')) {
|
if (!sending && output.includes(ready)) {
|
||||||
sending = true;
|
sending = true;
|
||||||
send();
|
send();
|
||||||
} else if (sending && isComplete(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, ''))) {
|
}
|
||||||
|
if (sending && isComplete(after())) {
|
||||||
socket.close(); // the reply is in; let devdesk exit (devdesk#35)
|
socket.close(); // the reply is in; let devdesk exit (devdesk#35)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
socket.onerror = () => {};
|
socket.onerror = () => {};
|
||||||
socket.onclose = () => {
|
socket.onclose = () => {
|
||||||
clearTimeout(idle);
|
clearTimeout(idle);
|
||||||
const text = stripAnsi(output.replace(/^[\s\S]*DEVDESK-UPLOAD-READY/, ''));
|
const text = stripAnsi(output.includes(ready) ? after() : output);
|
||||||
try {
|
try {
|
||||||
const result = parseListOutput(text);
|
resolve(parseListOutput(text));
|
||||||
if (result.ok && result.path) {
|
return;
|
||||||
resolve(result.path);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// fall through to the error message
|
// fall through to the error message
|
||||||
}
|
}
|
||||||
const message = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop();
|
const message = text.split(/\r?\n/).map((l) => l.trim()).filter(Boolean).pop();
|
||||||
reject(new Error((message || 'upload failed').replace(/^devdesk:\s*/, '')));
|
reject(new Error((message || 'no reply').replace(/^devdesk:\s*/, '')));
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -340,7 +397,23 @@ export async function fetchRepo(session) {
|
|||||||
// rejects with devdesk's own error message.
|
// rejects with devdesk's own error message.
|
||||||
export async function sessionAction(action, ...args) {
|
export async function sessionAction(action, ...args) {
|
||||||
const output = stripAnsi(await runCommand([`--${action}`, ...args])).trim();
|
const output = stripAnsi(await runCommand([`--${action}`, ...args])).trim();
|
||||||
if (/"ok"\s*:\s*true/.test(output)) return;
|
// 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';
|
const message = output.split(/\r?\n/).filter(Boolean).pop() || 'no response';
|
||||||
throw new Error(message.replace(/^devdesk:\s*/, ''));
|
throw new Error(message.replace(/^devdesk:\s*/, ''));
|
||||||
}
|
}
|
||||||
|
|||||||
+1640
-97
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+151
-10
@@ -5,14 +5,17 @@
|
|||||||
|
|
||||||
import { el } from './dom.js';
|
import { el } from './dom.js';
|
||||||
import { ago } from './sessions.js';
|
import { ago } from './sessions.js';
|
||||||
|
import { answerPrompt, peekSession, replyTo } from './connection.js';
|
||||||
|
import { toast } from './ui.js';
|
||||||
|
|
||||||
const KEY = 'devdesk.inbox';
|
const KEY = 'devdesk.inbox';
|
||||||
const MAX = 30;
|
const MAX = 30;
|
||||||
|
const MAX_AGE = 7 * 86400 * 1000;
|
||||||
|
|
||||||
function load() {
|
function load() {
|
||||||
try {
|
try {
|
||||||
const list = JSON.parse(localStorage.getItem(KEY) || '[]');
|
const list = JSON.parse(localStorage.getItem(KEY) || '[]');
|
||||||
return Array.isArray(list) ? list : [];
|
return Array.isArray(list) ? list.filter((e) => Date.now() - e.at < MAX_AGE) : [];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -45,6 +48,7 @@ export class Inbox {
|
|||||||
this.events = load();
|
this.events = load();
|
||||||
this.waiting = [];
|
this.waiting = [];
|
||||||
this.alerts = [];
|
this.alerts = [];
|
||||||
|
this.known = null; // session names that exist, once the list has loaded
|
||||||
this.list = panel.querySelector('.lt-notif-panel-list');
|
this.list = panel.querySelector('.lt-notif-panel-list');
|
||||||
panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead());
|
panel.querySelector('.lt-notif-panel-clear').addEventListener('click', () => this.markAllRead());
|
||||||
window.addEventListener('storage', (event) => {
|
window.addEventListener('storage', (event) => {
|
||||||
@@ -63,7 +67,29 @@ export class Inbox {
|
|||||||
if (open) this.panel.removeAttribute('aria-hidden');
|
if (open) this.panel.removeAttribute('aria-hidden');
|
||||||
else this.panel.setAttribute('aria-hidden', 'true');
|
else this.panel.setAttribute('aria-hidden', 'true');
|
||||||
this.button.setAttribute('aria-expanded', String(open));
|
this.button.setAttribute('aria-expanded', String(open));
|
||||||
if (open) this.render();
|
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
|
// Something happened. The same kind for the same session within a minute is
|
||||||
@@ -78,9 +104,10 @@ export class Inbox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Live state: other sessions waiting on you, firing alerts.
|
// Live state: other sessions waiting on you, firing alerts.
|
||||||
setLive({ waiting, alerts }) {
|
setLive({ waiting, alerts, sessions }) {
|
||||||
if (waiting) this.waiting = waiting;
|
if (waiting) this.waiting = waiting;
|
||||||
if (alerts) this.alerts = alerts;
|
if (alerts) this.alerts = alerts;
|
||||||
|
if (sessions) this.known = new Set(sessions);
|
||||||
this.render();
|
this.render();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +118,15 @@ export class Inbox {
|
|||||||
}
|
}
|
||||||
|
|
||||||
unread() {
|
unread() {
|
||||||
return this.events.filter((e) => !e.read && e.kind !== 'waiting').length;
|
return this.events.filter((e) => !e.read && e.kind !== 'waiting' && !(this.known && e.session && !e.url && !this.known.has(e.session))).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
this.renderBadge();
|
||||||
|
if (this.isOpen) this.renderPanel();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderBadge() {
|
||||||
const count = this.waiting.length + this.alerts.length + this.unread();
|
const count = this.waiting.length + this.alerts.length + this.unread();
|
||||||
const urgent = this.waiting.length > 0 || this.alerts.some((a) => a.severity === 'critical');
|
const urgent = this.waiting.length > 0 || this.alerts.some((a) => a.severity === 'critical');
|
||||||
this.badge.textContent = count ? String(count) : '';
|
this.badge.textContent = count ? String(count) : '';
|
||||||
@@ -107,10 +139,90 @@ export class Inbox {
|
|||||||
this.unread() && `${this.unread()} new event(s)`,
|
this.unread() && `${this.unread()} new event(s)`,
|
||||||
].filter(Boolean).join(' · ')
|
].filter(Boolean).join(' · ')
|
||||||
: 'Inbox: nothing new';
|
: 'Inbox: nothing new';
|
||||||
if (this.isOpen) this.renderPanel();
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ 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() {
|
renderPanel() {
|
||||||
|
if (this.peeking) return; // the peek view stays until you go back
|
||||||
const close = (run) => () => {
|
const close = (run) => () => {
|
||||||
this.setOpen(false);
|
this.setOpen(false);
|
||||||
run();
|
run();
|
||||||
@@ -133,11 +245,31 @@ export class Inbox {
|
|||||||
if (this.waiting.length) {
|
if (this.waiting.length) {
|
||||||
nodes.push(el('div', { class: 'dd-notif-section' }, 'Needs you'));
|
nodes.push(el('div', { class: 'dd-notif-section' }, 'Needs you'));
|
||||||
for (const s of this.waiting) {
|
for (const s of this.waiting) {
|
||||||
nodes.push(item({
|
const request = s.status?.request;
|
||||||
|
const node = item({
|
||||||
dot: 'red', unread: true, title: `${s.name}: ${s.status?.message || 'Claude is waiting for your input'}`,
|
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',
|
meta: s.status?.since ? `waiting ${ago(s.status.since)}` : 'waiting',
|
||||||
onclick: close(() => this.hooks.openSession(s.name)),
|
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) {
|
if (this.alerts.length) {
|
||||||
@@ -155,15 +287,24 @@ export class Inbox {
|
|||||||
nodes.push(el('div', { class: 'dd-notif-section' }, 'Recent'));
|
nodes.push(el('div', { class: 'dd-notif-section' }, 'Recent'));
|
||||||
for (const e of recent.slice(0, 12)) {
|
for (const e of recent.slice(0, 12)) {
|
||||||
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
|
const kind = KINDS[e.kind] ?? { tone: 'cyan' };
|
||||||
nodes.push(item({
|
// An entry about a session that has since ended must not bring it back (devdesk#38).
|
||||||
dot: e.read ? 'read' : kind.tone, unread: !e.read, title: e.text, meta: when(e.at),
|
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(() => {
|
onclick: close(() => {
|
||||||
this.events = this.events.map((x) => (x.id === e.id ? { ...x, read: true } : x));
|
this.events = this.events.map((x) => (x.id === e.id ? { ...x, read: true } : x));
|
||||||
save(this.events);
|
save(this.events);
|
||||||
if (e.url) window.open(e.url, '_blank', 'noopener');
|
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);
|
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.'));
|
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.'));
|
||||||
|
|||||||
@@ -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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
+324
-51
@@ -1,11 +1,12 @@
|
|||||||
/* global __DEVDESK_VERSION__ */
|
/* global __DEVDESK_VERSION__ */
|
||||||
import { THEMES, createTerminal, loadFonts } from './term.js';
|
import { THEMES, createTerminal, loadFonts } from './term.js';
|
||||||
import { getTheme, initTheme, onThemeChange, toggleTheme } from './theme.js';
|
import { getTheme, initTheme, onThemeChange, setTheme, toggleTheme } from './theme.js';
|
||||||
import {
|
import {
|
||||||
TtydConnection, fetchAlerts, fetchRepo, fetchToken, uploadFile, AuthExpiredError, UnauthorizedError,
|
TtydConnection, fetchAlerts, fetchRepo, fetchToken, investigateAlert, lastReply, replyTo, sessionAction, uploadFile,
|
||||||
|
AuthExpiredError, UnauthorizedError,
|
||||||
} from './connection.js';
|
} from './connection.js';
|
||||||
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
|
import { parseTitle, pasteSafe, sessionFromSearch } from './protocol.js';
|
||||||
import { SessionsPanel, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
import { SessionsPanel, ctxLabel, elapsed, recentNames, rememberSession, sessionStatus } from './sessions.js';
|
||||||
import { renderAlerts } from './alerts.js';
|
import { renderAlerts } from './alerts.js';
|
||||||
import { renderRepo } from './repo.js';
|
import { renderRepo } from './repo.js';
|
||||||
import { bindAskClaude } from './widgets.js';
|
import { bindAskClaude } from './widgets.js';
|
||||||
@@ -13,6 +14,9 @@ import { heardWithin, onShared, share } from './shared.js';
|
|||||||
import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
|
import { CommandPalette, dialogOpen, openDialog, toast } from './ui.js';
|
||||||
import { Inbox } from './inbox.js';
|
import { Inbox } from './inbox.js';
|
||||||
import { FilesPane } from './files.js';
|
import { FilesPane } from './files.js';
|
||||||
|
import { registerPathLinks } from './links.js';
|
||||||
|
import { openHistory } from './history.js';
|
||||||
|
import { applyUiScale, openSettings } from './settings.js';
|
||||||
import { el } from './dom.js';
|
import { el } from './dom.js';
|
||||||
import * as notify from './notify.js';
|
import * as notify from './notify.js';
|
||||||
|
|
||||||
@@ -52,12 +56,13 @@ const ui = {
|
|||||||
overlayActions: $('overlay-actions'),
|
overlayActions: $('overlay-actions'),
|
||||||
moreToggle: $('more-toggle'),
|
moreToggle: $('more-toggle'),
|
||||||
moreMenu: $('more-menu'),
|
moreMenu: $('more-menu'),
|
||||||
notifyButton: $('notify-toggle'),
|
|
||||||
sidebarButton: $('sidebar-toggle'),
|
sidebarButton: $('sidebar-toggle'),
|
||||||
backdrop: $('backdrop'),
|
backdrop: $('backdrop'),
|
||||||
};
|
};
|
||||||
|
|
||||||
const urlSession = sessionFromSearch(window.location.search);
|
const urlSession = sessionFromSearch(window.location.search);
|
||||||
|
// Notification links (Matrix) add existing=1: open, never re-create (devdesk#38).
|
||||||
|
const urlExisting = new URLSearchParams(window.location.search).get('existing') === '1';
|
||||||
const state = {
|
const state = {
|
||||||
session: urlSession, // null = picker
|
session: urlSession, // null = picker
|
||||||
conn: null,
|
conn: null,
|
||||||
@@ -121,10 +126,17 @@ function renderSessionChip() {
|
|||||||
const status = current ? sessionStatus(current) : null;
|
const status = current ? sessionStatus(current) : null;
|
||||||
ui.sessionName.textContent = state.session || 'picker';
|
ui.sessionName.textContent = state.session || 'picker';
|
||||||
ui.sessionChip.dataset.state = status?.key ?? '';
|
ui.sessionChip.dataset.state = status?.key ?? '';
|
||||||
|
// How long the current turn has been running (devdesk#48).
|
||||||
|
const turn = status?.key === 'working' && current.status?.since ? elapsed(current.status.since) : '';
|
||||||
|
const timer = $('turn-timer');
|
||||||
|
timer.hidden = !turn;
|
||||||
|
timer.textContent = turn;
|
||||||
|
notify.setStatusPrefix(turn ? `⏳ ${turn} · ` : status?.key === 'waiting' ? '❗ ' : '');
|
||||||
ui.sessionChip.title = [
|
ui.sessionChip.title = [
|
||||||
state.session ? `Session ${state.session}${status ? ` · ${status.label}` : ''}` : 'Session picker',
|
state.session ? `Session ${state.session}${status ? ` · ${status.label}` : ''}` : 'Session picker',
|
||||||
current?.status?.message,
|
current?.status?.message,
|
||||||
current?.context?.used_pct != null ? `context ${Math.floor(current.context.used_pct)}%` : '',
|
turn ? `working on this turn for ${turn}` : '',
|
||||||
|
current?.context?.used_pct != null ? ctxLabel(current.context).replace(/^ctx/, 'context') : '',
|
||||||
'Click to switch session',
|
'Click to switch session',
|
||||||
].filter(Boolean).join('\n');
|
].filter(Boolean).join('\n');
|
||||||
}
|
}
|
||||||
@@ -169,10 +181,16 @@ function recordTransitions(sessions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderPanelsBadge() {
|
function renderPanelsBadge() {
|
||||||
inbox?.setLive({ waiting: waitingOthers, alerts: lastAlerts?.alerts ?? [] });
|
inbox?.setLive({
|
||||||
|
waiting: waitingOthers,
|
||||||
|
alerts: lastAlerts?.alerts ?? [],
|
||||||
|
sessions: sessionsPanel?.data ? sessionsPanel.data.sessions.map((x) => x.name) : null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function onSessionData({ fetched = false } = {}) {
|
function onSessionData({ fetched = false } = {}) {
|
||||||
|
if (document.visibilityState === 'visible') inbox?.markSessionRead(state.session);
|
||||||
|
renderUsage();
|
||||||
renderSessionChip();
|
renderSessionChip();
|
||||||
renderAttention(fetched);
|
renderAttention(fetched);
|
||||||
if (!ui.switcher.hidden) renderSwitcher();
|
if (!ui.switcher.hidden) renderSwitcher();
|
||||||
@@ -249,14 +267,29 @@ function wireUploads() {
|
|||||||
$('drop-session').textContent = state.session || 'a session';
|
$('drop-session').textContent = state.session || 'a session';
|
||||||
overlay.hidden = false;
|
overlay.hidden = false;
|
||||||
});
|
});
|
||||||
|
// Files dragged from the explorer: @path mentions for Claude (devdesk#54).
|
||||||
|
const fromExplorer = (event) => [...(event.dataTransfer?.types ?? [])].includes('application/x-devdesk-paths');
|
||||||
frame.addEventListener('dragover', (event) => {
|
frame.addEventListener('dragover', (event) => {
|
||||||
if (hasFiles(event)) event.preventDefault();
|
if (hasFiles(event)) event.preventDefault();
|
||||||
|
else if (fromExplorer(event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.dataTransfer.dropEffect = 'copy';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
frame.addEventListener('dragleave', () => {
|
frame.addEventListener('dragleave', () => {
|
||||||
depth = Math.max(0, depth - 1);
|
depth = Math.max(0, depth - 1);
|
||||||
if (!depth) overlay.hidden = true;
|
if (!depth) overlay.hidden = true;
|
||||||
});
|
});
|
||||||
frame.addEventListener('drop', (event) => {
|
frame.addEventListener('drop', (event) => {
|
||||||
|
if (!hasFiles(event) && fromExplorer(event)) {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = event.dataTransfer.getData('text/plain');
|
||||||
|
if (text && state.conn?.isOpen) {
|
||||||
|
term.paste(text);
|
||||||
|
term.focus();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!hasFiles(event)) return;
|
if (!hasFiles(event)) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
depth = 0;
|
depth = 0;
|
||||||
@@ -266,7 +299,8 @@ function wireUploads() {
|
|||||||
// Pasting a screenshot / copied file: upload it instead of pasting text.
|
// Pasting a screenshot / copied file: upload it instead of pasting text.
|
||||||
document.addEventListener('paste', (event) => {
|
document.addEventListener('paste', (event) => {
|
||||||
const files = [...(event.clipboardData?.files ?? [])];
|
const files = [...(event.clipboardData?.files ?? [])];
|
||||||
if (!files.length) return;
|
// In the Files pane, pasted files go into the explorer's folder (devdesk#54).
|
||||||
|
if (!files.length || event.target.closest?.('#files')) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
attachFiles(files);
|
attachFiles(files);
|
||||||
@@ -275,6 +309,14 @@ function wireUploads() {
|
|||||||
|
|
||||||
// Repo / CI panel for the current session; notifies when HEAD's CI finishes.
|
// Repo / CI panel for the current session; notifies when HEAD's CI finishes.
|
||||||
let lastCi = { key: '', state: '' };
|
let lastCi = { key: '', state: '' };
|
||||||
|
// Repo panel → diffs in the file pane (devdesk#46).
|
||||||
|
const repoHooks = {
|
||||||
|
openChanges: () => files.open('changes'),
|
||||||
|
openDiff: (path, diffable) => {
|
||||||
|
files.open('changes');
|
||||||
|
files.openFile(path, { mode: diffable ? 'diff' : undefined, pin: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
let repoBusy = false;
|
let repoBusy = false;
|
||||||
async function refreshRepo() {
|
async function refreshRepo() {
|
||||||
const session = state.session;
|
const session = state.session;
|
||||||
@@ -288,7 +330,7 @@ async function refreshRepo() {
|
|||||||
try {
|
try {
|
||||||
const repo = await fetchRepo(session);
|
const repo = await fetchRepo(session);
|
||||||
if (session !== state.session) return;
|
if (session !== state.session) return;
|
||||||
renderRepo($('widget-repo'), repo);
|
renderRepo($('widget-repo'), repo, repoHooks);
|
||||||
lastRepo = repo;
|
lastRepo = repo;
|
||||||
const key = `${session}@${repo.sha}`;
|
const key = `${session}@${repo.sha}`;
|
||||||
const ci = repo.ci?.total ? repo.ci.state : '';
|
const ci = repo.ci?.total ? repo.ci.state : '';
|
||||||
@@ -316,14 +358,19 @@ function updateUrl(session) {
|
|||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
if (session) url.searchParams.set('arg', session);
|
if (session) url.searchParams.set('arg', session);
|
||||||
else url.searchParams.delete('arg');
|
else url.searchParams.delete('arg');
|
||||||
|
url.searchParams.delete('existing');
|
||||||
if (url.href !== window.location.href) window.history.replaceState(null, '', url);
|
if (url.href !== window.location.href) window.history.replaceState(null, '', url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- connection
|
// ---------------------------------------------------------------- connection
|
||||||
|
|
||||||
// reconnect: automatic re-attach after a drop. It must not re-create a session
|
// existing: go to a session that should already exist (reconnects, the
|
||||||
// that was ended or renamed meanwhile, so it asks for --existing (devdesk#4).
|
// switcher, inbox entries, notification links). It must not re-create one that
|
||||||
function connect(session, { reconnect = false } = {}) {
|
// was ended or renamed meanwhile, so it asks for --existing (devdesk#4, #38).
|
||||||
|
// Creating is explicit: a typed name, the New dialog, the picker, recents.
|
||||||
|
function connect(session, { existing = false, reconnect = false } = {}) {
|
||||||
|
const existingOnly = Boolean(session) && (existing || reconnect);
|
||||||
|
state.existingOnly = existingOnly;
|
||||||
clearTimeout(state.retryTimer);
|
clearTimeout(state.retryTimer);
|
||||||
state.conn?.dispose();
|
state.conn?.dispose();
|
||||||
state.session = session;
|
state.session = session;
|
||||||
@@ -339,13 +386,15 @@ function connect(session, { reconnect = false } = {}) {
|
|||||||
term.reset();
|
term.reset();
|
||||||
|
|
||||||
const conn = new TtydConnection({
|
const conn = new TtydConnection({
|
||||||
arg: session && reconnect ? ['--existing', session] : session,
|
arg: existingOnly ? ['--existing', session] : session,
|
||||||
size: () => ({ cols: term.cols, rows: term.rows }),
|
size: () => ({ cols: term.cols, rows: term.rows }),
|
||||||
onOpen: () => {
|
onOpen: () => {
|
||||||
state.openedAt = Date.now();
|
state.openedAt = Date.now();
|
||||||
state.attempts = 0;
|
state.attempts = 0;
|
||||||
setWsState('connected', 'live');
|
setWsState('connected', 'live');
|
||||||
if (session) rememberSession(session);
|
if (session) rememberSession(session);
|
||||||
|
if (session) files?.knownFiles(); // so paths in the output are links from the first hover
|
||||||
|
presenceTick();
|
||||||
refreshSoon(800); // opening ?arg=<new name> just created this session
|
refreshSoon(800); // opening ?arg=<new name> just created this session
|
||||||
refreshRepo();
|
refreshRepo();
|
||||||
fit.fit();
|
fit.fit();
|
||||||
@@ -373,6 +422,25 @@ function onClosed(code) {
|
|||||||
// few seconds of opening means the session could not start (the reason is
|
// few seconds of opening means the session could not start (the reason is
|
||||||
// printed in the terminal), so don't retry forever.
|
// printed in the terminal), so don't retry forever.
|
||||||
const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000;
|
const failedFast = code !== 1000 && Date.now() - state.openedAt < 4000;
|
||||||
|
if (failedFast && state.existingOnly) {
|
||||||
|
// Most likely ended or renamed since the link was made: say so, and only
|
||||||
|
// start it again when asked.
|
||||||
|
const name = state.session;
|
||||||
|
sessionsPanel?.reload().then(() => {
|
||||||
|
const known = sessionsPanel.data?.sessions?.some((x) => x.name === name);
|
||||||
|
if (known) {
|
||||||
|
scheduleReconnect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setWsState('disconnected', 'ended');
|
||||||
|
showOverlay({
|
||||||
|
title: `${name} has ended`,
|
||||||
|
text: `The session "${name}" no longer exists: it was ended or renamed. Its conversation is still in Claude's history.`,
|
||||||
|
actions: [pickerAction, { label: `Start ${name} again`, run: () => connect(name) }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (failedFast) {
|
if (failedFast) {
|
||||||
showOverlay({
|
showOverlay({
|
||||||
title: 'Session did not start',
|
title: 'Session did not start',
|
||||||
@@ -511,14 +579,15 @@ function renderSwitcher() {
|
|||||||
};
|
};
|
||||||
const items = sessions.map((s) => {
|
const items = sessions.map((s) => {
|
||||||
const status = sessionStatus(s);
|
const status = sessionStatus(s);
|
||||||
const ctx = s.context?.used_pct != null ? ` · ctx ${Math.floor(s.context.used_pct)}%` : '';
|
const ctx = s.context?.used_pct != null ? ` · ${ctxLabel(s.context)}` : '';
|
||||||
return el('button', {
|
return el('button', {
|
||||||
type: 'button', role: 'menuitem',
|
type: 'button', role: 'menuitem',
|
||||||
class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`,
|
class: `dd-menu-item dd-switch-item is-${status.key}${s.name === state.session ? ' is-current' : ''}`,
|
||||||
title: s.dir || '', onclick: choose(() => connect(s.name)),
|
title: s.dir || '', onclick: choose(() => connect(s.name, { existing: true })),
|
||||||
},
|
},
|
||||||
el('span', { class: `lt-dot ${status.dot}` }),
|
el('span', { class: `lt-dot ${status.dot}` }),
|
||||||
el('span', { class: 'dd-switch-name' }, s.name),
|
el('span', { class: 'dd-switch-name' }, s.name,
|
||||||
|
s.tags?.length ? el('span', { class: 'dd-tags' }, ...s.tags.slice(0, 2).map((t) => el('span', { class: 'dd-tag' }, t))) : null),
|
||||||
el('span', { class: 'dd-switch-meta' }, `${status.label}${ctx}`));
|
el('span', { class: 'dd-switch-meta' }, `${status.label}${ctx}`));
|
||||||
});
|
});
|
||||||
if (!items.length) items.push(el('div', { class: 'dd-menu-note' }, sessionsPanel?.data ? 'No sessions yet.' : 'Loading…'));
|
if (!items.length) items.push(el('div', { class: 'dd-menu-note' }, sessionsPanel?.data ? 'No sessions yet.' : 'Loading…'));
|
||||||
@@ -541,10 +610,41 @@ function setFontSize(size) {
|
|||||||
term.options.fontSize = clamped;
|
term.options.fontSize = clamped;
|
||||||
store.set('fontSize', clamped);
|
store.set('fontSize', clamped);
|
||||||
document.documentElement.style.setProperty('--dd-editor-font', `${clamped - 1}px`);
|
document.documentElement.style.setProperty('--dd-editor-font', `${clamped - 1}px`);
|
||||||
$('font-size').textContent = String(clamped);
|
|
||||||
fit.fit();
|
fit.fit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Settings (devdesk#61): appearance, notifications, Remote Control per session.
|
||||||
|
function showSettings() {
|
||||||
|
closeMenus();
|
||||||
|
openSettings({
|
||||||
|
getTheme,
|
||||||
|
setTheme,
|
||||||
|
fontSize: () => term.options.fontSize,
|
||||||
|
setFontSize,
|
||||||
|
uiScale: () => Number(store.get('uiScale', 1)) || 1,
|
||||||
|
setUiScale: (scale) => {
|
||||||
|
store.set('uiScale', scale);
|
||||||
|
applyUiScale(scale);
|
||||||
|
requestAnimationFrame(() => fit.fit());
|
||||||
|
},
|
||||||
|
notifications: () => {
|
||||||
|
const current = notify.notificationsState();
|
||||||
|
return {
|
||||||
|
label: {
|
||||||
|
unsupported: 'Not supported', denied: 'Blocked by the browser', default: 'Turn on',
|
||||||
|
granted: notify.isMuted() ? 'Muted: unmute' : 'On: mute',
|
||||||
|
}[current],
|
||||||
|
disabled: current === 'unsupported' || current === 'denied',
|
||||||
|
run: toggleNotifications,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
sessions: () => sessionsPanel?.data,
|
||||||
|
current: () => state.session,
|
||||||
|
refresh: () => sessionsPanel.reload(),
|
||||||
|
openHistory: showHistory,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleNotifications() {
|
async function toggleNotifications() {
|
||||||
const current = notify.notificationsState();
|
const current = notify.notificationsState();
|
||||||
if (current === 'default') {
|
if (current === 'default') {
|
||||||
@@ -559,10 +659,154 @@ async function toggleNotifications() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ops Alerts → Ask Claude (devdesk#39): the server starts Claude in a session of
|
||||||
|
// its own with the alert, its rule and where it runs (CT id, node) as the first
|
||||||
|
// prompt; then we open that session. A session already running is just opened.
|
||||||
|
async function investigate(button) {
|
||||||
|
const { investigate: uid, session } = button.dataset;
|
||||||
|
if (!uid || !session || button.disabled) return;
|
||||||
|
closeDrawerIfNarrow();
|
||||||
|
button.disabled = true;
|
||||||
|
const label = button.textContent;
|
||||||
|
button.textContent = 'Starting…';
|
||||||
|
try {
|
||||||
|
const result = await investigateAlert(session, uid);
|
||||||
|
if (result.started) toast.success(`Claude is on it in ${session}`);
|
||||||
|
else toast.info(`${session} is already running: opening it`);
|
||||||
|
await sessionsPanel.reload();
|
||||||
|
connect(session, { existing: true });
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`Ask Claude: ${error.message}`);
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.textContent = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------- history & usage
|
||||||
|
|
||||||
|
function showHistory() {
|
||||||
|
closeMenus();
|
||||||
|
openHistory({
|
||||||
|
sessions: () => sessionsPanel?.data?.sessions ?? [],
|
||||||
|
open: (name) => connect(name, { existing: true }),
|
||||||
|
refresh: () => sessionsPanel.reload(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan usage (devdesk#25): the 5-hour and weekly windows, from the newest
|
||||||
|
// statusLine snapshot of any session (they're account-wide). A toast when the
|
||||||
|
// 5-hour window passes 90 %, once per window.
|
||||||
|
let usageWarned = 0;
|
||||||
|
function renderUsage() {
|
||||||
|
const snapshots = (sessionsPanel?.data?.sessions ?? []).map((s) => s.context).filter((c) => c?.rate_limits);
|
||||||
|
const latest = snapshots.sort((a, b) => (b.updated || 0) - (a.updated || 0))[0]?.rate_limits;
|
||||||
|
const footer = $('usage-footer');
|
||||||
|
const menuRow = $('usage-row');
|
||||||
|
if (!latest || (!latest.five_hour && !latest.seven_day)) {
|
||||||
|
footer.hidden = true;
|
||||||
|
menuRow.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const at = (epoch) => (epoch ? new Date(epoch * 1000).toLocaleString(undefined, { weekday: 'short', hour: '2-digit', minute: '2-digit' }) : '?');
|
||||||
|
const clock = (epoch) => new Date(epoch * 1000).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
const day = (epoch) => new Date(epoch * 1000).toLocaleDateString(undefined, { weekday: 'short' });
|
||||||
|
// devdesk#45: when it resets, and when it fills at the pace of the window so
|
||||||
|
// far (straight line from the window's start), if that is before the reset.
|
||||||
|
const meter = (label, w, span, short) => {
|
||||||
|
if (!w || w.pct === null || w.pct === undefined) return null;
|
||||||
|
const pct = Math.round(w.pct);
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
const start = w.resets_at ? w.resets_at - span : null;
|
||||||
|
const used = start ? now - start : 0;
|
||||||
|
const full = start && w.pct >= 5 && w.pct < 100 && used >= 600 ? now + ((100 - w.pct) * used) / w.pct : null;
|
||||||
|
const early = full && full < w.resets_at ? full : null;
|
||||||
|
const title = [`${label}: ${pct}% used, resets ${at(w.resets_at)}`,
|
||||||
|
early ? `At this pace it is used up around ${at(early)}, before it resets` : start && pct < 100 ? 'At this pace it lasts until it resets' : ''].filter(Boolean).join('\n');
|
||||||
|
return el('span', { class: `dd-meter${pct >= 95 ? ' is-full' : pct >= 80 || early ? ' is-high' : ''}`, title },
|
||||||
|
el('span', { class: 'dd-meter-label' }, label),
|
||||||
|
el('span', { class: 'dd-meter-bar' }, el('span', { class: 'dd-meter-fill', style: `width:${Math.min(100, pct)}%` })),
|
||||||
|
`${pct}%`,
|
||||||
|
early ? el('span', { class: 'dd-meter-pace' }, `⚠ full ~${short(early)}`)
|
||||||
|
: w.resets_at ? el('span', { class: 'dd-meter-reset' }, `↻ ${short(w.resets_at)}`) : null);
|
||||||
|
};
|
||||||
|
const parts = () => [meter('5h', latest.five_hour, 5 * 3600, clock), meter('week', latest.seven_day, 7 * 86400, day)].filter(Boolean);
|
||||||
|
footer.replaceChildren(...parts());
|
||||||
|
$('usage-menu').replaceChildren(...parts());
|
||||||
|
footer.hidden = false;
|
||||||
|
menuRow.hidden = false;
|
||||||
|
const five = latest.five_hour;
|
||||||
|
if (five?.pct >= 90 && five.resets_at !== usageWarned) {
|
||||||
|
usageWarned = five.resets_at;
|
||||||
|
toast.warning(`Plan usage: ${Math.round(five.pct)}% of the 5-hour window (resets ${at(five.resets_at)})`, 10000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote Control presence (devdesk#26): while this tab shows a session and is
|
||||||
|
// visible, its Claude knows a client is present and holds back phone pushes.
|
||||||
|
let presenceFor = null;
|
||||||
|
function presence(on) {
|
||||||
|
const session = state.session;
|
||||||
|
if (presenceFor && presenceFor !== session) sessionAction('presence', presenceFor, 'off').catch(() => {});
|
||||||
|
presenceFor = on ? session : null;
|
||||||
|
if (session) sessionAction('presence', session, on ? 'on' : 'off').catch(() => {});
|
||||||
|
}
|
||||||
|
function presenceTick() {
|
||||||
|
presence(Boolean(state.session && state.conn?.isOpen && document.visibilityState === 'visible'));
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- palette & help
|
// ---------------------------------------------------------------- palette & help
|
||||||
|
|
||||||
const sendKeys = (keys) => state.conn?.sendInput(keys);
|
const sendKeys = (keys) => state.conn?.sendInput(keys);
|
||||||
|
|
||||||
|
// Claude's whole last answer as Markdown (devdesk#47). The clipboard write starts
|
||||||
|
// inside the click (Safari needs that) and gets its text when the server answers.
|
||||||
|
async function copyReply(session) {
|
||||||
|
const text = lastReply(session).then((r) => {
|
||||||
|
if (!r.text) throw new Error(`${session}: Claude hasn't answered anything yet`);
|
||||||
|
return r.text;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
if (window.ClipboardItem && navigator.clipboard?.write) {
|
||||||
|
await navigator.clipboard.write([new ClipboardItem({ 'text/plain': text.then((t) => new Blob([t], { type: 'text/plain' })) })]);
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(await text);
|
||||||
|
}
|
||||||
|
const t = await text;
|
||||||
|
toast.success(`Copied ${session}'s last reply (${t.length.toLocaleString()} characters)`);
|
||||||
|
} catch (error) {
|
||||||
|
const reason = await text.then(() => null, (e) => e);
|
||||||
|
toast.error(reason ? reason.message : `Copy failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// /compact in a session whose context is filling up (devdesk#49). Only when Claude
|
||||||
|
// is idle: mid-turn or at a permission prompt the keys would land in the wrong place.
|
||||||
|
async function compact(session) {
|
||||||
|
const s = sessionsPanel?.data?.sessions?.find((x) => x.name === session);
|
||||||
|
const key = s ? sessionStatus(s).key : 'idle';
|
||||||
|
if (key === 'working') return toast.warning(`${session} is working: compact once the turn ends`);
|
||||||
|
if (key === 'waiting') return toast.warning(`${session} is waiting on you: answer it first`);
|
||||||
|
try {
|
||||||
|
await replyTo(session, '/compact');
|
||||||
|
toast.success(`Compacting ${session}'s context`);
|
||||||
|
sessionsPanel.reload();
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(`${session}: ${error.message}`);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stray split (devdesk#52): close every extra pane, keeping Claude's.
|
||||||
|
async function unsplit(session) {
|
||||||
|
try {
|
||||||
|
const result = await sessionAction('unsplit', session);
|
||||||
|
toast.success(result?.closed ? `Closed ${result.closed} pane${result.closed === 1 ? '' : 's'}` : 'No split panes');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function copyLink() {
|
async function copyLink() {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.search = state.session ? `?arg=${encodeURIComponent(state.session)}` : '';
|
url.search = state.session ? `?arg=${encodeURIComponent(state.session)}` : '';
|
||||||
@@ -590,8 +834,9 @@ function buildCommands() {
|
|||||||
if (s.name === state.session) continue;
|
if (s.name === state.session) continue;
|
||||||
const status = sessionStatus(s);
|
const status = sessionStatus(s);
|
||||||
add({
|
add({
|
||||||
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: status.label, keywords: s.dir,
|
id: `switch:${s.name}`, group: 'Sessions', label: `Switch to ${s.name}`, hint: [status.label, ...(s.tags ?? []).slice(0, 2)].join(' · '),
|
||||||
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name),
|
keywords: `${s.dir} ${(s.tags ?? []).join(' ')}`,
|
||||||
|
icon: status.key === 'waiting' ? '!' : s.running ? '●' : '○', run: () => connect(s.name, { existing: true }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
add({ id: 'new', group: 'Sessions', icon: '+', label: 'New session…', keywords: 'create open project', run: () => sessionsPanel.newDialog() });
|
add({ id: 'new', group: 'Sessions', icon: '+', label: 'New session…', keywords: 'create open project', run: () => sessionsPanel.newDialog() });
|
||||||
@@ -602,19 +847,24 @@ function buildCommands() {
|
|||||||
run: () => sessionsPanel.end(current ?? { name: state.session, running: true }),
|
run: () => sessionsPanel.end(current ?? { name: state.session, running: true }),
|
||||||
});
|
});
|
||||||
add({ id: 'copy-link', group: 'Sessions', icon: '⧉', label: 'Copy link to this session', keywords: 'url share', run: copyLink });
|
add({ id: 'copy-link', group: 'Sessions', icon: '⧉', label: 'Copy link to this session', keywords: 'url share', run: copyLink });
|
||||||
|
add({ id: 'copy-reply', group: 'Sessions', icon: '⧉', label: 'Copy Claude\'s last reply (Markdown)', keywords: 'clipboard answer response markdown', run: () => copyReply(state.session) });
|
||||||
|
if (current?.running) add({ id: 'compact', group: 'Sessions', icon: '⇲', label: `Compact the context (/compact)${current.context?.used_pct != null ? ` · ${Math.floor(current.context.used_pct)}% full` : ''}`, keywords: 'context summarize shrink', run: () => compact(state.session) });
|
||||||
}
|
}
|
||||||
add({ id: 'picker', group: 'Sessions', icon: '≡', label: 'Open the session picker', keywords: 'fzf list', run: () => connect(null) });
|
add({ id: 'picker', group: 'Sessions', icon: '≡', label: 'Open the session picker', keywords: 'fzf list', run: () => connect(null) });
|
||||||
|
add({ id: 'history', group: 'Sessions', icon: '⌕', label: 'Search past conversations…', keywords: 'history transcript resume fork find old', run: showHistory });
|
||||||
|
|
||||||
if (state.session && state.conn?.isOpen) {
|
if (state.session && state.conn?.isOpen) {
|
||||||
add({ id: 'win-claude', group: 'Terminal', icon: '0', label: 'Claude window', kbd: '^b 0', run: () => sendKeys('\x020') });
|
add({ id: 'win-claude', group: 'Terminal', icon: '0', label: 'Claude window', kbd: '^b 0', run: () => sendKeys('\x020') });
|
||||||
add({ id: 'win-shell', group: 'Terminal', icon: '1', label: 'Shell window', kbd: '^b 1', run: () => sendKeys('\x021') });
|
add({ id: 'win-shell', group: 'Terminal', icon: '1', label: 'Shell window', kbd: '^b 1', run: () => sendKeys('\x021') });
|
||||||
add({ id: 'scroll', group: 'Terminal', icon: '⇞', label: 'Scroll back (tmux copy mode, q to leave)', kbd: '^b [', keywords: 'history scrollback', run: () => sendKeys('\x02[') });
|
add({ id: 'scroll', group: 'Terminal', icon: '⇞', label: 'Scroll back (tmux copy mode, q to leave)', kbd: '^b [', keywords: 'history scrollback', run: () => sendKeys('\x02[') });
|
||||||
add({ id: 'detach', group: 'Terminal', icon: '⏏', label: 'Detach (the session keeps running)', kbd: '^b d', run: () => sendKeys('\x02d') });
|
add({ id: 'detach', group: 'Terminal', icon: '⏏', label: 'Detach (the session keeps running)', kbd: '^b d', run: () => sendKeys('\x02d') });
|
||||||
|
add({ id: 'unsplit', group: 'Terminal', icon: '▣', label: 'Close split panes (keeps Claude)', keywords: 'pane split remove side by side layout', run: () => unsplit(state.session) });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.session) {
|
if (state.session) {
|
||||||
add({ id: 'files', group: 'Files', icon: '🗀', label: files.isOpen ? 'Close files' : 'Browse files', kbd: 'Ctrl+⇧E', keywords: 'explorer folder read open view', run: () => files.toggle('files') });
|
add({ id: 'files', group: 'Files', icon: '🗀', label: files.isOpen ? 'Close files' : 'Browse files', kbd: 'Ctrl+⇧E', keywords: 'explorer folder read open view', run: () => files.toggle('files') });
|
||||||
add({ id: 'changes', group: 'Files', icon: '±', label: 'Review changes (git diff)', keywords: 'git status diff claude edits', run: () => files.open('changes') });
|
add({ id: 'changes', group: 'Files', icon: '±', label: 'Review changes (git diff)', keywords: 'git status diff claude edits', run: () => files.open('changes') });
|
||||||
|
add({ id: 'search-files', group: 'Files', icon: '⌕', label: 'Search in files', kbd: 'Ctrl+⇧F', keywords: 'grep find text content', run: () => files.openSearch() });
|
||||||
add({
|
add({
|
||||||
id: 'goto-file', group: 'Files', icon: '⌕', label: 'Go to file…', kbd: 'Ctrl+P in Files', keywords: 'open find quick search',
|
id: 'goto-file', group: 'Files', icon: '⌕', label: 'Go to file…', kbd: 'Ctrl+P in Files', keywords: 'open find quick search',
|
||||||
run: () => {
|
run: () => {
|
||||||
@@ -631,12 +881,13 @@ function buildCommands() {
|
|||||||
add({ id: 'font-up', group: 'View', icon: 'A+', label: 'Larger text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize + 1) });
|
add({ id: 'font-up', group: 'View', icon: 'A+', label: 'Larger text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize + 1) });
|
||||||
add({ id: 'font-down', group: 'View', icon: 'A−', label: 'Smaller text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize - 1) });
|
add({ id: 'font-down', group: 'View', icon: 'A−', label: 'Smaller text', keywords: 'font zoom', run: () => setFontSize(term.options.fontSize - 1) });
|
||||||
add({ id: 'font-reset', group: 'View', icon: 'A', label: 'Reset text size', keywords: 'font zoom', run: () => setFontSize(14) });
|
add({ id: 'font-reset', group: 'View', icon: 'A', label: 'Reset text size', keywords: 'font zoom', run: () => setFontSize(14) });
|
||||||
|
add({ id: 'settings', group: 'View', icon: '⚙', label: 'Settings…', keywords: 'preferences text size font theme remote control privacy notifications', run: showSettings });
|
||||||
add({ id: 'fullscreen', group: 'View', icon: '⛶', label: document.fullscreenElement ? 'Exit fullscreen' : 'Fullscreen', run: toggleFullscreen });
|
add({ id: 'fullscreen', group: 'View', icon: '⛶', label: document.fullscreenElement ? 'Exit fullscreen' : 'Fullscreen', run: toggleFullscreen });
|
||||||
if (notifyState !== 'unsupported') {
|
if (notifyState !== 'unsupported') {
|
||||||
add({
|
add({
|
||||||
id: 'notify', group: 'View', icon: '🔔', keywords: 'notifications alerts bell',
|
id: 'notify', group: 'View', icon: '🔔', keywords: 'notifications alerts bell',
|
||||||
label: { default: 'Enable desktop notifications', denied: 'Desktop notifications (blocked)', granted: notify.isMuted() ? 'Unmute desktop notifications' : 'Mute desktop notifications' }[notifyState],
|
label: { default: 'Enable desktop notifications', denied: 'Desktop notifications (blocked)', granted: notify.isMuted() ? 'Unmute desktop notifications' : 'Mute desktop notifications' }[notifyState],
|
||||||
run: () => toggleNotifications().then(renderNotifyButton),
|
run: () => toggleNotifications(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,10 +933,16 @@ const KEY_HELP = [
|
|||||||
]],
|
]],
|
||||||
['DevDesk', [
|
['DevDesk', [
|
||||||
['Ctrl+⇧ P', 'Command palette'],
|
['Ctrl+⇧ P', 'Command palette'],
|
||||||
['Ctrl+⇧ E', 'Files: browse, preview, diff (Esc closes)'],
|
['Ctrl+⇧ E', 'Files: browse, preview, diff, edit (Esc closes)'],
|
||||||
|
['Ctrl+P', 'Files: go to file'],
|
||||||
|
['Ctrl+S', 'Files: save the open file'],
|
||||||
|
['Drag', 'Select text; it is copied when you let go'],
|
||||||
|
['Ctrl+C', 'Copy the selection (interrupts only when nothing is selected)'],
|
||||||
['Ctrl+⇧ C / V', 'Copy selection / paste'],
|
['Ctrl+⇧ C / V', 'Copy selection / paste'],
|
||||||
|
['Ctrl+⇧ F', 'Search in the session\'s files'],
|
||||||
['⇧ drag', 'Select text in the browser'],
|
['⇧ drag', 'Select text in the browser'],
|
||||||
['Drop or paste a file', 'Upload it and attach it for Claude'],
|
['Drop or paste a file', 'Upload it and attach it for Claude'],
|
||||||
|
['Click a file path', 'Open it in Files (at the line, if given)'],
|
||||||
['Ctrl+W', 'Asks before closing; Chromium fullscreen keeps it for the terminal'],
|
['Ctrl+W', 'Asks before closing; Chromium fullscreen keeps it for the terminal'],
|
||||||
]],
|
]],
|
||||||
];
|
];
|
||||||
@@ -743,17 +1000,6 @@ async function toggleFullscreen() {
|
|||||||
navigator.keyboard?.lock?.().catch(() => {});
|
navigator.keyboard?.lock?.().catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderNotifyButton() {
|
|
||||||
const current = notify.notificationsState();
|
|
||||||
const labels = {
|
|
||||||
unsupported: 'Desktop notifications: unsupported',
|
|
||||||
denied: 'Desktop notifications: blocked by the browser',
|
|
||||||
default: 'Enable desktop notifications',
|
|
||||||
granted: notify.isMuted() ? 'Desktop notifications: muted (unmute)' : 'Desktop notifications: on (mute)',
|
|
||||||
};
|
|
||||||
ui.notifyButton.textContent = labels[current];
|
|
||||||
ui.notifyButton.disabled = current === 'unsupported' || current === 'denied';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSidebar(open) {
|
function setSidebar(open) {
|
||||||
if (open && files?.isOpen) files.close();
|
if (open && files?.isOpen) files.close();
|
||||||
@@ -803,12 +1049,7 @@ function wireKeybar() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function wireChrome() {
|
function wireChrome() {
|
||||||
$('font-down').addEventListener('click', () => setFontSize(term.options.fontSize - 1));
|
$('settings-menu').addEventListener('click', showSettings);
|
||||||
$('font-up').addEventListener('click', () => setFontSize(term.options.fontSize + 1));
|
|
||||||
ui.notifyButton.addEventListener('click', async () => {
|
|
||||||
await toggleNotifications();
|
|
||||||
renderNotifyButton();
|
|
||||||
});
|
|
||||||
$('fullscreen-toggle').addEventListener('click', () => {
|
$('fullscreen-toggle').addEventListener('click', () => {
|
||||||
closeMenus();
|
closeMenus();
|
||||||
toggleFullscreen();
|
toggleFullscreen();
|
||||||
@@ -818,6 +1059,9 @@ function wireChrome() {
|
|||||||
showHelp();
|
showHelp();
|
||||||
});
|
});
|
||||||
$('footer-help').addEventListener('click', showHelp);
|
$('footer-help').addEventListener('click', showHelp);
|
||||||
|
$('history-menu').addEventListener('click', showHistory);
|
||||||
|
setInterval(presenceTick, 45 * 1000);
|
||||||
|
document.addEventListener('visibilitychange', presenceTick);
|
||||||
$('palette-menu').addEventListener('click', () => {
|
$('palette-menu').addEventListener('click', () => {
|
||||||
closeMenus();
|
closeMenus();
|
||||||
palette.open();
|
palette.open();
|
||||||
@@ -835,6 +1079,13 @@ function wireChrome() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
$('files-toggle').addEventListener('click', () => files.toggle());
|
$('files-toggle').addEventListener('click', () => files.toggle());
|
||||||
|
registerPathLinks(term, {
|
||||||
|
resolve: (printed) => (state.session ? files.resolvePrinted(printed) : null),
|
||||||
|
open: (path, line) => {
|
||||||
|
if (!files.isOpen) files.open();
|
||||||
|
files.openFile(path, { pin: true, line });
|
||||||
|
},
|
||||||
|
});
|
||||||
wireResizer();
|
wireResizer();
|
||||||
palette = new CommandPalette(buildCommands);
|
palette = new CommandPalette(buildCommands);
|
||||||
$('cmd-toggle').addEventListener('click', () => palette.open());
|
$('cmd-toggle').addEventListener('click', () => palette.open());
|
||||||
@@ -846,6 +1097,13 @@ function wireChrome() {
|
|||||||
if (state.session) files.toggle();
|
if (state.session) files.toggle();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Ctrl+Shift+F: search in the session's files (devdesk#56).
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyF') {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (state.session) files.openSearch();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') {
|
if ((event.ctrlKey || event.metaKey) && event.shiftKey && !event.altKey && event.code === 'KeyP') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
@@ -853,20 +1111,15 @@ function wireChrome() {
|
|||||||
else if (!dialogOpen()) palette.open();
|
else if (!dialogOpen()) palette.open();
|
||||||
}
|
}
|
||||||
}, true);
|
}, true);
|
||||||
wireMenu(ui.moreToggle, ui.moreMenu, () => {
|
wireMenu(ui.moreToggle, ui.moreMenu, () => {});
|
||||||
renderNotifyButton();
|
|
||||||
$('theme-menu').textContent = getTheme() === 'light' ? 'Dark mode' : 'Light mode';
|
|
||||||
});
|
|
||||||
$('theme-menu').addEventListener('click', () => {
|
|
||||||
closeMenus();
|
|
||||||
toggleTheme();
|
|
||||||
});
|
|
||||||
inbox = new Inbox({
|
inbox = new Inbox({
|
||||||
button: $('inbox-toggle'),
|
button: $('inbox-toggle'),
|
||||||
badge: $('inbox-badge'),
|
badge: $('inbox-badge'),
|
||||||
panel: $('inbox-panel'),
|
panel: $('inbox-panel'),
|
||||||
hooks: {
|
hooks: {
|
||||||
openSession: (name) => connect(name),
|
openSession: (name) => connect(name, { existing: true }),
|
||||||
|
ended: (name) => toast.info(`${name} has ended; its conversation is still in Claude's history`),
|
||||||
|
refresh: () => sessionsPanel.reload(),
|
||||||
showAlerts: () => {
|
showAlerts: () => {
|
||||||
setSidebar(true);
|
setSidebar(true);
|
||||||
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
|
$('widget-alerts').scrollIntoView({ block: 'start', behavior: 'smooth' });
|
||||||
@@ -896,6 +1149,9 @@ function wireChrome() {
|
|||||||
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
|
// Esc closes menus and the narrow-screen drawer before the terminal sees it.
|
||||||
document.addEventListener('keydown', (event) => {
|
document.addEventListener('keydown', (event) => {
|
||||||
if (event.key !== 'Escape' || dialogOpen()) return;
|
if (event.key !== 'Escape' || dialogOpen()) return;
|
||||||
|
// A right-click menu, the explorer's name box, or an explorer selection /
|
||||||
|
// cut to clear take their own Esc first (devdesk#54).
|
||||||
|
if (event.target.closest?.('.dd-ctx-menu, .dd-tree-input') || files?.wantsEscape(event)) return;
|
||||||
// Files pane: Esc goes back / closes, unless the terminal or CodeMirror's
|
// Files pane: Esc goes back / closes, unless the terminal or CodeMirror's
|
||||||
// own search panel has the focus.
|
// own search panel has the focus.
|
||||||
if (files?.isOpen && !event.target.closest?.('.xterm, .cm-panels') && !menus.some(({ menu }) => !menu.hidden) && !inbox.isOpen) {
|
if (files?.isOpen && !event.target.closest?.('.xterm, .cm-panels') && !menus.some(({ menu }) => !menu.hidden) && !inbox.isOpen) {
|
||||||
@@ -921,7 +1177,7 @@ function wireChrome() {
|
|||||||
|
|
||||||
// Ctrl+W closes the tab before the terminal sees it; ask first.
|
// Ctrl+W closes the tab before the terminal sees it; ask first.
|
||||||
window.addEventListener('beforeunload', (event) => {
|
window.addEventListener('beforeunload', (event) => {
|
||||||
if (state.conn?.isOpen && state.session) {
|
if ((state.conn?.isOpen && state.session) || files?.hasDirty()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
event.returnValue = '';
|
event.returnValue = '';
|
||||||
}
|
}
|
||||||
@@ -938,12 +1194,20 @@ function wireChrome() {
|
|||||||
|
|
||||||
window.addEventListener('online', retrySoon);
|
window.addEventListener('online', retrySoon);
|
||||||
document.addEventListener('visibilitychange', () => {
|
document.addEventListener('visibilitychange', () => {
|
||||||
if (!document.hidden) retrySoon();
|
if (!document.hidden) {
|
||||||
|
retrySoon();
|
||||||
|
inbox.markSessionRead(state.session);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// Session states feed the header chip and the notifications for other sessions,
|
// Session states feed the header chip and the notifications for other sessions,
|
||||||
// so poll even in a background tab (browsers throttle that to about once a
|
// so poll even in a background tab (browsers throttle that to about once a
|
||||||
// minute) — unless another tab just shared a fresh list (devdesk#8, #15).
|
// minute) — unless another tab just shared a fresh list (devdesk#8, #15).
|
||||||
onShared('sessions', (data) => sessionsPanel.accept(data));
|
onShared('sessions', (data) => sessionsPanel.accept(data));
|
||||||
|
// The turn timer and the usage pace move between polls.
|
||||||
|
setInterval(() => {
|
||||||
|
renderSessionChip();
|
||||||
|
renderUsage();
|
||||||
|
}, 10 * 1000);
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (!heardWithin('sessions', 16 * 1000)) sessionsPanel.refresh();
|
if (!heardWithin('sessions', 16 * 1000)) sessionsPanel.refresh();
|
||||||
}, 15 * 1000);
|
}, 15 * 1000);
|
||||||
@@ -957,7 +1221,7 @@ function wireChrome() {
|
|||||||
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo();
|
if (!document.hidden && ui.body.classList.contains('dd-sidebar-open')) refreshRepo();
|
||||||
}, 60 * 1000);
|
}, 60 * 1000);
|
||||||
ui.attention.addEventListener('click', () => {
|
ui.attention.addEventListener('click', () => {
|
||||||
if (ui.attention.dataset.target) connect(ui.attention.dataset.target);
|
if (ui.attention.dataset.target) connect(ui.attention.dataset.target, { existing: true });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -978,13 +1242,14 @@ async function boot() {
|
|||||||
initTheme();
|
initTheme();
|
||||||
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
|
$('version').textContent = `devdesk ${__DEVDESK_VERSION__}`;
|
||||||
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
|
const fontSize = Math.min(24, Math.max(10, Number(store.get('fontSize', 14)) || 14));
|
||||||
$('font-size').textContent = String(fontSize);
|
applyUiScale(Number(store.get('uiScale', 1)) || 1);
|
||||||
await loadFonts(fontSize);
|
await loadFonts(fontSize);
|
||||||
|
|
||||||
({ term, fit } = createTerminal($('terminal'), {
|
({ term, fit } = createTerminal($('terminal'), {
|
||||||
fontSize,
|
fontSize,
|
||||||
theme: getTheme(),
|
theme: getTheme(),
|
||||||
onShiftEnter: () => state.conn?.sendInput('\n'),
|
onShiftEnter: () => state.conn?.sendInput('\n'),
|
||||||
|
onCopied: (text) => toast.success(`Copied ${text.length.toLocaleString()} character${text.length === 1 ? '' : 's'}`, 1500),
|
||||||
}));
|
}));
|
||||||
onThemeChange((theme) => {
|
onThemeChange((theme) => {
|
||||||
term.options.theme = THEMES[theme];
|
term.options.theme = THEMES[theme];
|
||||||
@@ -1021,6 +1286,7 @@ async function boot() {
|
|||||||
form: $('new-session-form'),
|
form: $('new-session-form'),
|
||||||
nameInput: $('new-session-name'),
|
nameInput: $('new-session-name'),
|
||||||
select: $('project-select'),
|
select: $('project-select'),
|
||||||
|
worktree: $('new-session-worktree'),
|
||||||
refreshButton: $('sessions-refresh'),
|
refreshButton: $('sessions-refresh'),
|
||||||
newToggle: $('new-session-toggle'),
|
newToggle: $('new-session-toggle'),
|
||||||
status: $('sessions-status'),
|
status: $('sessions-status'),
|
||||||
@@ -1030,6 +1296,8 @@ async function boot() {
|
|||||||
connect(name);
|
connect(name);
|
||||||
},
|
},
|
||||||
onEnding: () => { state.endingCurrent = true; },
|
onEnding: () => { state.endingCurrent = true; },
|
||||||
|
onCompact: (name) => compact(name),
|
||||||
|
onCopyReply: (name) => copyReply(name),
|
||||||
onEnded: (name) => {
|
onEnded: (name) => {
|
||||||
if (name !== state.session) state.endingCurrent = false;
|
if (name !== state.session) state.endingCurrent = false;
|
||||||
},
|
},
|
||||||
@@ -1045,6 +1313,10 @@ async function boot() {
|
|||||||
onData: (_data, meta) => onSessionData(meta),
|
onData: (_data, meta) => onSessionData(meta),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
$('widget-alerts').addEventListener('click', (event) => {
|
||||||
|
const button = event.target.closest('[data-investigate]');
|
||||||
|
if (button) investigate(button);
|
||||||
|
});
|
||||||
bindAskClaude($('sidebar'), (text) => {
|
bindAskClaude($('sidebar'), (text) => {
|
||||||
// Alert text is untrusted: no control characters (a newline would run it in a
|
// Alert text is untrusted: no control characters (a newline would run it in a
|
||||||
// shell window, CR would submit), and bracketed paste around it (devdesk#2).
|
// shell window, CR would submit), and bracketed paste around it (devdesk#2).
|
||||||
@@ -1057,7 +1329,8 @@ async function boot() {
|
|||||||
wireUploads();
|
wireUploads();
|
||||||
refreshAlerts();
|
refreshAlerts();
|
||||||
await sessionsPanel.refresh();
|
await sessionsPanel.refresh();
|
||||||
connect(urlSession ?? defaultSession(sessionsPanel.data));
|
if (urlSession) connect(urlSession, { existing: urlExisting });
|
||||||
|
else connect(defaultSession(sessionsPanel.data), { existing: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
boot();
|
boot();
|
||||||
|
|||||||
+9
-1
@@ -6,13 +6,14 @@ import { FAVICON, FAVICON_BADGE } from './icon.js';
|
|||||||
|
|
||||||
let unread = 0;
|
let unread = 0;
|
||||||
let baseTitle = document.title;
|
let baseTitle = document.title;
|
||||||
|
let statusPrefix = '';
|
||||||
|
|
||||||
function attentive() {
|
function attentive() {
|
||||||
return !document.hidden && document.hasFocus();
|
return !document.hidden && document.hasFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
document.title = unread ? `(${unread}) ${baseTitle}` : baseTitle;
|
document.title = `${unread ? `(${unread}) ` : ''}${statusPrefix}${baseTitle}`;
|
||||||
const link = document.getElementById('favicon');
|
const link = document.getElementById('favicon');
|
||||||
if (link) link.href = unread ? FAVICON_BADGE : FAVICON;
|
if (link) link.href = unread ? FAVICON_BADGE : FAVICON;
|
||||||
}
|
}
|
||||||
@@ -22,6 +23,13 @@ export function setBaseTitle(title) {
|
|||||||
render();
|
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() {
|
export function notificationsState() {
|
||||||
if (!('Notification' in window)) return 'unsupported';
|
if (!('Notification' in window)) return 'unsupported';
|
||||||
return Notification.permission; // granted | denied | default
|
return Notification.permission; // granted | denied | default
|
||||||
|
|||||||
+46
-2
@@ -18,13 +18,16 @@ function link(href, text) {
|
|||||||
return a;
|
return a;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renderRepo(root, repo) {
|
// hooks: openDiff(path, diffable), openChanges() (devdesk#46)
|
||||||
|
export function renderRepo(root, repo, hooks = {}) {
|
||||||
const field = (name) => root.querySelector(`[data-field="${name}"]`);
|
const field = (name) => root.querySelector(`[data-field="${name}"]`);
|
||||||
const set = (name, value) => field(name).replaceChildren(value);
|
const set = (name, value) => field(name).replaceChildren(value);
|
||||||
const grid = root.querySelector('.dd-repo-grid');
|
const grid = root.querySelector('.dd-repo-grid');
|
||||||
const note = root.querySelector('.dd-repo-note');
|
const note = root.querySelector('.dd-repo-note');
|
||||||
const fix = root.querySelector('.dd-fix-ci');
|
const fix = root.querySelector('.dd-fix-ci');
|
||||||
fix.hidden = true;
|
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).
|
// No repository: one line instead of a grid of dashes (devdesk#17).
|
||||||
if (!repo || !repo.git) {
|
if (!repo || !repo.git) {
|
||||||
@@ -41,7 +44,48 @@ export function renderRepo(root, repo) {
|
|||||||
? [repo.ahead ? `↑${repo.ahead}` : '', repo.behind ? `↓${repo.behind}` : ''].filter(Boolean).join(' ') || 'in sync'
|
? [repo.ahead ? `↑${repo.ahead}` : '', repo.behind ? `↓${repo.behind}` : ''].filter(Boolean).join(' ') || 'in sync'
|
||||||
: 'no upstream';
|
: 'no upstream';
|
||||||
set('branch', `${repo.branch} @ ${repo.sha || '—'} · ${sync}`);
|
set('branch', `${repo.branch} @ ${repo.sha || '—'} · ${sync}`);
|
||||||
set('dirty', repo.dirty ? `${repo.dirty} changed` : 'clean');
|
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 ci = field('ci');
|
||||||
const [label, cls] = repo.ci?.total ? (CI[repo.ci.state] || [repo.ci.state, 'lt-status-pending'])
|
const [label, cls] = repo.ci?.total ? (CI[repo.ci.state] || [repo.ci.state, 'lt-status-pending'])
|
||||||
|
|||||||
+71
-14
@@ -46,6 +46,22 @@ function renameRecent(from, to) {
|
|||||||
writeRecents(readRecents().map((r) => (r.name === from ? { ...r, name: to } : r)));
|
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) {
|
export function ago(epochSeconds) {
|
||||||
if (!epochSeconds) return '';
|
if (!epochSeconds) return '';
|
||||||
const s = Math.max(0, Date.now() / 1000 - epochSeconds);
|
const s = Math.max(0, Date.now() / 1000 - epochSeconds);
|
||||||
@@ -71,9 +87,11 @@ export function sessionStatus(s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class SessionsPanel {
|
export class SessionsPanel {
|
||||||
// hooks: onOpen(name), onEnding(name), onEnded(name), onEndAborted(), onRenamed(from, to), onData(data)
|
// hooks: onOpen(name), onEnding(name), onEnded(name), onEndAborted(), onRenamed(from, to), onData(data),
|
||||||
constructor({ list, form, nameInput, select, refreshButton, newToggle, status, hooks }) {
|
// onCompact(name), onCopyReply(name)
|
||||||
Object.assign(this, { list, form, nameInput, select, newToggle, status, hooks });
|
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.current = null;
|
||||||
this.data = null;
|
this.data = null;
|
||||||
this.pending = null;
|
this.pending = null;
|
||||||
@@ -145,6 +163,14 @@ export class SessionsPanel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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() {
|
async createFromForm() {
|
||||||
const project = this.select.value;
|
const project = this.select.value;
|
||||||
const name = slugify(this.nameInput.value) || project;
|
const name = slugify(this.nameInput.value) || project;
|
||||||
@@ -153,16 +179,17 @@ export class SessionsPanel {
|
|||||||
this.nameInput.focus();
|
this.nameInput.focus();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!(await this.create(name, project))) return;
|
if (!(await this.create(name, project, this.worktree?.checked))) return;
|
||||||
this.nameInput.value = '';
|
this.nameInput.value = '';
|
||||||
this.select.value = '';
|
this.select.value = '';
|
||||||
|
this.worktreeOption(this.select, this.worktree);
|
||||||
this.toggleForm(false);
|
this.toggleForm(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// With a project the session starts in that folder; without one, opening it
|
// 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).
|
// creates it in the default folder (or its own project folder, by name).
|
||||||
async create(name, project) {
|
async create(name, project, worktree = false) {
|
||||||
if (project && !(await this.act('new', name, project))) return false;
|
if (project && !(await this.act('new', name, project, ...(worktree ? ['--worktree'] : [])))) return false;
|
||||||
this.hooks.onOpen(name);
|
this.hooks.onOpen(name);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -183,14 +210,19 @@ export class SessionsPanel {
|
|||||||
const project = el('select', { class: 'lt-select' });
|
const project = el('select', { class: 'lt-select' });
|
||||||
this.projectOptions(project);
|
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 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', () => {
|
project.addEventListener('change', () => {
|
||||||
if (!name.value.trim()) name.placeholder = project.value || 'e.g. pbs-upgrade';
|
if (!name.value.trim()) name.placeholder = project.value || 'e.g. pbs-upgrade';
|
||||||
|
this.worktreeOption(project, worktree);
|
||||||
});
|
});
|
||||||
const result = await openDialog({
|
const result = await openDialog({
|
||||||
title: 'New session',
|
title: 'New session',
|
||||||
body: el('div', { class: 'dd-dialog-fields' },
|
body: el('div', { class: 'dd-dialog-fields' },
|
||||||
el('label', { class: 'dd-field' }, el('span', {}, 'Name'), name),
|
el('label', { class: 'dd-field' }, el('span', {}, 'Name'), name),
|
||||||
el('label', { class: 'dd-field' }, el('span', {}, 'Project folder'), project),
|
el('label', { class: 'dd-field' }, el('span', {}, 'Project folder'), project),
|
||||||
|
worktreeLabel,
|
||||||
hint),
|
hint),
|
||||||
actions: [{ label: 'Cancel' }, { label: 'Open', primary: true }],
|
actions: [{ label: 'Cancel' }, { label: 'Open', primary: true }],
|
||||||
submit: () => {
|
submit: () => {
|
||||||
@@ -201,10 +233,10 @@ export class SessionsPanel {
|
|||||||
name.focus();
|
name.focus();
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return { name: value, project: project.value };
|
return { name: value, project: project.value, worktree: worktree.checked };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (result) await this.create(result.name, result.project);
|
if (result) await this.create(result.name, result.project, result.worktree);
|
||||||
}
|
}
|
||||||
|
|
||||||
async rename(name) {
|
async rename(name) {
|
||||||
@@ -246,9 +278,10 @@ export class SessionsPanel {
|
|||||||
async end(session) {
|
async end(session) {
|
||||||
const ok = await confirmDialog({
|
const ok = await confirmDialog({
|
||||||
title: session.running ? `End ${session.name}?` : `Forget ${session.name}?`,
|
title: session.running ? `End ${session.name}?` : `Forget ${session.name}?`,
|
||||||
text: session.running
|
text: (session.running
|
||||||
? 'Claude in this session stops and the session closes. The conversation stays in Claude\'s history (claude --resume).'
|
? '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.',
|
: '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',
|
confirmLabel: session.running ? 'End session' : 'Forget',
|
||||||
danger: true,
|
danger: true,
|
||||||
});
|
});
|
||||||
@@ -267,8 +300,9 @@ export class SessionsPanel {
|
|||||||
renderSession(s) {
|
renderSession(s) {
|
||||||
const isCurrent = s.name === this.current;
|
const isCurrent = s.name === this.current;
|
||||||
const status = sessionStatus(s);
|
const status = sessionStatus(s);
|
||||||
const ctx = s.context?.used_pct != null ? `ctx ${Math.floor(s.context.used_pct)}%` : '';
|
const ctx = ctxLabel(s.context);
|
||||||
const since = s.status?.since ? ago(s.status.since) : ago(s.activity);
|
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 model = isCurrent && s.context?.model ? s.context.model.replace(/^Claude\s+/i, '') : '';
|
||||||
const meta = s.running
|
const meta = s.running
|
||||||
? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, model, s.attached ? `${s.attached} open` : '']
|
? [`${status.label}${since && since !== 'now' ? ` ${since}` : ''}`, ctx, model, s.attached ? `${s.attached} open` : '']
|
||||||
@@ -278,18 +312,33 @@ export class SessionsPanel {
|
|||||||
const title = [
|
const title = [
|
||||||
s.dir,
|
s.dir,
|
||||||
status.key === 'waiting' && s.status?.message,
|
status.key === 'waiting' && s.status?.message,
|
||||||
s.context?.model && `${s.context.model}${s.context.cost_usd != null ? ` · $${s.context.cost_usd.toFixed(2)}` : ''}`,
|
// 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');
|
].filter(Boolean).join('\n');
|
||||||
const iconButton = (label, glyph, onclick, extra = '') =>
|
const iconButton = (label, glyph, onclick, extra = '') =>
|
||||||
el('button', { class: `dd-icon-btn ${extra}`, type: 'button', title: label, 'aria-label': `${label} ${s.name}`, onclick }, glyph);
|
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' : ''}` },
|
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('button', { class: 'dd-session-open', type: 'button', title, onclick: () => this.hooks.onOpen(s.name) },
|
||||||
el('span', { class: `lt-dot ${status.dot}` }),
|
el('span', { class: `lt-dot ${status.dot}` }),
|
||||||
el('span', { class: 'dd-session-name' }, s.name),
|
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),
|
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' },
|
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-ctx-fill', style: `width:${Math.min(100, pct)}%` })) : null),
|
||||||
el('span', { class: 'dd-session-actions' },
|
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)),
|
iconButton('Rename', '✎', () => this.rename(s.name)),
|
||||||
el('a', {
|
el('a', {
|
||||||
class: 'dd-icon-btn', href: `?arg=${encodeURIComponent(s.name)}`, target: '_blank', rel: 'noopener',
|
class: 'dd-icon-btn', href: `?arg=${encodeURIComponent(s.name)}`, target: '_blank', rel: 'noopener',
|
||||||
@@ -319,7 +368,15 @@ export class SessionsPanel {
|
|||||||
}, '×'))));
|
}, '×'))));
|
||||||
}
|
}
|
||||||
if (!items.length) items.push(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);
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
this.projectOptions(this.select);
|
this.projectOptions(this.select);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+43
-2
@@ -90,7 +90,7 @@ export async function loadFonts(size) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter }) {
|
export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnter, onCopied }) {
|
||||||
const term = new Terminal({
|
const term = new Terminal({
|
||||||
fontFamily: FONT_FAMILY,
|
fontFamily: FONT_FAMILY,
|
||||||
fontSize,
|
fontSize,
|
||||||
@@ -113,16 +113,28 @@ export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnt
|
|||||||
window.open(uri, '_blank', 'noopener');
|
window.open(uri, '_blank', 'noopener');
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const copySelection = () => {
|
||||||
|
const text = term.getSelection();
|
||||||
|
navigator.clipboard?.writeText(text).then(() => onCopied?.(text), () => {});
|
||||||
|
};
|
||||||
|
|
||||||
term.attachCustomKeyEventHandler((event) => {
|
term.attachCustomKeyEventHandler((event) => {
|
||||||
// Shift+Enter: newline in Claude's prompt (Ctrl+J), not submit.
|
// 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.key === 'Enter' && event.shiftKey && !event.ctrlKey && !event.altKey && !event.metaKey) {
|
||||||
if (event.type === 'keydown') onShiftEnter();
|
if (event.type === 'keydown') onShiftEnter();
|
||||||
return false;
|
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.
|
// Ctrl+Shift+C / Ctrl+Shift+V: browser clipboard, like desktop terminals.
|
||||||
if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && !event.altKey) {
|
if (event.type === 'keydown' && event.ctrlKey && event.shiftKey && !event.altKey) {
|
||||||
if (event.code === 'KeyC' && term.hasSelection()) {
|
if (event.code === 'KeyC' && term.hasSelection()) {
|
||||||
navigator.clipboard?.writeText(term.getSelection()).catch(() => {});
|
copySelection();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (event.code === 'KeyV') {
|
if (event.code === 'KeyV') {
|
||||||
@@ -135,6 +147,35 @@ export function createTerminal(container, { fontSize, theme = 'dark', onShiftEnt
|
|||||||
|
|
||||||
term.open(container);
|
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.
|
// Touch devices: the DOM renderer is more reliable at high DPR than WebGL.
|
||||||
if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit };
|
if (window.matchMedia?.('(pointer: coarse)').matches) return { term, fit };
|
||||||
try {
|
try {
|
||||||
|
|||||||
+88
-7
@@ -24,13 +24,21 @@ function dismiss(node) {
|
|||||||
setTimeout(() => node.remove(), 250);
|
setTimeout(() => node.remove(), 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function toast(message, type = 'info', duration = 4000) {
|
// `actions`: [{ label, run }] buttons (e.g. Undo); clicking one dismisses the toast.
|
||||||
|
export function toast(message, type = 'info', duration = 4000, actions = []) {
|
||||||
const box = toastContainer();
|
const box = toastContainer();
|
||||||
// At most four on screen: the oldest goes first.
|
// At most four on screen: the oldest goes first.
|
||||||
while (box.children.length >= 4) box.firstElementChild.remove();
|
while (box.children.length >= 4) box.firstElementChild.remove();
|
||||||
const node = el('div', { class: `lt-toast lt-toast-${type}`, role: type === 'error' ? 'alert' : 'status' },
|
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-icon', 'aria-hidden': 'true' }, `[${ICONS[type] || 'i'}]`),
|
||||||
el('span', { class: 'lt-toast-msg' }, message),
|
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('button', { type: 'button', class: 'lt-toast-close', 'aria-label': 'Dismiss', onclick: () => dismiss(node) }, '✕'),
|
||||||
el('div', { class: 'lt-toast-progress', style: `animation-duration:${duration}ms` }));
|
el('div', { class: 'lt-toast-progress', style: `animation-duration:${duration}ms` }));
|
||||||
box.append(node);
|
box.append(node);
|
||||||
@@ -39,10 +47,83 @@ export function toast(message, type = 'info', duration = 4000) {
|
|||||||
node.addEventListener('mouseleave', () => { node.dismissTimer = setTimeout(() => dismiss(node), 1500); });
|
node.addEventListener('mouseleave', () => { node.dismissTimer = setTimeout(() => dismiss(node), 1500); });
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.success = (message, duration) => toast(message, 'success', duration);
|
toast.success = (message, duration, actions) => toast(message, 'success', duration, actions);
|
||||||
toast.error = (message, duration = 8000) => toast(message, 'error', duration);
|
toast.error = (message, duration = 8000, actions) => toast(message, 'error', duration, actions);
|
||||||
toast.warning = (message, duration) => toast(message, 'warning', duration);
|
toast.warning = (message, duration, actions) => toast(message, 'warning', duration, actions);
|
||||||
toast.info = (message, duration) => toast(message, 'info', duration);
|
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
|
// ---------------------------------------------------------------- dialogs
|
||||||
|
|
||||||
@@ -52,7 +133,7 @@ export const dialogOpen = () => openDialogs > 0 || !!document.querySelector('.lt
|
|||||||
// A .lt-modal dialog. `body` is a node; `actions` are { label, value, primary,
|
// 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
|
// danger }. Resolves with the chosen action's value (or `submit()`'s value when
|
||||||
// a form is submitted), or null when dismissed (✕, Esc, backdrop).
|
// a form is submitted), or null when dismissed (✕, Esc, backdrop).
|
||||||
export function openDialog({ title, body, actions = [], danger = false, size = '', onOpen, submit }) {
|
export function openDialog({ title, body, actions = [], danger = false, size = '', className = '', onOpen, submit }) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const opener = document.activeElement;
|
const opener = document.activeElement;
|
||||||
let done = false;
|
let done = false;
|
||||||
@@ -81,7 +162,7 @@ export function openDialog({ title, body, actions = [], danger = false, size = '
|
|||||||
if (button.type === 'submit') button.setAttribute('form', titleId);
|
if (button.type === 'submit') button.setAttribute('form', titleId);
|
||||||
}
|
}
|
||||||
form.id = titleId;
|
form.id = titleId;
|
||||||
const overlay = el('div', { class: 'lt-modal-overlay is-open dd-dialog', role: 'dialog', 'aria-modal': 'true', 'aria-labelledby': `${titleId}-title` },
|
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${size ? ` lt-modal-${size}` : ''}` },
|
||||||
el('div', { class: `lt-modal-header${danger ? ' lt-modal-header--danger' : ''}` },
|
el('div', { class: `lt-modal-header${danger ? ' lt-modal-header--danger' : ''}` },
|
||||||
el('h2', { class: 'lt-modal-title', id: `${titleId}-title` }, title),
|
el('h2', { class: 'lt-modal-title', id: `${titleId}-title` }, title),
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
// "Ask Claude": any [data-ask-claude] button (Ops Alerts, Repo/CI) types its
|
// "Ask Claude": any [data-ask-claude] button (Repo/CI) types its prompt into the
|
||||||
// prompt into the current session without pressing Enter, so you can review it
|
// current session without pressing Enter, so you can review it before sending.
|
||||||
// before sending.
|
// (Ops Alerts start their own session instead: devdesk#39.)
|
||||||
|
|
||||||
export function bindAskClaude(root, send) {
|
export function bindAskClaude(root, send) {
|
||||||
root.addEventListener('click', (event) => {
|
root.addEventListener('click', (event) => {
|
||||||
|
|||||||
+254
-5
@@ -2,7 +2,7 @@
|
|||||||
everything visual comes from the TDS tokens defined in base.css :root. */
|
everything visual comes from the TDS tokens defined in base.css :root. */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--dd-footer-height: 30px;
|
--dd-footer-height: 40px;
|
||||||
--dd-sidebar-width: 320px;
|
--dd-sidebar-width: 320px;
|
||||||
--dd-gap: 8px;
|
--dd-gap: 8px;
|
||||||
}
|
}
|
||||||
@@ -239,6 +239,13 @@ body.dd-sidebar-open .dd-sidebar {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
margin-bottom: var(--space-sm);
|
margin-bottom: var(--space-sm);
|
||||||
|
/* Any number of sessions: the list scrolls on its own so the panels below stay
|
||||||
|
in reach (devdesk#53). */
|
||||||
|
max-height: 46vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--bg-tertiary) transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dd-session-item {
|
.dd-session-item {
|
||||||
@@ -375,6 +382,7 @@ body.dd-sidebar-open .dd-sidebar {
|
|||||||
}
|
}
|
||||||
.dd-new-session .lt-input { grid-column: 1 / -1; font-size: 0.74rem; }
|
.dd-new-session .lt-input { grid-column: 1 / -1; font-size: 0.74rem; }
|
||||||
.dd-new-session .lt-select { min-width: 0; font-size: 0.72rem; }
|
.dd-new-session .lt-select { min-width: 0; font-size: 0.72rem; }
|
||||||
|
.dd-new-session .dd-check { grid-column: 1 / -1; }
|
||||||
|
|
||||||
.dd-status.is-error { color: var(--accent-red); }
|
.dd-status.is-error { color: var(--accent-red); }
|
||||||
|
|
||||||
@@ -402,6 +410,9 @@ body.dd-sidebar-open .dd-sidebar {
|
|||||||
.dd-alert-name { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.dd-alert-name { font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.dd-alert-age { margin-left: auto; white-space: nowrap; }
|
.dd-alert-age { margin-left: auto; white-space: nowrap; }
|
||||||
.dd-alert-instances { overflow-wrap: anywhere; }
|
.dd-alert-instances { overflow-wrap: anywhere; }
|
||||||
|
.dd-alert-where { color: var(--accent-cyan); font-family: var(--font-mono); overflow-wrap: anywhere; }
|
||||||
|
.dd-alert-where::before { content: "\2192 "; color: var(--text-muted); }
|
||||||
|
.dd-alert-where.is-guess { color: var(--text-secondary); }
|
||||||
.dd-alert-summary { color: var(--text-secondary); }
|
.dd-alert-summary { color: var(--text-secondary); }
|
||||||
.dd-alert .dd-ask { align-self: flex-start; margin-top: 2px; }
|
.dd-alert .dd-ask { align-self: flex-start; margin-top: 2px; }
|
||||||
.dd-all-clear { display: flex; align-items: center; gap: var(--space-sm); color: var(--accent-green); font-size: 0.74rem; }
|
.dd-all-clear { display: flex; align-items: center; gap: var(--space-sm); color: var(--accent-green); font-size: 0.74rem; }
|
||||||
@@ -489,7 +500,23 @@ body.dd-sidebar-open .dd-sidebar {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dd-footer .lt-footer-key { margin-right: 4px; }
|
.dd-footer .lt-footer-key { margin-right: 5px; font-size: 0.86rem; }
|
||||||
|
/* devdesk#41: the bottom row was too small to read at a glance */
|
||||||
|
.dd-footer .lt-footer-hint { font-size: 0.9rem; }
|
||||||
|
.dd-footer .lt-footer-sep { font-size: 0.9rem; }
|
||||||
|
/* Narrower windows keep the usage meters and drop the least-used hints. */
|
||||||
|
@media (max-width: 1700px) {
|
||||||
|
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 13),
|
||||||
|
.dd-footer .lt-footer-sep:nth-child(n + 12) { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 1500px) {
|
||||||
|
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 11),
|
||||||
|
.dd-footer .lt-footer-sep:nth-child(n + 10) { display: none; }
|
||||||
|
}
|
||||||
|
@media (max-width: 1250px) {
|
||||||
|
.dd-footer .lt-footer-hints .lt-footer-hint:nth-child(n + 7),
|
||||||
|
.dd-footer .lt-footer-sep:nth-child(n + 6) { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.dd-keybar { display: flex; gap: 4px; width: 100%; overflow-x: auto; scrollbar-width: none; }
|
.dd-keybar { display: flex; gap: 4px; width: 100%; overflow-x: auto; scrollbar-width: none; }
|
||||||
.dd-keybar[hidden] { display: none; }
|
.dd-keybar[hidden] { display: none; }
|
||||||
@@ -622,7 +649,7 @@ html[data-theme="light"] .lt-cmd-item.is-selected { background: var(--accent-cya
|
|||||||
background: none;
|
background: none;
|
||||||
color: var(--accent-cyan);
|
color: var(--accent-cyan);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.64rem;
|
font-size: 0.8rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.dd-footer-help:hover { border-color: var(--accent-cyan); }
|
.dd-footer-help:hover { border-color: var(--accent-cyan); }
|
||||||
@@ -772,7 +799,7 @@ body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transpar
|
|||||||
.dd-explorer-resizer { flex: 0 0 4px; margin-left: -2px; margin-right: -2px; cursor: col-resize; z-index: 2; }
|
.dd-explorer-resizer { flex: 0 0 4px; margin-left: -2px; margin-right: -2px; cursor: col-resize; z-index: 2; }
|
||||||
.dd-explorer-resizer:hover { background: var(--accent-cyan); }
|
.dd-explorer-resizer:hover { background: var(--accent-cyan); }
|
||||||
.dd-explorer-views { display: flex; border-bottom: 1px solid var(--border-dim); }
|
.dd-explorer-views { display: flex; border-bottom: 1px solid var(--border-dim); }
|
||||||
.dd-explorer-views button {
|
.dd-explorer-views button[role="tab"] {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 6px 4px 5px;
|
padding: 6px 4px 5px;
|
||||||
border: 0;
|
border: 0;
|
||||||
@@ -785,7 +812,7 @@ body.dd-resizing .dd-files-resizer { background: linear-gradient(90deg, transpar
|
|||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.dd-explorer-views button:hover { color: var(--text-primary); }
|
.dd-explorer-views button[role="tab"]:hover { color: var(--text-primary); }
|
||||||
.dd-explorer-views button[aria-selected="true"] { color: var(--accent-orange); border-bottom-color: var(--accent-orange); }
|
.dd-explorer-views button[aria-selected="true"] { color: var(--accent-orange); border-bottom-color: var(--accent-orange); }
|
||||||
.dd-files-count:not(:empty) { margin-left: 3px; padding: 0 5px; border-radius: 8px; background: var(--accent-amber-dim); color: var(--accent-amber); font-size: 0.6rem; }
|
.dd-files-count:not(:empty) { margin-left: 3px; padding: 0 5px; border-radius: 8px; background: var(--accent-amber-dim); color: var(--accent-amber); font-size: 0.6rem; }
|
||||||
.dd-explorer-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 0 12px; outline: none; }
|
.dd-explorer-body { flex: 1; min-height: 0; overflow: auto; padding: 4px 0 12px; outline: none; }
|
||||||
@@ -924,3 +951,225 @@ html[data-theme="light"] .dd-files.is-narrow .dd-explorer { box-shadow: 8px 0 24
|
|||||||
.dd-tree-row { min-height: 34px; }
|
.dd-tree-row { min-height: 34px; }
|
||||||
.dd-tab-close { opacity: 0.7; width: 28px; height: 28px; }
|
.dd-tab-close { opacity: 0.7; width: 28px; height: 28px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- editing (devdesk#20) ------------------------------------------------------------- */
|
||||||
|
.dd-tab-view:not(.is-dirty) .dd-dirty-only { display: none; }
|
||||||
|
.dd-unsaved { color: var(--accent-amber); }
|
||||||
|
.dd-tab.is-dirty .dd-tab-close { opacity: 1; color: var(--accent-amber); }
|
||||||
|
.dd-tab.is-dirty .dd-tab-close:hover { color: var(--accent-red); }
|
||||||
|
.dd-viewer-btn.is-primary { border-color: var(--accent-orange); color: var(--accent-orange); }
|
||||||
|
.dd-viewer-btn.is-primary:hover { background: var(--accent-orange-dim); }
|
||||||
|
.dd-viewer-banner {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px 10px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-bottom: 1px solid var(--accent-amber-dim);
|
||||||
|
background: var(--accent-amber-dim);
|
||||||
|
color: var(--accent-amber);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
.dd-dialog.is-wide .lt-modal { width: min(1100px, 94vw); max-height: 88vh; }
|
||||||
|
.dd-dialog.is-wide .lt-modal-body { padding: 0; }
|
||||||
|
.dd-compare { display: flex; height: min(64vh, 640px); }
|
||||||
|
.dd-compare > .cm-editor { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
/* ---- inbox: answer & peek (devdesk#22, #23) --------------------------------------------- */
|
||||||
|
.dd-inbox-panel.is-peek { width: min(460px, calc(100vw - 16px)); }
|
||||||
|
.dd-request-desc { margin-top: 4px; color: var(--text-secondary); font-size: 0.7rem; }
|
||||||
|
.dd-request {
|
||||||
|
display: block;
|
||||||
|
max-height: 7.5em;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
overflow: auto;
|
||||||
|
border-left: 2px solid var(--accent-amber);
|
||||||
|
background: var(--bg-terminal);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.7rem;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.dd-request-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
|
||||||
|
.dd-peek { display: flex; flex-direction: column; gap: 8px; padding: 8px 10px 10px; }
|
||||||
|
.dd-peek-head { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.dd-peek-back { padding: 0 6px; border: 1px solid var(--border-color); background: none; color: var(--accent-cyan); cursor: pointer; }
|
||||||
|
.dd-peek-title { flex: 1; color: var(--text-primary); font-weight: 700; font-size: 0.8rem; }
|
||||||
|
.dd-peek-state { color: var(--text-muted); font-size: 0.66rem; }
|
||||||
|
.dd-peek-label { display: block; margin-bottom: 2px; color: var(--accent-orange); font-size: 0.6rem; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||||
|
.dd-peek-user { padding: 6px 8px; border-left: 2px solid var(--border-color); color: var(--text-secondary); font-size: 0.72rem; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.dd-peek-assistant { max-height: 38vh; padding: 6px 8px; overflow: auto; border-left: 2px solid var(--accent-cyan); background: var(--bg-terminal); color: var(--text-primary); font-size: 0.74rem; line-height: 1.5; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.dd-peek-input { width: 100%; min-height: 4.5em; resize: vertical; font-family: var(--font-mono); font-size: 0.76rem; }
|
||||||
|
.dd-peek-actions { display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
|
||||||
|
.dd-ask-row { display: flex; flex-wrap: wrap; gap: 6px; align-self: flex-start; margin-top: 2px; }
|
||||||
|
.dd-alert .dd-ask-row .dd-ask { margin-top: 0; }
|
||||||
|
|
||||||
|
/* ---- ops trail (devdesk#27) ------------------------------------------------------------- */
|
||||||
|
.dd-tags { display: inline-flex; flex-wrap: wrap; gap: 3px; margin-left: 6px; vertical-align: middle; }
|
||||||
|
.dd-tag { padding: 0 4px; border: 1px solid var(--accent-cyan-border); color: var(--accent-cyan); font-size: 0.58rem; font-weight: 400; letter-spacing: 0; }
|
||||||
|
.dd-peek-commands { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.dd-peek-command { display: flex; gap: 8px; min-width: 0; font-size: 0.68rem; }
|
||||||
|
.dd-peek-command code { overflow: hidden; color: var(--text-secondary); font-family: var(--font-mono); text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dd-peek-when { flex: 0 0 2.6em; color: var(--text-muted); text-align: right; }
|
||||||
|
|
||||||
|
/* ---- history, usage, PR (devdesk#24-#26) ------------------------------------------- */
|
||||||
|
.dd-history { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; }
|
||||||
|
.dd-history-input { width: 100%; font-size: 0.82rem; }
|
||||||
|
.dd-history-results { display: flex; flex-direction: column; gap: 8px; max-height: 60vh; overflow: auto; }
|
||||||
|
.dd-history-item { padding: 8px 10px; border: 1px solid var(--border-dim); border-left: 2px solid var(--accent-cyan); background: var(--bg-terminal); }
|
||||||
|
.dd-history-title { color: var(--text-primary); font-size: 0.8rem; font-weight: 700; overflow-wrap: anywhere; }
|
||||||
|
.dd-history-meta { margin: 2px 0 6px; color: var(--text-muted); font-size: 0.66rem; overflow-wrap: anywhere; }
|
||||||
|
.dd-history-snippet { display: flex; gap: 8px; margin-top: 3px; color: var(--text-secondary); font-size: 0.72rem; line-height: 1.45; overflow-wrap: anywhere; }
|
||||||
|
.dd-history-snippet mark { background: var(--accent-amber-dim); color: var(--accent-amber); padding: 0 1px; }
|
||||||
|
.dd-history-role { flex: 0 0 4.2em; color: var(--accent-orange); font-size: 0.6rem; letter-spacing: 0.08em; text-transform: uppercase; padding-top: 2px; }
|
||||||
|
.dd-history-snippet.is-command span:last-child,
|
||||||
|
.dd-history-snippet.is-output span:last-child { font-family: var(--font-mono); }
|
||||||
|
.dd-history-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-top: 8px; }
|
||||||
|
.dd-history-actions .lt-input { width: 220px; font-size: 0.74rem; }
|
||||||
|
|
||||||
|
.dd-footer-usage { margin-left: auto; padding-left: 16px; }
|
||||||
|
.dd-footer-usage[hidden],
|
||||||
|
.dd-usage-row[hidden] { display: none; }
|
||||||
|
.dd-usage { display: inline-flex; align-items: center; gap: 10px; }
|
||||||
|
.dd-footer-usage { display: inline-flex; align-items: center; gap: 18px; }
|
||||||
|
.dd-meter { display: inline-flex; align-items: center; gap: 5px; color: var(--text-secondary); font-size: 0.66rem; white-space: nowrap; }
|
||||||
|
.dd-footer-usage .dd-meter { gap: 7px; color: var(--text-primary); font-family: var(--font-mono); font-size: 1rem; font-weight: 600; }
|
||||||
|
.dd-footer-usage .dd-meter-label { font-weight: 400; }
|
||||||
|
.dd-footer-usage .dd-meter-bar { width: 80px; height: 8px; }
|
||||||
|
.dd-meter-reset { color: var(--text-muted); font-weight: 400; }
|
||||||
|
.dd-meter-pace { color: var(--accent-amber); }
|
||||||
|
/* Turn timer in the session chip (devdesk#48) */
|
||||||
|
.dd-turn-timer { margin-left: 6px; color: var(--accent-amber); font-family: var(--font-mono); font-size: 0.85em; }
|
||||||
|
.dd-turn-timer[hidden] { display: none; }
|
||||||
|
.dd-session-actions .dd-compact { color: var(--accent-amber); }
|
||||||
|
.dd-wt { color: var(--accent-cyan); }
|
||||||
|
.dd-check { display: flex; align-items: center; gap: 6px; color: var(--text-secondary); font-size: 0.74rem; cursor: pointer; }
|
||||||
|
.dd-check[hidden] { display: none; }
|
||||||
|
.dd-check input { accent-color: var(--accent-cyan); }
|
||||||
|
.dd-meter-label { color: var(--text-muted); }
|
||||||
|
.dd-meter-bar { width: 42px; height: 4px; background: var(--bg-tertiary); }
|
||||||
|
.dd-meter-fill { display: block; height: 100%; background: var(--accent-cyan); }
|
||||||
|
.dd-meter.is-high .dd-meter-fill { background: var(--accent-amber); }
|
||||||
|
.dd-meter.is-high { color: var(--accent-amber); }
|
||||||
|
.dd-meter.is-full .dd-meter-fill { background: var(--accent-red); }
|
||||||
|
.dd-meter.is-full { color: var(--accent-red); }
|
||||||
|
body.dd-touch .dd-footer-usage { display: none; }
|
||||||
|
|
||||||
|
.dd-pr { margin-left: 6px; padding: 0 4px; border: 1px solid var(--border-color); color: var(--text-secondary); font-size: 0.58rem; font-weight: 400; text-decoration: none; vertical-align: middle; }
|
||||||
|
.dd-pr.is-approved { border-color: var(--accent-green-border); color: var(--accent-green); }
|
||||||
|
.dd-pr.is-changes_requested { border-color: rgba(255, 45, 85, 0.4); color: var(--accent-red); }
|
||||||
|
|
||||||
|
/* Repo panel: changed files open as diffs (devdesk#46) */
|
||||||
|
.dd-repo-changes { list-style: none; margin: 6px 0 0; padding: 0; display: flex; flex-direction: column; gap: 1px; }
|
||||||
|
.dd-repo-changes[hidden] { display: none; }
|
||||||
|
.dd-repo-changes .dd-muted { font-size: 0.68rem; padding: 2px 6px; }
|
||||||
|
.dd-repo-change { display: flex; width: 100%; gap: 8px; align-items: baseline; padding: 3px 6px; border: 0; background: none; color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.72rem; text-align: left; cursor: pointer; }
|
||||||
|
.dd-repo-change:hover, .dd-repo-change:focus-visible { background: var(--bg-tertiary); color: var(--accent-cyan); }
|
||||||
|
.dd-repo-change-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dd-repo-change-stat { flex: none; color: var(--text-muted); }
|
||||||
|
.dd-link-btn { padding: 0; border: 0; background: none; color: var(--accent-cyan); font: inherit; text-decoration: underline dotted; cursor: pointer; }
|
||||||
|
|
||||||
|
/* Toast actions (Undo) and right-click menus (devdesk#54) */
|
||||||
|
.lt-toast-action { flex: none; padding: 2px 8px; border: 1px solid currentColor; background: none; color: inherit; font: inherit; font-size: 0.72rem; cursor: pointer; }
|
||||||
|
.lt-toast-action:hover { background: var(--accent-cyan-dim); }
|
||||||
|
.dd-ctx-menu { position: fixed; top: 0; left: 0; z-index: 200; min-width: 220px; max-height: 80vh; }
|
||||||
|
.dd-ctx-menu .dd-menu-item { justify-content: space-between; padding: 5px 10px; }
|
||||||
|
.dd-ctx-menu .dd-menu-item.is-danger { color: var(--accent-red); }
|
||||||
|
.dd-ctx-kbd { margin-left: 24px; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.66rem; }
|
||||||
|
.dd-menu-sep { height: 1px; margin: 4px 6px; background: var(--border-dim); }
|
||||||
|
|
||||||
|
/* Explorer operations (devdesk#54) */
|
||||||
|
.dd-explorer-actions { display: flex; align-items: center; gap: 1px; padding: 0 4px; border-left: 1px solid var(--border-dim); }
|
||||||
|
.dd-explorer-actions .dd-icon-btn { width: 24px; height: 24px; padding: 0; font-size: 0.82rem; line-height: 1; }
|
||||||
|
.dd-files:not([data-view="explorer"]) .dd-explorer-actions { display: none; }
|
||||||
|
.dd-tree-row.is-selected { background: var(--accent-cyan-dim); }
|
||||||
|
.dd-tree-row.is-selected.is-active { box-shadow: inset 2px 0 0 var(--accent-cyan); }
|
||||||
|
.dd-explorer-body:focus-within .dd-tree-row.is-selected { background: rgba(0, 150, 220, 0.22); }
|
||||||
|
.dd-tree-row.is-cut { opacity: 0.45; }
|
||||||
|
.dd-tree-row.is-drop { background: var(--accent-orange-dim); }
|
||||||
|
.dd-tree-row.is-drop.is-dir { box-shadow: inset 0 0 0 1px var(--accent-orange); }
|
||||||
|
.dd-explorer-body.is-drop { background: var(--accent-orange-dim); box-shadow: inset 0 0 0 1px var(--accent-orange); }
|
||||||
|
.dd-tree-row.is-editing { cursor: default; background: var(--bg-tertiary); }
|
||||||
|
.dd-tree-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 1px solid var(--accent-cyan);
|
||||||
|
background: var(--bg-input, var(--bg-primary));
|
||||||
|
color: var(--text-primary);
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.dd-upload-bar { display: flex; flex-direction: column; gap: 4px; padding: 6px 10px; border-top: 1px solid var(--border-dim); font-size: 0.68rem; color: var(--text-secondary); }
|
||||||
|
.dd-upload-bar[hidden] { display: none; }
|
||||||
|
.dd-upload-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dd-upload-meter { height: 3px; background: var(--bg-tertiary); }
|
||||||
|
.dd-upload-fill { display: block; width: 0; height: 100%; background: var(--accent-cyan); transition: width 0.15s linear; }
|
||||||
|
.dd-drag-ghost { position: fixed; top: -100px; left: -100px; padding: 3px 10px; background: var(--accent-cyan); color: var(--bg-primary); font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700; }
|
||||||
|
|
||||||
|
/* Markdown preview and split, editor status bar (devdesk#55) */
|
||||||
|
.dd-viewer-content.is-preview { overflow: hidden; }
|
||||||
|
.dd-md-host { flex: 1; min-height: 0; outline: none; }
|
||||||
|
.dd-viewer-content.is-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
|
||||||
|
.dd-split-editor { min-width: 0; height: 100%; overflow: hidden; border-right: 1px solid var(--border-dim); }
|
||||||
|
.dd-split-editor .cm-editor { height: 100%; }
|
||||||
|
.dd-split-preview { min-width: 0; }
|
||||||
|
.dd-editor-status { display: flex; align-items: center; gap: 14px; flex: none; height: 22px; padding: 0 10px; border-top: 1px solid var(--border-dim); background: var(--bg-secondary); color: var(--text-muted); font-family: var(--font-mono); font-size: 0.66rem; white-space: nowrap; overflow: hidden; }
|
||||||
|
.dd-status-pos { color: var(--text-secondary); }
|
||||||
|
.dd-status-sel { color: var(--accent-cyan); }
|
||||||
|
.dd-status-spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* Search in files (devdesk#56) */
|
||||||
|
.dd-search { display: flex; flex-direction: column; min-height: 100%; }
|
||||||
|
.dd-search-box { display: flex; gap: 2px; padding: 6px 8px; border-bottom: 1px solid var(--border-dim); }
|
||||||
|
.dd-search-input { flex: 1; min-width: 0; height: 26px; padding: 0 6px; font-family: var(--font-mono); font-size: 0.74rem; }
|
||||||
|
.dd-search-flag { flex: none; width: 26px; height: 26px; padding: 0; border: 1px solid transparent; background: none; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.7rem; cursor: pointer; }
|
||||||
|
.dd-search-flag:hover { color: var(--text-primary); }
|
||||||
|
.dd-search-flag[aria-pressed="true"] { border-color: var(--accent-cyan); background: var(--accent-cyan-dim); color: var(--accent-cyan); }
|
||||||
|
.dd-search-summary { padding: 4px 10px; color: var(--text-muted); font-size: 0.66rem; }
|
||||||
|
.dd-search-file { display: flex; align-items: center; gap: 6px; width: 100%; padding: 3px 8px; border: 0; background: none; color: var(--text-primary); font-family: var(--font-mono); font-size: 0.74rem; text-align: left; cursor: pointer; }
|
||||||
|
.dd-search-file:hover { background: var(--bg-tertiary); }
|
||||||
|
.dd-search-file::before { content: '▾'; width: 10px; color: var(--text-muted); font-size: 0.66rem; }
|
||||||
|
.dd-search-file[aria-expanded="false"]::before { content: '▸'; }
|
||||||
|
.dd-search-file .dd-tree-name { flex: none; }
|
||||||
|
.dd-search-file .dd-change-dir { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.dd-search-count { flex: none; padding: 0 6px; border-radius: 8px; background: var(--bg-tertiary); color: var(--text-secondary); font-size: 0.62rem; }
|
||||||
|
.dd-search-lines[hidden] { display: none; }
|
||||||
|
.dd-search-line { display: flex; gap: 8px; padding: 2px 8px 2px 30px; color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.72rem; white-space: nowrap; cursor: pointer; outline: none; }
|
||||||
|
.dd-search-line:hover, .dd-search-line:focus { background: var(--bg-tertiary); color: var(--text-primary); }
|
||||||
|
.dd-search-line:focus { box-shadow: inset 0 0 0 1px var(--accent-cyan); }
|
||||||
|
.dd-search-ln { flex: none; min-width: 2.5em; color: var(--text-muted); text-align: right; }
|
||||||
|
.dd-search-text { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.dd-search-text mark { background: var(--accent-amber-dim); color: var(--accent-amber); outline: 1px solid color-mix(in srgb, var(--accent-amber) 50%, transparent); }
|
||||||
|
|
||||||
|
/* Settings (devdesk#61) */
|
||||||
|
.dd-settings-dialog .lt-modal { width: min(620px, calc(100vw - 24px)); max-height: calc(100vh - 40px); }
|
||||||
|
.dd-settings-dialog .dd-dialog-body { max-height: calc(100vh - 170px); overflow-y: auto; }
|
||||||
|
.dd-settings { display: flex; flex-direction: column; gap: 18px; }
|
||||||
|
.dd-settings-group h3 { margin: 0 0 8px; color: var(--accent-orange); font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.12em; text-transform: uppercase; }
|
||||||
|
.dd-setting { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 34px; padding: 4px 0; border-bottom: 1px solid var(--border-dim); }
|
||||||
|
.dd-setting-label { display: flex; flex-direction: column; color: var(--text-primary); font-size: 0.8rem; }
|
||||||
|
.dd-setting-hint { color: var(--text-muted); font-size: 0.68rem; }
|
||||||
|
.dd-setting-value { min-width: 2ch; text-align: center; font-family: var(--font-mono); }
|
||||||
|
.dd-setting-note { margin: 4px 0 8px; color: var(--text-secondary); font-size: 0.72rem; line-height: 1.5; }
|
||||||
|
.dd-setting-note a { color: var(--accent-cyan); }
|
||||||
|
.dd-setting-sub { margin-top: 10px; color: var(--text-muted); font-family: var(--font-mono); font-size: 0.64rem; letter-spacing: 0.08em; text-transform: uppercase; }
|
||||||
|
.dd-stepper { display: inline-flex; align-items: center; gap: 6px; }
|
||||||
|
.dd-seg { display: inline-flex; border: 1px solid var(--border-color); }
|
||||||
|
.dd-seg-btn { padding: 4px 10px; border: 0; border-right: 1px solid var(--border-color); background: none; color: var(--text-secondary); font-family: var(--font-mono); font-size: 0.72rem; cursor: pointer; }
|
||||||
|
.dd-seg-btn:last-child { border-right: 0; }
|
||||||
|
.dd-seg-btn[aria-pressed="true"] { background: var(--accent-cyan-dim); color: var(--accent-cyan); }
|
||||||
|
.dd-switch-row { cursor: pointer; }
|
||||||
|
.dd-switch { position: relative; flex: none; width: 36px; height: 20px; }
|
||||||
|
.dd-switch input { position: absolute; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
|
||||||
|
.dd-switch-track { position: absolute; inset: 0; border: 1px solid var(--border-color); border-radius: 10px; background: var(--bg-tertiary); transition: background 0.15s; }
|
||||||
|
.dd-switch-track::after { content: ''; position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--text-muted); transition: transform 0.15s, background 0.15s; }
|
||||||
|
.dd-switch input:checked + .dd-switch-track { background: var(--accent-cyan-dim); border-color: var(--accent-cyan); }
|
||||||
|
.dd-switch input:checked + .dd-switch-track::after { transform: translateX(16px); background: var(--accent-cyan); }
|
||||||
|
.dd-switch input:focus-visible + .dd-switch-track { outline: 1px dashed var(--accent-cyan); outline-offset: 2px; }
|
||||||
|
.dd-switch input:disabled + .dd-switch-track { opacity: 0.5; }
|
||||||
|
.dd-rc { color: var(--accent-purple); border-color: color-mix(in srgb, var(--accent-purple) 50%, transparent); }
|
||||||
|
.dd-danger-text { color: var(--accent-red) !important; }
|
||||||
|
|||||||
Reference in New Issue
Block a user