Add one-command Arch DSH setup

Package the portable DSH profiles, local plugins, desktop integration, Firefox wrapper, and ownership-aware installer lifecycle. Bundle checksum-pinned OpenVSCode, editor extensions, translation assets, and cloudflared so unreliable upstream artifact downloads cannot break a clean installation.
This commit is contained in:
Шурупов Илья Викторович 2026-09-12 19:13:36 +03:00
commit cf9443753e
211 changed files with 69463 additions and 0 deletions

5
.gitattributes vendored Normal file
View file

@ -0,0 +1,5 @@
* text=auto eol=lf
*.png binary
*.patch whitespace=-space-before-tab
vendor/dsh-ui-translate/lib/opus-worker.js whitespace=-trailing-space
vendor/dsh-ui-translate/lib/ort-wasm-simd-threaded.jsep.mjs whitespace=-trailing-space

5
.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
**/node_modules/
plugins/dsh-touched-git/runtime/
.test-home/
*.log
*.tmp

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 ilusha
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

94
README.md Normal file
View file

@ -0,0 +1,94 @@
# DSH Arch Setup
One-command installation of a customized DeepSeek Harness environment on x86-64 Arch Linux.
## Install
```bash
git clone https://git.ilusha-basic.online/ilusha/DSH-Arch-Setup.git
cd DSH-Arch-Setup
./install.sh
```
The installer may request `sudo` once to install missing Arch packages. It then:
- installs `@deepseek-ai/dsh@0.1.1-rc.2` under `~/.local`;
- links every repository-owned plugin into `~/.dsh/plugins`;
- installs all external Web-profile plugins from the committed pnpm lockfile;
- installs checksum-pinned cloudflared 2026.8.3 locally instead of running its unpinned network postinstall;
- verifies and extracts the bundled OpenVSCode Server 1.109.5 runtime without starting it;
- checksum-verifies and installs the bundled local VSIX artifacts listed in `manifests/vscode-extension-artifacts.tsv`;
- applies narrow compatibility patches required by `dsh-touched-git`;
- installs the Firefox app profile, launcher, desktop entry, and icons;
- installs and starts `dsh-web.service` as a systemd user service.
Open **DSH** from the desktop application menu or run:
```bash
dsh-app
```
Provider credentials are intentionally not included. Configure accounts on first launch. Sublime Merge is optional; install it separately if you want the `dsh-open-in-smerge` action.
### Installer options
```bash
./install.sh --skip-system-packages
./install.sh --skip-network --no-start
```
The second form is intended for local layout tests; it does not install DSH packages, editor components, or start the service.
## Update
```bash
git pull --ff-only
./install.sh
```
The installer is idempotent. Before replacing a path it does not already manage, it moves the old path under `~/.local/state/dsh-arch-setup/backups/`.
## Validate
```bash
./test/run.sh
```
Tests use temporary homes and never start or stop the live DSH service.
## Uninstall
```bash
./uninstall.sh
```
The uninstaller stops the user service, removes unchanged managed files and exact repository links, restores backups, and removes generated installations only when they did not predate the first setup run. Credentials, sessions, attachments, settings, and user-modified files are preserved.
## Included local plugins
- `dsh-compact-sidebar`
- `dsh-fleet-control`
- `dsh-fullwidth`
- `dsh-header-menu`
- `dsh-open-in-smerge`
- `dsh-project-tree`
- `dsh-sidebar-match-bg`
- `dsh-touched-git`
- `dsh-ui-tweaks`
- `dsh-web-hotkeys`
External DSH plugins are declared in `profile/web/package.json` and pinned by `profile/web/pnpm-lock.yaml`. `dsh-ui-translate@0.4.0` is vendored with its MIT license and notices because its upstream Git package cannot build on a clean install; the remaining external plugins are downloaded by pnpm. OpenVSCode extensions are bundled as license-retaining VSIX files and checksum-pinned in `manifests/vscode-extension-artifacts.tsv`, avoiding Open VSX availability during installation. Upstream extension sources and licenses are listed in `THIRD_PARTY_EXTENSIONS.md`; bundled binary provenance is in `THIRD_PARTY_BINARIES.md`.
## Deliberately excluded
The repository never contains:
- DSH credentials, OAuth state, sessions, attachments, or caches;
- Firefox cookies, logins, history, session state, or generated preferences;
- repository-path history from Sublime Merge integration;
- OpenVSCode editor state or downloaded `node_modules`;
- machine-specific PID files, logs, backups, or window geometry.
## Browser limitations
OpenVSCode runs as a web application. Firefox retains hard-reserved shortcuts such as `Ctrl+W`, `Ctrl+T`, and `Ctrl+L`. The included dedicated profile enables web shortcut handling and the Firefox context-menu mouse-up workaround, but Chromium remains more compatible with VS Code Web input handling.

12
SOURCE_REVISIONS.md Normal file
View file

@ -0,0 +1,12 @@
# Source revisions
| Component | Source |
|---|---|
| `dsh-touched-git` | Local repository `main` at `c33e67bdb43620b9a65fd5684971ade1caa14153`; one test-only absolute-home fallback was made portable with `homedir()` |
| Other local plugins | Imported from non-Git directories under `~/.dsh/plugins` |
| DSH CLI | npm `@deepseek-ai/dsh@0.1.1-rc.2` |
| OpenVSCode Server | Exact 1.109.5 runtime bundled and SHA-256 pinned by `manifests/editor-runtime.tsv`; upstream release metadata retained in `plugins/dsh-touched-git/editor-release.json` |
| cloudflared | Exact 2026.8.3 Linux x86-64 binary bundled and SHA-256 pinned by `manifests/cloudflared.tsv` |
| `dsh-ui-translate` | Vendored built package `0.4.0` from commit `260083f023ec8494fbf59db7c4b9a237cf8e9648`, retaining MIT and third-party notices |
| Other external DSH plugins | Pinned by `profile/web/pnpm-lock.yaml` |
| OpenVSCode extensions | Exact installed artifacts bundled as VSIX files and SHA-256 pinned by `manifests/vscode-extension-artifacts.tsv` |

17
THIRD_PARTY_BINARIES.md Normal file
View file

@ -0,0 +1,17 @@
# Third-party binaries
## OpenVSCode Server
- Version: 1.109.5
- Source: https://github.com/gitpod-io/openvscode-server/tree/openvscode-server-v1.109.5
- License: MIT, retained at `editor-runtime/LICENSE.openvscode-server.txt`
- Integrity: `manifests/editor-runtime.tsv`
## cloudflared
- Version: 2026.8.3
- Source: https://github.com/cloudflare/cloudflared/tree/2026.8.3
- License: Apache-2.0, retained at `vendor/dsh-ui-translate/THIRD_PARTY_LICENSES/Apache-2.0.txt`
- Integrity: `manifests/cloudflared.tsv`
Both binaries are unmodified x86-64 Linux release artifacts. The installer verifies their SHA-256 digests before use.

14
THIRD_PARTY_EXTENSIONS.md Normal file
View file

@ -0,0 +1,14 @@
# Third-party editor extensions
The repository redistributes the exact installed extension payloads as VSIX archives. Each archive retains its upstream license file and package metadata. Versions and SHA-256 digests are authoritative in `manifests/vscode-extension-artifacts.tsv`.
| Extension | Version | License | Source |
|---|---:|---|---|
| `asispts.neo-git-graph` | 0.5.0 | MIT | https://github.com/asispts/neo-git-graph |
| `llvm-vs-code-extensions.vscode-clangd` | 0.6.0 | MIT | https://github.com/clangd/vscode-clangd |
| `ms-vscode.cmake-tools` | 1.23.52 | MIT | https://github.com/microsoft/vscode-cmake-tools |
| `rionoir.gitcharm` | 0.4.7 | GPL-3.0-only | https://github.com/RioNoir/GitCharm |
| `skippingpetticoatconvent.git-graph-zh` | 1.0.8 | bundled `LICENSE` | https://github.com/SKIPPINGpetticoatconvent/vscode-git-graph-zh |
| `tofilagman.git-branch-compare` | 0.4.0 | MIT | https://github.com/tofilagman/vscode-ext-gitlab-compare |
The corresponding source is available at the linked upstream repositories under the recorded release version. Do not remove the license files from redistributed VSIX archives.

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

31
bin/dsh-app Executable file
View file

@ -0,0 +1,31 @@
#!/usr/bin/env bash
set -euo pipefail
url="${DSH_WEB_URL:-http://127.0.0.1:3080/}"
profile="${DSH_FIREFOX_PROFILE:-$HOME/.local/share/dsh-app-firefox}"
log="$profile/dsh-web.log"
mkdir -p "$profile"
kiosk=0
[[ "${1:-}" == "--kiosk" ]] && kiosk=1
if ! curl --fail --silent --output /dev/null "$url" 2>/dev/null; then
if systemctl --user cat dsh-web.service >/dev/null 2>&1; then
systemctl --user start dsh-web.service
else
nohup dsh web --no-open --port 3080 >"$log" 2>&1 &
fi
for _ in $(seq 1 60); do
curl --fail --silent --output /dev/null "$url" 2>/dev/null && break
sleep 1
done
fi
export MOZ_ENABLE_WAYLAND=1
unset GDK_BACKEND
arguments=(--no-remote --new-instance --profile "$profile" --name dsh-app)
if [[ "$kiosk" == 1 ]]; then
exec firefox "${arguments[@]}" --kiosk "$url"
fi
exec firefox "${arguments[@]}" --new-window "$url"

14
bin/free-port-3080.sh Executable file
View file

@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -u
port=3080
pids=$(ss -ltnpH "sport = :${port}" 2>/dev/null | grep -oP 'pid=\K[0-9]+' | sort -u)
for pid in $pids; do
kill "$pid" 2>/dev/null || true
done
for _ in $(seq 1 20); do
ss -ltnH "sport = :${port}" 2>/dev/null | grep -q . || break
sleep 0.5
done
exit 0

View file

@ -0,0 +1,14 @@
[Desktop Entry]
Type=Application
Name=DSH
Comment=DSH web UI in a dedicated Firefox window
Exec=@HOME@/.local/bin/dsh-app
Icon=dsh
Terminal=false
Categories=Development;
StartupNotify=true
Actions=kiosk;
[Desktop Action kiosk]
Name=DSH (fullscreen / borderless)
Exec=@HOME@/.local/bin/dsh-app --kiosk

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2015 - present Microsoft Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

19
firefox/user.js Normal file
View file

@ -0,0 +1,19 @@
user_pref("toolkit.legacyUserProfileCustomizations.stylesheets", true);
user_pref("browser.shell.checkDefaultBrowser", false);
user_pref("browser.aboutwelcome.enabled", false);
user_pref("browser.startup.homepage_override.mstone", "ignore");
user_pref("startup.homepage_welcome_url", "");
user_pref("startup.homepage_welcome_url.additional", "");
user_pref("datareporting.policy.dataSubmissionEnabled", false);
user_pref("datareporting.healthreport.uploadEnabled", false);
user_pref("browser.tabs.warnOnClose", false);
user_pref("browser.sessionstore.resume_from_crash", false);
user_pref("browser.startup.page", 1);
user_pref("browser.startup.homepage", "http://127.0.0.1:3080/");
user_pref("browser.tabs.inTitlebar", 1);
user_pref("full-screen-api.warning.timeout", 0);
user_pref("full-screen-api.transition-duration.enter", "0 0");
user_pref("full-screen-api.transition-duration.leave", "0 0");
user_pref("permissions.default.shortcuts", 1);
user_pref("dom.event.contextmenu.enabled", true);
user_pref("ui.context_menus.after_mouseup", true);

8
firefox/userChrome.css Normal file
View file

@ -0,0 +1,8 @@
#TabsToolbar { visibility: visible !important; }
#nav-bar { visibility: collapse !important; }
#PersonalToolbar { visibility: collapse !important; }
#navigator-toolbox { min-height: 0 !important; }
#navigator-toolbox::after { display: none !important; }
.browserContainer > .browserStack > [class*="warning"] { display: none !important; }
#toolbar-menubar { visibility: collapse !important; }
.titlebar-buttonbox-container { display: none !important; }

259
install.sh Executable file
View file

@ -0,0 +1,259 @@
#!/usr/bin/env bash
set -euo pipefail
repository_directory=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
dsh_home="$HOME/.dsh"
profile_directory="$dsh_home/profiles/web"
state_directory=${XDG_STATE_HOME:-"$HOME/.local/state"}/dsh-arch-setup
managed_manifest="$state_directory/managed.tsv"
partial_manifest="$state_directory/partial.tsv"
backup_manifest="$state_directory/backups.tsv"
new_manifest=$(mktemp)
cleanup() {
local status=$?
trap - EXIT
if [[ "$status" != 0 && -s "$new_manifest" ]]; then
mkdir -p "$state_directory"
cat "$new_manifest" >> "$partial_manifest"
fi
rm -f "$new_manifest"
exit "$status"
}
trap cleanup EXIT
skip_system_packages=${DSH_SETUP_SKIP_SYSTEM_PACKAGES:-0}
skip_network=${DSH_SETUP_SKIP_NETWORK:-0}
start_service=${DSH_SETUP_START_SERVICE:-1}
skip_host_integration=${DSH_SETUP_SKIP_HOST_INTEGRATION:-0}
for argument in "$@"; do
case "$argument" in
--skip-system-packages) skip_system_packages=1 ;;
--skip-network) skip_network=1 ;;
--no-start) start_service=0 ;;
--test-mode) skip_system_packages=1; skip_network=1; start_service=0; skip_host_integration=1 ;;
*) printf 'Unknown option: %s\n' "$argument" >&2; exit 2 ;;
esac
done
[[ "$EUID" != 0 ]] || { printf 'Run this installer as a regular user, not root.\n' >&2; exit 1; }
[[ "$(uname -s)" == Linux && "$(uname -m)" == x86_64 ]] || { printf 'This release supports Linux x86-64 only.\n' >&2; exit 1; }
if [[ "$skip_system_packages" != 1 ]]; then
[[ -f /etc/arch-release ]] || { printf 'This installer supports Arch Linux only.\n' >&2; exit 1; }
fi
[[ -f "$repository_directory/profile/web/pnpm-lock.yaml" ]] || { printf 'Missing Web profile lockfile.\n' >&2; exit 1; }
if [[ -x "$HOME/.local/bin/dsh" ]]; then
installed_dsh_version=$("$HOME/.local/bin/dsh" --version)
[[ "$installed_dsh_version" == 0.1.1-rc.2 ]] || { printf 'Existing ~/.local DSH version %s conflicts with required 0.1.1-rc.2.\n' "$installed_dsh_version" >&2; exit 1; }
fi
while IFS= read -r plugin; do
[[ -n "$plugin" ]] || continue
[[ -f "$repository_directory/plugins/$plugin/package.json" ]] || { printf 'Missing local plugin: %s\n' "$plugin" >&2; exit 1; }
done < "$repository_directory/manifests/local-plugins.txt"
while IFS= read -r plugin; do
[[ -n "$plugin" ]] || continue
[[ -f "$repository_directory/vendor/$plugin/package.json" ]] || { printf 'Missing vendored plugin: %s\n' "$plugin" >&2; exit 1; }
done < "$repository_directory/manifests/vendored-plugins.txt"
while IFS=$'\t' read -r extension relative expected_hash; do
[[ -n "$extension" ]] || continue
archive="$repository_directory/$relative"
[[ -f "$archive" ]] || { printf 'Missing editor extension archive: %s\n' "$relative" >&2; exit 1; }
actual_hash=$(sha256sum "$archive" | cut -d ' ' -f 1)
[[ "$actual_hash" == "$expected_hash" ]] || { printf 'Editor extension checksum mismatch: %s\n' "$relative" >&2; exit 1; }
done < "$repository_directory/manifests/vscode-extension-artifacts.tsv"
IFS=$'\t' read -r editor_version editor_archive_relative editor_archive_hash < "$repository_directory/manifests/editor-runtime.tsv"
editor_archive="$repository_directory/$editor_archive_relative"
[[ -f "$editor_archive" ]] || { printf 'Missing OpenVSCode runtime archive: %s\n' "$editor_archive_relative" >&2; exit 1; }
actual_editor_hash=$(sha256sum "$editor_archive" | cut -d ' ' -f 1)
[[ "$actual_editor_hash" == "$editor_archive_hash" ]] || { printf 'OpenVSCode runtime checksum mismatch: %s\n' "$editor_archive_relative" >&2; exit 1; }
IFS=$'\t' read -r cloudflared_version cloudflared_relative cloudflared_hash < "$repository_directory/manifests/cloudflared.tsv"
cloudflared_source="$repository_directory/$cloudflared_relative"
[[ -x "$cloudflared_source" ]] || { printf 'Missing cloudflared binary: %s\n' "$cloudflared_relative" >&2; exit 1; }
actual_cloudflared_hash=$(sha256sum "$cloudflared_source" | cut -d ' ' -f 1)
[[ "$actual_cloudflared_hash" == "$cloudflared_hash" ]] || { printf 'cloudflared checksum mismatch: %s\n' "$cloudflared_relative" >&2; exit 1; }
actual_cloudflared_version=$("$cloudflared_source" --version | awk '{print $3}')
[[ "$actual_cloudflared_version" == "$cloudflared_version" ]] || { printf 'cloudflared version mismatch: %s\n' "$actual_cloudflared_version" >&2; exit 1; }
existing_editor="$repository_directory/plugins/dsh-touched-git/runtime/current/bin/openvscode-server"
if [[ -x "$existing_editor" ]]; then
existing_editor_version=$("$existing_editor" --version | head -n 1)
[[ "$existing_editor_version" == "$editor_version" ]] || { printf 'Existing OpenVSCode runtime version %s conflicts with required %s.\n' "$existing_editor_version" "$editor_version" >&2; exit 1; }
elif [[ -e "$repository_directory/plugins/dsh-touched-git/runtime/current" ]]; then
printf 'Existing OpenVSCode runtime is incomplete.\n' >&2
exit 1
fi
mkdir -p "$state_directory"
touch "$backup_manifest"
if [[ ! -f "$managed_manifest" && ! -f "$partial_manifest" ]]; then
[[ -x "$HOME/.local/bin/dsh" ]] && touch "$state_directory/dsh-preexisting"
[[ -d "$profile_directory" ]] && touch "$state_directory/profile-preexisting"
[[ -d "$dsh_home/touched-git/editor" ]] && touch "$state_directory/editor-preexisting"
[[ -d "$repository_directory/plugins/dsh-touched-git/runtime/current" ]] && touch "$state_directory/runtime-preexisting"
[[ -d "$repository_directory/plugins/dsh-touched-git/node_modules" ]] && touch "$state_directory/plugin-dependencies-preexisting"
fi
is_managed_unchanged() {
local destination=$1
local record
record=$(
for manifest in "$managed_manifest" "$partial_manifest"; do
[[ ! -f "$manifest" ]] || cat "$manifest"
done | awk -F '\t' -v candidate="$destination" '$2 == candidate { record = $0 } END { print record }'
)
[[ -n "$record" ]] || return 1
local kind ignored expected
IFS=$'\t' read -r kind ignored expected <<< "$record"
case "$kind" in
file) [[ -f "$destination" && "$(sha256sum "$destination" | cut -d ' ' -f 1)" == "$expected" ]] ;;
link) [[ -L "$destination" && "$(readlink -f "$destination")" == "$(readlink -f "$expected")" ]] ;;
*) return 1 ;;
esac
}
backup_unmanaged() {
local destination=$1
if [[ ! -e "$destination" && ! -L "$destination" ]]; then
return
fi
if is_managed_unchanged "$destination"; then
return
fi
local relative=${destination#/}
local backup="$state_directory/backups/$relative"
if [[ -e "$backup" || -L "$backup" ]]; then
backup="$backup.$(date +%s%N)"
fi
mkdir -p "$(dirname "$backup")"
mv "$destination" "$backup"
printf '%s\t%s\n' "$destination" "$backup" >> "$backup_manifest"
}
record_file() {
local destination=$1
printf 'file\t%s\t%s\n' "$destination" "$(sha256sum "$destination" | cut -d ' ' -f 1)" >> "$new_manifest"
}
install_managed_file() {
local source=$1
local destination=$2
local mode=$3
backup_unmanaged "$destination"
install -Dm"$mode" "$source" "$destination"
record_file "$destination"
}
install_managed_link() {
local source=$1
local destination=$2
if [[ -L "$destination" && "$(readlink -f "$destination")" == "$(readlink -f "$source")" ]]; then
printf 'link\t%s\t%s\n' "$destination" "$source" >> "$new_manifest"
return
fi
backup_unmanaged "$destination"
mkdir -p "$(dirname "$destination")"
rm -rf "$destination"
ln -s "$source" "$destination"
printf 'link\t%s\t%s\n' "$destination" "$source" >> "$new_manifest"
}
if [[ "$skip_system_packages" != 1 ]]; then
packages=(nodejs npm pnpm git curl tar firefox desktop-file-utils hicolor-icon-theme iproute2 python clang cmake ninja)
missing=()
for package in "${packages[@]}"; do
pacman -Q "$package" >/dev/null 2>&1 || missing+=("$package")
done
if ((${#missing[@]})); then
sudo pacman -S --needed --noconfirm "${missing[@]}"
fi
fi
mkdir -p "$dsh_home/plugins" "$profile_directory" "$dsh_home/profiles/headless"
while IFS= read -r plugin; do
[[ -n "$plugin" ]] || continue
install_managed_link "$repository_directory/plugins/$plugin" "$dsh_home/plugins/$plugin"
done < "$repository_directory/manifests/local-plugins.txt"
while IFS= read -r plugin; do
[[ -n "$plugin" ]] || continue
install_managed_link "$repository_directory/vendor/$plugin" "$dsh_home/vendor/$plugin"
done < "$repository_directory/manifests/vendored-plugins.txt"
profile_files=(package.json pnpm-workspace.yaml pnpm-lock.yaml cordis.yml cordis.patch.yml patches/dsh-codex-subscription@1.10.0.patch)
for relative in "${profile_files[@]}"; do
install_managed_file "$repository_directory/profile/web/$relative" "$profile_directory/$relative" 0644
done
headless_files=(package.json pnpm-workspace.yaml cordis.yml cordis.patch.yml)
for relative in "${headless_files[@]}"; do
install_managed_file "$repository_directory/profile/headless/$relative" "$dsh_home/profiles/headless/$relative" 0644
done
install_managed_file "$repository_directory/bin/dsh-app" "$HOME/.local/bin/dsh-app" 0755
install_managed_file "$repository_directory/bin/free-port-3080.sh" "$dsh_home/free-port-3080.sh" 0755
install_managed_file "$repository_directory/systemd/dsh-web.service" "$HOME/.config/systemd/user/dsh-web.service" 0644
install_managed_file "$repository_directory/firefox/user.js" "$HOME/.local/share/dsh-app-firefox/user.js" 0644
install_managed_file "$repository_directory/firefox/userChrome.css" "$HOME/.local/share/dsh-app-firefox/chrome/userChrome.css" 0644
desktop_temporary=$(mktemp)
sed "s|@HOME@|$HOME|g" "$repository_directory/desktop/dsh-app.desktop.in" > "$desktop_temporary"
install_managed_file "$desktop_temporary" "$HOME/.local/share/applications/dsh-app.desktop" 0644
rm -f "$desktop_temporary"
while IFS= read -r icon; do
relative=${icon#"$repository_directory/assets/icons/"}
install_managed_file "$icon" "$HOME/.local/share/icons/hicolor/$relative" 0644
done < <(find "$repository_directory/assets/icons" -type f -name '*.png' | sort)
if [[ "$skip_network" != 1 ]]; then
if [[ ! -x "$HOME/.local/bin/dsh" ]]; then
npm install --global --prefix "$HOME/.local" --allow-scripts='@deepseek-ai/dsh-subprocess-local,koffi,node-pty,@google/genai,protobufjs' '@deepseek-ai/dsh@0.1.1-rc.2'
fi
touched_git_directory="$repository_directory/plugins/dsh-touched-git"
pnpm --dir "$touched_git_directory" install --frozen-lockfile
pnpm --dir "$profile_directory" install --frozen-lockfile
[[ -f "$profile_directory/node_modules/cloudflared/package.json" ]] || { printf 'cloudflared npm wrapper is missing after profile installation.\n' >&2; exit 1; }
install_managed_file "$cloudflared_source" "$profile_directory/node_modules/cloudflared/bin/cloudflared" 0755
runtime_directory="$touched_git_directory/runtime"
editor_binary="$runtime_directory/current/bin/openvscode-server"
if [[ ! -x "$editor_binary" ]]; then
mkdir -p "$runtime_directory"
runtime_staging=$(mktemp -d "$runtime_directory/.install-XXXXXX")
if ! tar -xzf "$editor_archive" -C "$runtime_staging"; then
rm -rf "$runtime_staging"
printf 'Failed to extract bundled OpenVSCode runtime.\n' >&2
exit 1
fi
[[ -x "$runtime_staging/current/bin/openvscode-server" ]] || { rm -rf "$runtime_staging"; printf 'Bundled OpenVSCode runtime is incomplete.\n' >&2; exit 1; }
mv "$runtime_staging/current" "$runtime_directory/current"
rmdir "$runtime_staging"
fi
printf 'Installed bundled OpenVSCode Server %s. No server started.\n' "$editor_version"
while IFS=$'\t' read -r extension relative expected_hash; do
[[ -n "$extension" ]] || continue
archive="$repository_directory/$relative"
timeout --signal=TERM 180 "$editor_binary" --extensions-dir "$dsh_home/touched-git/editor/extensions" --install-extension "$archive" --force
done < "$repository_directory/manifests/vscode-extension-artifacts.tsv"
DSH_PROFILE_DIRECTORY="$profile_directory" \
DSH_EDITOR_EXTENSIONS_DIRECTORY="$dsh_home/touched-git/editor/extensions" \
node "$repository_directory/scripts/apply-compatibility-patches.mjs"
fi
mv "$new_manifest" "$managed_manifest"
rm -f "$partial_manifest"
printf '%s\n' "$repository_directory" > "$state_directory/repository"
if [[ "$skip_host_integration" != 1 ]]; then
command -v update-desktop-database >/dev/null 2>&1 && update-desktop-database "$HOME/.local/share/applications"
command -v gtk-update-icon-cache >/dev/null 2>&1 && gtk-update-icon-cache --force "$HOME/.local/share/icons/hicolor" >/dev/null 2>&1 || true
systemctl --user daemon-reload
if [[ "$start_service" == 1 ]]; then
systemctl --user enable dsh-web.service
systemctl --user restart dsh-web.service
fi
fi
printf 'DSH installation complete. Open DSH from the application menu or run dsh-app.\n'
printf 'Configure provider credentials on first launch; no credentials are included here.\n'

View file

@ -0,0 +1 @@
2026.8.3 third-party-binaries/cloudflared-2026.8.3-linux-amd64 f29324fe934d1e100617484c78deef803c4dc2cd351d645bbde42e96b4fccc5e
1 2026.8.3 third-party-binaries/cloudflared-2026.8.3-linux-amd64 f29324fe934d1e100617484c78deef803c4dc2cd351d645bbde42e96b4fccc5e

View file

@ -0,0 +1 @@
1.109.5 editor-runtime/openvscode-server-1.109.5-linux-x64.tar.gz 89bfa2171c8fbf9ce987b2a0731c5ddd73f9cca476c47717e0854cf0aefafec2
1 1.109.5 editor-runtime/openvscode-server-1.109.5-linux-x64.tar.gz 89bfa2171c8fbf9ce987b2a0731c5ddd73f9cca476c47717e0854cf0aefafec2

View file

@ -0,0 +1,10 @@
dsh-compact-sidebar
dsh-fleet-control
dsh-fullwidth
dsh-header-menu
dsh-open-in-smerge
dsh-project-tree
dsh-sidebar-match-bg
dsh-touched-git
dsh-ui-tweaks
dsh-web-hotkeys

View file

@ -0,0 +1 @@
dsh-ui-translate

View file

@ -0,0 +1,8 @@
asispts.neo-git-graph@0.5.0 editor-extensions/asispts.neo-git-graph-0.5.0.vsix 78570de7941ee888047b31732cf55016ee797bece06c8476f1270ad1e24632df
dsh-local.dsh-editor-file-bridge@0.1.0 editor-extensions/dsh-local.dsh-editor-file-bridge-0.1.0.vsix a7666a83e9d547d22c85fb1ecac9446888ef98dfb15c01a649be302c7d90bdbb
dsh-local.dsh-editor-preferences@0.2.0 editor-extensions/dsh-local.dsh-editor-preferences-0.2.0.vsix 8d02775587aeeeed0cee4ba21735efc7e07ecf00fc7f2de860d562472502dae1
llvm-vs-code-extensions.vscode-clangd@0.6.0 editor-extensions/llvm-vs-code-extensions.vscode-clangd-0.6.0.vsix 97fb4e4a94b75e9b7ef14995f4d5d2f441758424d2fdf6d60f8f2107eeef6b86
ms-vscode.cmake-tools@1.23.52 editor-extensions/ms-vscode.cmake-tools-1.23.52.vsix de4b52a2588e8e634edbf7b1fb8d25f98e3b86f63c85303d2b4eebfd37809a45
rionoir.gitcharm@0.4.7 editor-extensions/rionoir.gitcharm-0.4.7.vsix 337f9ef135ac227527a1932390f8f03a9847de06c7e1e3864902338a77251003
skippingpetticoatconvent.git-graph-zh@1.0.8 editor-extensions/skippingpetticoatconvent.git-graph-zh-1.0.8.vsix 54ae65c1a8cfdfd377b6766ae9310bb80745f32cab08faaf22f7d792b2878926
tofilagman.git-branch-compare@0.4.0 editor-extensions/tofilagman.git-branch-compare-0.4.0.vsix 23d063380071245fae3d6180598a092f0db4fc49b863f14489604d8f4b2b3bc5
1 asispts.neo-git-graph@0.5.0 editor-extensions/asispts.neo-git-graph-0.5.0.vsix 78570de7941ee888047b31732cf55016ee797bece06c8476f1270ad1e24632df
2 dsh-local.dsh-editor-file-bridge@0.1.0 editor-extensions/dsh-local.dsh-editor-file-bridge-0.1.0.vsix a7666a83e9d547d22c85fb1ecac9446888ef98dfb15c01a649be302c7d90bdbb
3 dsh-local.dsh-editor-preferences@0.2.0 editor-extensions/dsh-local.dsh-editor-preferences-0.2.0.vsix 8d02775587aeeeed0cee4ba21735efc7e07ecf00fc7f2de860d562472502dae1
4 llvm-vs-code-extensions.vscode-clangd@0.6.0 editor-extensions/llvm-vs-code-extensions.vscode-clangd-0.6.0.vsix 97fb4e4a94b75e9b7ef14995f4d5d2f441758424d2fdf6d60f8f2107eeef6b86
5 ms-vscode.cmake-tools@1.23.52 editor-extensions/ms-vscode.cmake-tools-1.23.52.vsix de4b52a2588e8e634edbf7b1fb8d25f98e3b86f63c85303d2b4eebfd37809a45
6 rionoir.gitcharm@0.4.7 editor-extensions/rionoir.gitcharm-0.4.7.vsix 337f9ef135ac227527a1932390f8f03a9847de06c7e1e3864902338a77251003
7 skippingpetticoatconvent.git-graph-zh@1.0.8 editor-extensions/skippingpetticoatconvent.git-graph-zh-1.0.8.vsix 54ae65c1a8cfdfd377b6766ae9310bb80745f32cab08faaf22f7d792b2878926
8 tofilagman.git-branch-compare@0.4.0 editor-extensions/tofilagman.git-branch-compare-0.4.0.vsix 23d063380071245fae3d6180598a092f0db4fc49b863f14489604d8f4b2b3bc5

View file

@ -0,0 +1,7 @@
# dsh-compact-sidebar bundle patch: inserts the plugin row into the profile
# roster. The node half (exports ".") is a no-op; all behavior lives in the
# browser half (exports "./client"), which injects a <style> tightening the
# sidebar logo row so the New Session button sits right under the logo.
- insert:
- id: compact-sidebar
name: dsh-compact-sidebar

View file

@ -0,0 +1,80 @@
/**
* dsh-compact-sidebar — browser half.
*
* Reclaims the top of the sidebar by fully hiding chrome, rather than merely
* shrinking it:
* • the logo row (`.logoRow` — the "deepseek HARNESS" brand)
* • the New Session button (`.newSession`)
*
* NOTE: the sidebar collapse / panel-toggle button lives INSIDE the logo row,
* so hiding the whole row removes it too. If you want to keep the collapse
* toggle, set HIDE_LOGO_ROW = false below (the brand text can be hidden on its
* own via HIDE_BRAND_ONLY instead).
*
* The CSS-module classes are content-hashed (e.g. `hHd-Xa_logoRow`) and the
* hash changes between builds, so we match on the stable suffix with
* attribute-substring selectors like `[class*="_logoRow"]`.
* @module dsh-compact-sidebar/client
*/
window.__ModuleLoader__.load({
id: "dsh-compact-sidebar",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
// ---- CONFIG (edit here) -----------------------------------------------
// Remove the entire logo row (brand + the collapse/panel-toggle button).
const HIDE_LOGO_ROW = true;
// Remove the New Session button.
const HIDE_NEW_SESSION = true;
// Alternative to HIDE_LOGO_ROW: keep the row (and its collapse toggle) but
// hide only the brand text/logo. Ignored when HIDE_LOGO_ROW is true.
const HIDE_BRAND_ONLY = false;
// -----------------------------------------------------------------------
const rules = [];
if (HIDE_LOGO_ROW) {
rules.push('[class*="_root"] [class*="_logoRow"]{display:none !important;}');
} else if (HIDE_BRAND_ONLY) {
rules.push('[class*="_root"] [class*="_brand"]{display:none !important;}');
}
if (HIDE_NEW_SESSION) {
rules.push('[class*="_root"] [class*="_newSession"]{display:none !important;}');
}
const STYLE_ID = "dsh-compact-sidebar-style";
const cssText = rules.join("");
/** Insert (or update) the override <style>, returning a cleanup fn. */
function install() {
let el = document.getElementById(STYLE_ID);
if (!el) {
el = document.createElement("style");
el.id = STYLE_ID;
(document.head || document.documentElement).appendChild(el);
}
el.textContent = cssText;
return () => {
const node = document.getElementById(STYLE_ID);
if (node) node.remove();
};
}
/** No client services required — this is pure CSS. */
const inject = [];
/** Client plugin body: inject the sidebar hiding (HMR-safe cleanup). */
function apply(ctx) {
if (ctx && typeof ctx.effect === "function") {
ctx.effect(install, "dsh-compact-sidebar: hide brand + New Session");
} else {
install();
}
}
exports.apply = apply;
exports.inject = inject;
return module.exports;
},
});

View file

@ -0,0 +1,16 @@
/**
* dsh-compact-sidebar — host half.
*
* Client-only: all behavior lives in the browser half (exports "./client"),
* which injects a <style> tightening the sidebar logo row. The host half
* exists solely so the profile row loads cleanly; it registers nothing.
* @module dsh-compact-sidebar
*/
export const name = 'dsh-compact-sidebar'
/** No host services required. */
export const inject = []
/** No-op host apply. */
export function apply() {}

View file

@ -0,0 +1,37 @@
{
"name": "dsh-compact-sidebar",
"description": "dsh web plugin: remove the dead vertical space between the logo row and the New Session button by tightening the sidebar logo row. Client-only.",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": "^22.19.0 || >=24.0.0"
},
"main": "lib/index.js",
"exports": {
".": {
"default": "./lib/index.js"
},
"./client": {
"default": "./lib/client.js"
},
"./package.json": "./package.json"
},
"dsh": {
"engines": {
"dsh": ">=0.1.2-alpha.1"
},
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [],
"platform": "web"
}
},
"files": [
"lib/**/*.js",
"cordis.patch.yml"
],
"license": "MIT"
}

View file

@ -0,0 +1,9 @@
# dsh-fleet-control bundle patch: inserts the dual-face plugin row into the
# profile roster. The node half (exports ".") registers the loopback
# /fleet/stopped route that persists the stopped-session set to
# ~/.dsh/fleet-control/stopped.json; the dsh.client declaration in
# package.json makes the browser half (exports "./client") load in the web GUI
# and add the Stop All / Resume All header buttons.
- insert:
- id: fleet-control
name: dsh-fleet-control

View file

@ -0,0 +1,308 @@
/**
* dsh-fleet-control — browser half.
*
* Adds three buttons to the conversation header:
* • Stop All — gracefully cancel the running turn of every session and
* remember which ones were actually running (persisted host-side).
* • Resume Stopped — re-open only the remembered sessions, send each a "continue"
* prompt, then clear the remembered set.
* • Resume All — send "continue" to every session, whether or not Stop All
* touched it; also clears the remembered set.
*
* Cancel/send require a live per-session binding (dsh-client-ui-conversation
* throws "resolved no binding" otherwise), which only exists once a session is
* opened. So both actions open a session and wait for its binding before acting,
* and restore the originally-current session when done.
*
* @module dsh-fleet-control/client
*/
window.__ModuleLoader__.load({
id: "dsh-fleet-control",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const react = require("react");
const jsx = require("react/jsx-runtime");
/** Prompt sent to each session by Resume All. Edit to taste. */
const RESUME_TEXT = "continue";
/** How long to wait for a freshly-opened session to bind before giving up. */
const BINDING_TIMEOUT_MS = 8000;
const STYLE_ID = "dsh-fleet-control-style";
const STYLE_TEXT = ".dsh-fleet-control{display:inline-flex;align-items:center;gap:6px}.dsh-fleet-control__btn{border:1px solid var(--dsw-alias-border-l2);height:32px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);cursor:pointer;background:transparent;border-radius:18px;display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:6px 12px;font-size:13px;font-weight:400;line-height:20px;white-space:nowrap}.dsh-fleet-control__btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.dsh-fleet-control__btn:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}.dsh-fleet-control__note{color:var(--dsw-alias-label-tertiary);font-family:var(--dsw-font-family);font-size:12px;white-space:nowrap}";
function installStyle() {
if (document.getElementById(STYLE_ID) !== null) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.dataset.plugin = "dsh-fleet-control";
style.textContent = STYLE_TEXT;
document.head.appendChild(style);
}
async function request(path, options) {
let response;
try {
response = await fetch(path, options);
} catch {
return { ok: false };
}
try {
const envelope = await response.json();
if (envelope && envelope.ok === true) return { ok: true, value: envelope.value };
return { ok: false };
} catch {
return { ok: false };
}
}
const loadStopped = () => request("/fleet/stopped");
const saveStopped = (ids) => request("/fleet/stopped", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ids }),
});
function toMillis(value) {
if (typeof value === "number") return value;
if (typeof value === "string") {
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
return 0;
}
/** All session ids, most-recently-updated first (mirrors dsh-web-hotkeys). */
function orderedIds(sessions) {
const snapshot = sessions.list.getSnapshot();
const byId = snapshot.byId || {};
return Object.keys(byId).sort((a, b) => toMillis(byId[b] && byId[b].updatedAt) - toMillis(byId[a] && byId[a].updatedAt));
}
/**
* Best-effort read of a session's running state from the list snapshot, used
* only to skip obviously-idle sessions during Stop All. Returns undefined
* when the snapshot shape carries no such flag, in which case the caller
* attempts the session anyway (cancel is a safe no-op on idle sessions).
*/
function runningHint(byId, id) {
const row = byId && byId[id];
if (!row || typeof row !== "object") return undefined;
if (typeof row.running === "boolean") return row.running;
if (typeof row.busy === "boolean") return row.busy;
if (typeof row.status === "string") return row.status !== "idle" && row.status !== "closed";
if (typeof row.activity === "string") return row.activity !== "idle";
return undefined;
}
function conversationOf(sessions, id) {
try {
const scoped = sessions.scope(id);
return scoped && typeof scoped.get === "function" ? scoped.get("conversation") : undefined;
} catch {
return undefined;
}
}
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForBinding(sessions, id, timeoutMs) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
if (typeof sessions.binding === "function" && sessions.binding(id) !== undefined) return true;
} catch {
/* keep polling */
}
await delay(120);
}
return false;
}
function isNoBinding(error) {
return /no binding/i.test(String(error && error.message));
}
/** Cancel one session's running turn. Returns true only if a turn was stopped. */
async function stopOne(sessions, id) {
let conversation = conversationOf(sessions, id);
if (conversation && typeof conversation.cancel === "function") {
try {
await conversation.cancel();
return true;
} catch (error) {
// idle / nothing-to-cancel -> not running; only retry when unbound.
if (!isNoBinding(error)) return false;
}
}
try {
if (typeof sessions.open === "function") sessions.open(id);
if (!(await waitForBinding(sessions, id, BINDING_TIMEOUT_MS))) return false;
conversation = conversationOf(sessions, id);
if (!conversation || typeof conversation.cancel !== "function") return false;
await conversation.cancel();
return true;
} catch {
return false;
}
}
/** Open a session, wait for its binding, then send the resume prompt. */
async function resumeOne(sessions, id) {
try {
if (typeof sessions.open === "function") sessions.open(id);
if (!(await waitForBinding(sessions, id, BINDING_TIMEOUT_MS))) return false;
const conversation = conversationOf(sessions, id);
if (!conversation || typeof conversation.send !== "function") return false;
await conversation.send(RESUME_TEXT);
return true;
} catch {
return false;
}
}
async function stopAll(sessions) {
const original = sessions.list.getSnapshot().current;
const byId = sessions.list.getSnapshot().byId || {};
const ids = orderedIds(sessions);
const stopped = [];
for (const id of ids) {
if (runningHint(byId, id) === false) continue;
if (await stopOne(sessions, id)) stopped.push(id);
}
if (original && typeof sessions.open === "function") sessions.open(original);
await saveStopped(stopped);
return stopped;
}
/** Resume only the sessions Stop All recorded, then clear the set. */
async function resumeStopped(sessions) {
const original = sessions.list.getSnapshot().current;
const loaded = await loadStopped();
const ids = loaded.ok && loaded.value && Array.isArray(loaded.value.ids) ? loaded.value.ids : [];
const resumed = [];
for (const id of ids) {
if (await resumeOne(sessions, id)) resumed.push(id);
}
if (original && typeof sessions.open === "function") sessions.open(original);
await saveStopped([]);
return resumed;
}
/** Resume every session, regardless of whether Stop All touched it; clears the set. */
async function resumeAll(sessions) {
const original = sessions.list.getSnapshot().current;
const ids = orderedIds(sessions);
const resumed = [];
for (const id of ids) {
if (await resumeOne(sessions, id)) resumed.push(id);
}
if (original && typeof sessions.open === "function") sessions.open(original);
await saveStopped([]);
return resumed;
}
function FleetControlButtons(props) {
const [busy, setBusy] = react.useState("");
const [note, setNote] = react.useState("");
const flash = react.useCallback((text) => {
setNote(text);
window.setTimeout(() => setNote(""), 5000);
}, []);
const onStop = react.useCallback(async () => {
if (busy) return;
setBusy("stop");
try {
const stopped = await props.stopAll();
flash(`Stopped ${stopped.length}`);
} catch {
flash("Stop failed");
} finally {
setBusy("");
}
}, [busy, props, flash]);
const onResumeStopped = react.useCallback(async () => {
if (busy) return;
setBusy("resume-stopped");
try {
const resumed = await props.resumeStopped();
flash(`Resumed ${resumed.length} stopped`);
} catch {
flash("Resume failed");
} finally {
setBusy("");
}
}, [busy, props, flash]);
const onResumeAll = react.useCallback(async () => {
if (busy) return;
setBusy("resume-all");
try {
const resumed = await props.resumeAll();
flash(`Resumed ${resumed.length}`);
} catch {
flash("Resume failed");
} finally {
setBusy("");
}
}, [busy, props, flash]);
return jsx.jsxs("div", {
className: "dsh-fleet-control",
children: [
jsx.jsx("button", {
type: "button",
className: "dsh-fleet-control__btn",
disabled: busy !== "",
title: "Gracefully stop the running turn of every session and remember which were running",
onClick: onStop,
children: busy === "stop" ? "Stopping…" : "Stop All",
}),
jsx.jsx("button", {
type: "button",
className: "dsh-fleet-control__btn",
disabled: busy !== "",
title: `Re-open only the sessions Stop All stopped and send "${RESUME_TEXT}"`,
onClick: onResumeStopped,
children: busy === "resume-stopped" ? "Resuming…" : "Resume Stopped",
}),
jsx.jsx("button", {
type: "button",
className: "dsh-fleet-control__btn",
disabled: busy !== "",
title: `Send "${RESUME_TEXT}" to every session, whether or not Stop All touched it`,
onClick: onResumeAll,
children: busy === "resume-all" ? "Resuming…" : "Resume All",
}),
note ? jsx.jsx("span", { className: "dsh-fleet-control__note", children: note }) : null,
],
});
}
const inject = ["slots", "sessions"];
function apply(ctx) {
installStyle();
ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
name: "conversation.session.header.utilities",
id: "fleet-control",
order: 70,
label: "Fleet control",
inject: () => ({
stopAll: () => stopAll(ctx.sessions),
resumeStopped: () => resumeStopped(ctx.sessions),
resumeAll: () => resumeAll(ctx.sessions),
}),
}, FleetControlButtons));
}
exports.apply = apply;
exports.inject = inject;
return module.exports;
},
});

View file

@ -0,0 +1,190 @@
/**
* dsh-fleet-control — host half.
*
* Persists the set of session ids that "Stop All" gracefully cancelled so that
* "Resume All" can re-prompt exactly those sessions later — including after an
* app restart. State lives in ~/.dsh/fleet-control/stopped.json.
*
* Exposes one loopback-only route, GET/POST /fleet/stopped:
* GET -> { ok: true, value: { ids: string[], updatedAt: string } }
* POST { ids: string[] } -> replaces the set, returns the same envelope.
*
* @module dsh-fleet-control
*/
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
const STORE_FILE = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'fleet-control', 'stopped.json')
export const name = 'dsh-fleet-control'
export const inject = ['webServer']
const JSON_HEADERS = {
'content-type': 'application/json; charset=utf-8',
'referrer-policy': 'no-referrer',
}
function writeJson(response, status, body) {
response.writeHead(status, JSON_HEADERS)
response.end(JSON.stringify(body))
}
// ---- loopback guards (mirrors dsh-open-in-smerge) -------------------------
function isIPv4Loopback(address) {
const parts = address.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
function isLoopbackAddress(address) {
if (address === undefined) return false
const normalized = address.toLowerCase()
if (normalized === '::1') return true
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
return isIPv4Loopback(normalized)
}
function isLoopbackHostname(hostname) {
return hostname === 'localhost' || hostname === '[::1]' || isIPv4Loopback(hostname)
}
export function isLoopbackRequest(request) {
if (!isLoopbackAddress(request.socket?.remoteAddress)) return false
const host = request.headers.host
if (typeof host !== 'string') return false
let hostUrl
try {
hostUrl = new URL(`http://${host}`)
} catch {
return false
}
if (!isLoopbackHostname(hostUrl.hostname)) return false
if (request.headers['sec-fetch-site'] === 'cross-site') return false
const origin = request.headers.origin
if (origin === undefined) return true
try {
return new URL(origin).host === hostUrl.host
} catch {
return false
}
}
// --------------------------------------------------------------------------
async function readJsonBody(request) {
const chunks = []
let size = 0
for await (const chunk of request) {
size += chunk.length
if (size > 64 * 1024) {
request.destroy()
return null
}
chunks.push(chunk)
}
if (chunks.length === 0) return null
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
function sanitizeIds(value) {
if (!Array.isArray(value)) return []
const out = []
for (const entry of value) {
if (typeof entry === 'string' && entry !== '' && out.indexOf(entry) === -1) out.push(entry)
}
return out
}
export class StoppedSessionStore {
constructor(filePath = STORE_FILE) {
this.filePath = filePath
this.ids = []
this.updatedAt = new Date(0).toISOString()
this.ready = this.load()
this.pendingWrite = Promise.resolve()
}
async load() {
try {
const parsed = JSON.parse(await readFile(this.filePath, 'utf8'))
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return
this.ids = sanitizeIds(parsed.ids)
if (typeof parsed.updatedAt === 'string') this.updatedAt = parsed.updatedAt
} catch {
this.ids = []
}
}
async get() {
await this.ready
return { ids: this.ids.slice(), updatedAt: this.updatedAt }
}
async set(ids) {
await this.ready
this.ids = sanitizeIds(ids)
this.updatedAt = new Date().toISOString()
this.pendingWrite = this.pendingWrite.then(() => this.persist())
await this.pendingWrite
return { ids: this.ids.slice(), updatedAt: this.updatedAt }
}
async persist() {
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath = `${this.filePath}.tmp-${process.pid}`
const body = JSON.stringify({ ids: this.ids, updatedAt: this.updatedAt }, null, 2)
await writeFile(temporaryPath, `${body}\n`, 'utf8')
await rename(temporaryPath, this.filePath)
}
}
export function apply(context) {
const store = new StoppedSessionStore()
const handler = async (request, response) => {
const method = (request.method ?? 'GET').toUpperCase()
if (method !== 'GET' && method !== 'POST') {
writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
return
}
if (!isLoopbackRequest(request)) {
writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
return
}
if (method === 'GET') {
writeJson(response, 200, { ok: true, value: await store.get() })
return
}
const body = await readJsonBody(request)
const value = await store.set(body && body.ids)
writeJson(response, 200, { ok: true, value })
}
context.effect(() => {
const disposers = [
context.webServer.register({ kind: 'exact', path: '/fleet/stopped', handler }),
]
return () => {
for (const dispose of disposers.reverse()) dispose()
}
}, 'fleet-control: stopped-session store route')
}

View file

@ -0,0 +1,42 @@
{
"name": "dsh-fleet-control",
"description": "Stop All / Resume All from the conversation header: gracefully cancel every running agent turn, remember exactly which sessions were running, and later re-prompt only those with a 'continue' message. Survives an app restart.",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": "^22.19.0 || >=24.0.0"
},
"main": "lib/index.js",
"exports": {
".": {
"default": "./lib/index.js"
},
"./client": {
"default": "./lib/client.js"
},
"./package.json": "./package.json"
},
"scripts": {
"test": "node --test"
},
"dsh": {
"engines": {
"dsh": ">=0.1.2-alpha.1"
},
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"files": [
"lib/**/*.js",
"cordis.patch.yml"
],
"license": "MIT"
}

View file

@ -0,0 +1,7 @@
# dsh-fullwidth bundle patch: inserts the plugin row into the profile roster.
# The node half (exports ".") is a no-op; all behavior lives in the browser
# half (exports "./client"), which injects a <style> overriding the chat
# content-width CSS variable so the transcript + composer fill the frame.
- insert:
- id: fullwidth
name: dsh-fullwidth

View file

@ -0,0 +1,72 @@
/**
* dsh-fullwidth — browser half.
*
* The whole chat column width is driven by one CSS custom property,
* `--dsh-chat-content-width`, defined as a hardcoded `748px` on the
* ConversationRoot element. The composer/input width derives from it
* (`--dsh-composer-card-max-width: calc(var(--dsh-chat-content-width) + 32px)`),
* as do the user bubbles. So overriding that single variable widens the
* transcript AND the input box together.
*
* We can't target the ConversationRoot class directly — it is content-hashed
* and changes between builds — so we set the variable on every element with
* `!important`. `!important` beats the app's normal declaration regardless of
* selector specificity, so the override wins on the root element too, and the
* value inherits/derives everywhere it is consumed.
*
* Rebind the width by editing CONTENT_WIDTH below.
* @module dsh-fullwidth/client
*/
window.__ModuleLoader__.load({
id: "dsh-fullwidth",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
// ---- CONFIG (edit here) -----------------------------------------------
// Width of the chat transcript + composer. Percentages fill the pane and
// leave symmetric gutters; use a px value or min()/clamp() to cap it on
// very wide monitors. Examples:
// "100%" — edge to edge, no gutter
// "96%" — full width, tiny gutter
// "min(1600px, 94%)" — full width but never wider than 1600px
// "clamp(748px, 92%, 1800px)"— original min, grow to 92%, cap at 1800px
const CONTENT_WIDTH = "min(1600px, 94%)";
// -----------------------------------------------------------------------
const STYLE_ID = "dsh-fullwidth-style";
const cssText = "*{--dsh-chat-content-width:" + CONTENT_WIDTH + " !important;}";
/** Insert (or update) the override <style>, returning a cleanup fn. */
function install() {
let el = document.getElementById(STYLE_ID);
if (!el) {
el = document.createElement("style");
el.id = STYLE_ID;
(document.head || document.documentElement).appendChild(el);
}
el.textContent = cssText;
return () => {
const node = document.getElementById(STYLE_ID);
if (node) node.remove();
};
}
/** No client services required — this is pure CSS. */
const inject = [];
/** Client plugin body: inject the width override (with HMR-safe cleanup). */
function apply(ctx) {
if (ctx && typeof ctx.effect === "function") {
ctx.effect(install, "dsh-fullwidth: chat content-width override");
} else {
install();
}
}
exports.apply = apply;
exports.inject = inject;
return module.exports;
},
});

View file

@ -0,0 +1,17 @@
/**
* dsh-fullwidth — host half.
*
* This plugin is client-only: every behavior lives in the browser half
* (exports "./client"), which injects a <style> overriding the chat
* content-width CSS variable. The host half exists solely so the profile row
* loads cleanly; it registers nothing.
* @module dsh-fullwidth
*/
export const name = 'dsh-fullwidth'
/** No host services required. */
export const inject = []
/** No-op host apply. */
export function apply() {}

View file

@ -0,0 +1,37 @@
{
"name": "dsh-fullwidth",
"description": "dsh web plugin: widen the chat transcript + composer to use the full horizontal space by overriding the --dsh-chat-content-width CSS variable. Client-only.",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": "^22.19.0 || >=24.0.0"
},
"main": "lib/index.js",
"exports": {
".": {
"default": "./lib/index.js"
},
"./client": {
"default": "./lib/client.js"
},
"./package.json": "./package.json"
},
"dsh": {
"engines": {
"dsh": ">=0.1.2-alpha.1"
},
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [],
"platform": "web"
}
},
"files": [
"lib/**/*.js",
"cordis.patch.yml"
],
"license": "MIT"
}

View file

@ -0,0 +1,7 @@
# dsh-header-menu bundle patch: inserts the plugin row into the profile roster.
# The node half (exports ".") is a no-op; all behavior lives in the browser
# half (exports "./client"), which collapses the conversation header utility
# buttons into a single dropdown menu.
- insert:
- id: header-menu
name: dsh-header-menu

View file

@ -0,0 +1,192 @@
/**
* dsh-header-menu — browser half.
*
* The conversation header exposes a utilities region (`.headerUtilities` inside
* `.headerActions`) that plugins/packages fill via the
* `conversation.session.header.utilities` slot: Session log, Stop All, Resume
* Stopped, Resume All, the Git indicator, etc. Several standalone pills eat a
* lot of horizontal space. This plugin collapses them behind one `⋯` trigger.
*
* Strategy — no React fighting:
* • We never move or synthetically click the real buttons; their real React
* handlers stay intact. We only (a) inject global CSS that restyles the
* `.headerUtilities` container into a hidden dropdown panel, and (b) add a
* single vanilla trigger button next to it.
* • CSS module classes are content-hashed, so we match the stable suffix via
* `[class*="_headerUtilities"]` / `[class*="_headerActions"]`.
* • Open/close state and the "managed" marks are stored in imperatively-set
* `data-*` attributes, which React does not manage and therefore does not
* clobber on re-render. A MutationObserver re-applies the enhancement (and
* re-adds the trigger) whenever React rebuilds the header.
* @module dsh-header-menu/client
*/
window.__ModuleLoader__.load({
id: "dsh-header-menu",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
// ---- CONFIG (edit here) -----------------------------------------------
// Label shown on the collapsed trigger button.
const TRIGGER_LABEL = "⋯"; // ⋯ (e.g. "Menu", "Actions ▾")
// Close the dropdown after clicking one of its items.
const CLOSE_ON_ITEM_CLICK = true;
// -----------------------------------------------------------------------
const STYLE_ID = "dsh-header-menu-style";
const STYLE_TEXT = [
// host anchors the absolutely-positioned panel
'[data-dsh-hdr-menu="host"]{position:relative}',
// the utility row becomes a dropdown panel
'[data-dsh-hdr-menu="panel"]{',
"position:absolute;top:calc(100% + 8px);right:0;z-index:2147483000;",
"margin-left:0 !important;flex-direction:column !important;",
"align-items:stretch !important;gap:6px !important;padding:8px;min-width:210px;",
"background:var(--dsw-alias-bg-elevated, var(--dsw-specific-sidebar-fill, var(--dsw-alias-bg-base,#1f1f1f)));",
"border:1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12));",
"border-radius:12px;box-shadow:0 12px 32px rgba(0,0,0,.45)}",
// hidden unless the host is marked open
'[data-dsh-hdr-menu="host"]:not([data-open="1"]) [data-dsh-hdr-menu="panel"]{display:none !important}',
// stack items full width, left aligned
'[data-dsh-hdr-menu="panel"] > *{width:100%;box-sizing:border-box;justify-content:flex-start !important}',
// known grouped entry (fleet-control) stacks vertically too
'[data-dsh-hdr-menu="panel"] .dsh-fleet-control{flex-direction:column;align-items:stretch;gap:6px;width:100%}',
'[data-dsh-hdr-menu="panel"] .dsh-fleet-control > *{width:100%;justify-content:flex-start}',
// the trigger pill
".dsh-hdr-menu-trigger{border:1px solid var(--dsw-alias-border-l2, rgba(255,255,255,.12));",
"height:32px;min-width:32px;color:var(--dsw-alias-label-primary);background:transparent;",
"border-radius:18px;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;",
"gap:5px;padding:6px 12px;font-size:15px;line-height:20px;white-space:nowrap;",
"font-family:var(--dsw-font-family)}",
".dsh-hdr-menu-trigger:hover{background:var(--dsw-alias-interactive-bg-hover)}",
'.dsh-hdr-menu-trigger[aria-expanded="true"]{background:var(--dsw-alias-interactive-bg-hover)}',
].join("");
function installStyle() {
if (document.getElementById(STYLE_ID)) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.dataset.plugin = "dsh-header-menu";
style.textContent = STYLE_TEXT;
(document.head || document.documentElement).appendChild(style);
}
/** Close every open header menu. */
function closeAll(except) {
document.querySelectorAll('[data-dsh-hdr-menu="host"][data-open="1"]').forEach((host) => {
if (host === except) return;
host.removeAttribute("data-open");
const t = host.querySelector(".dsh-hdr-menu-trigger");
if (t) t.setAttribute("aria-expanded", "false");
});
}
/** Build the trigger button for a given host (the .headerActions element). */
function makeTrigger(host) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "dsh-hdr-menu-trigger";
btn.textContent = TRIGGER_LABEL;
btn.title = "Session actions";
btn.setAttribute("aria-label", "Session actions menu");
btn.setAttribute("aria-expanded", "false");
btn.addEventListener("click", (e) => {
e.preventDefault();
e.stopPropagation();
const open = host.getAttribute("data-open") === "1";
closeAll(host);
if (open) {
host.removeAttribute("data-open");
btn.setAttribute("aria-expanded", "false");
} else {
host.setAttribute("data-open", "1");
btn.setAttribute("aria-expanded", "true");
}
});
return btn;
}
/** Enhance every header-utilities region currently in the DOM (idempotent). */
function enhance() {
const panels = document.querySelectorAll('[class*="_headerUtilities"]');
panels.forEach((panel) => {
const host = panel.parentElement;
if (!host) return;
panel.setAttribute("data-dsh-hdr-menu", "panel");
host.setAttribute("data-dsh-hdr-menu", "host");
// close-on-item-click wiring (once)
if (CLOSE_ON_ITEM_CLICK && panel.getAttribute("data-dsh-hdr-wired") !== "1") {
panel.setAttribute("data-dsh-hdr-wired", "1");
panel.addEventListener("click", (e) => {
if (e.target && e.target.closest("button,a,[role='menuitem'],[role='button']")) {
// let the real handler run, then close on next tick
setTimeout(() => closeAll(), 0);
}
});
}
// ensure exactly one trigger, placed right after the panel
if (!host.querySelector(".dsh-hdr-menu-trigger")) {
const trigger = makeTrigger(host);
if (panel.nextSibling) host.insertBefore(trigger, panel.nextSibling);
else host.appendChild(trigger);
}
});
}
let scheduled = false;
function scheduleEnhance() {
if (scheduled) return;
scheduled = true;
requestAnimationFrame(() => {
scheduled = false;
enhance();
});
}
/** No client services required. */
const inject = [];
function apply(ctx) {
const start = () => {
installStyle();
enhance();
const observer = new MutationObserver(scheduleEnhance);
observer.observe(document.body, { childList: true, subtree: true });
const onDocClick = (e) => {
const host = e.target && e.target.closest && e.target.closest('[data-dsh-hdr-menu="host"]');
if (!host) closeAll();
};
const onKey = (e) => {
if (e.key === "Escape") closeAll();
};
document.addEventListener("click", onDocClick, true);
document.addEventListener("keydown", onKey, true);
return () => {
observer.disconnect();
document.removeEventListener("click", onDocClick, true);
document.removeEventListener("keydown", onKey, true);
closeAll();
const style = document.getElementById(STYLE_ID);
if (style) style.remove();
document.querySelectorAll(".dsh-hdr-menu-trigger").forEach((n) => n.remove());
document.querySelectorAll('[data-dsh-hdr-menu]').forEach((n) => {
n.removeAttribute("data-dsh-hdr-menu");
n.removeAttribute("data-open");
n.removeAttribute("data-dsh-hdr-wired");
});
};
};
if (ctx && typeof ctx.effect === "function") ctx.effect(start, "dsh-header-menu: collapse header utilities");
else start();
}
exports.apply = apply;
exports.inject = inject;
return module.exports;
},
});

View file

@ -0,0 +1,16 @@
/**
* dsh-header-menu — host half.
*
* Client-only: all behavior lives in the browser half (exports "./client"),
* which collapses the conversation header utility buttons into a dropdown.
* The host half exists solely so the profile row loads cleanly.
* @module dsh-header-menu
*/
export const name = 'dsh-header-menu'
/** No host services required. */
export const inject = []
/** No-op host apply. */
export function apply() {}

View file

@ -0,0 +1,37 @@
{
"name": "dsh-header-menu",
"description": "dsh web plugin: collapse the conversation header utility buttons (Session log, Stop All, Resume Stopped, Resume All, Git) into a single dropdown menu. Client-only.",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": "^22.19.0 || >=24.0.0"
},
"main": "lib/index.js",
"exports": {
".": {
"default": "./lib/index.js"
},
"./client": {
"default": "./lib/client.js"
},
"./package.json": "./package.json"
},
"dsh": {
"engines": {
"dsh": ">=0.1.2-alpha.1"
},
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [],
"platform": "web"
}
},
"files": [
"lib/**/*.js",
"cordis.patch.yml"
],
"license": "MIT"
}

View file

@ -0,0 +1,9 @@
# dsh-open-in-smerge bundle patch: inserts the dual-face plugin row into the
# profile roster. Applied as a profile bundle layer (the `dsh.bundle.patch`
# manifest field) over dsh-base. The row is a bare plugin by package name:
# the node half (exports ".") runs in the host process (the /smerge/open
# route), and the dsh.client declaration in package.json makes the browser
# half (exports "./client") load in the web GUI.
- insert:
- id: open-in-smerge
name: dsh-open-in-smerge

View file

@ -0,0 +1,152 @@
window.__ModuleLoader__.load({
id: "dsh-open-in-smerge",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const react = require("react");
const jsx = require("react/jsx-runtime");
const TRANSPORT_ERROR = { code: "route-unavailable", message: "Git target route unavailable" };
const STYLE_ID = "dsh-open-in-smerge-style";
const STYLE_TEXT = ".dsh-open-in-smerge{border:1px solid var(--dsw-alias-border-l2);max-width:260px;height:32px;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family);cursor:pointer;background:transparent;border-radius:18px;display:inline-flex;align-items:center;justify-content:center;gap:5px;padding:6px 12px;font-size:13px;font-weight:400;line-height:20px;white-space:nowrap}.dsh-open-in-smerge:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover)}.dsh-open-in-smerge:disabled{color:var(--dsw-alias-label-dimmed);cursor:default}.dsh-open-in-smerge[data-failed=true]{color:var(--dsw-alias-status-error,var(--dsw-alias-label-primary));border-color:currentColor}.dsh-open-in-smerge__kind{font-weight:600}.dsh-open-in-smerge__directory{min-width:0;overflow:hidden;text-overflow:ellipsis}.dsh-open-in-smerge__separator{color:var(--dsw-alias-label-tertiary)}";
function installStyle() {
if (document.getElementById(STYLE_ID) !== null) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.dataset.plugin = "dsh-open-in-smerge";
style.textContent = STYLE_TEXT;
document.head.appendChild(style);
}
async function request(path, options) {
let response;
try {
response = await fetch(path, options);
} catch {
return { ok: false, error: TRANSPORT_ERROR };
}
try {
const envelope = await response.json();
if (typeof envelope !== "object" || envelope === null) return { ok: false, error: TRANSPORT_ERROR };
if (envelope.ok === true) return { ok: true, value: envelope.value };
return { ok: false, error: envelope.error ?? TRANSPORT_ERROR };
} catch {
return { ok: false, error: TRANSPORT_ERROR };
}
}
function directoryName(path) {
const trimmed = path.replace(/[/\\]+$/, "");
const parts = trimmed.split(/[/\\]/);
return parts[parts.length - 1] || trimmed;
}
function OpenInSmergeButton(props) {
const [busy, setBusy] = react.useState(false);
const [error, setError] = react.useState(null);
const [value, setValue] = react.useState(null);
const refresh = react.useCallback(async () => {
const result = await props.target(props.sessionId);
if (result.ok) {
setValue(result.value);
setError(null);
return;
}
setError(result.error);
}, [props.sessionId]);
react.useEffect(() => {
let active = true;
const update = async () => {
if (!active || document.visibilityState === "hidden") return;
await refresh();
};
const onVisibilityChange = () => {
if (document.visibilityState === "visible") void update();
};
void update();
const timer = window.setInterval(() => { void update(); }, 2500);
document.addEventListener("visibilitychange", onVisibilityChange);
return () => {
active = false;
window.clearInterval(timer);
document.removeEventListener("visibilitychange", onVisibilityChange);
};
}, [refresh]);
const onClick = async () => {
if (busy) return;
setBusy(true);
setError(null);
const result = await props.open(props.sessionId);
setBusy(false);
if (result.ok) {
setValue(result.value);
return;
}
setError(result.error);
};
const isSet = value !== null && value.set === true;
const name = isSet ? directoryName(value.workingDirectory) : "";
const title = error !== null
? `Git target error: ${error.message}`
: isSet
? `Working directory: ${value.workingDirectory}\nRepository: ${value.repository}\nClick to open in Sublime Merge`
: "No Git working directory set — the agent sets it with set_active_git_directory";
return jsx.jsxs("button", {
type: "button",
className: "dsh-open-in-smerge",
disabled: busy || !isSet,
"aria-busy": busy,
"aria-label": isSet ? `Open Git working directory ${name}` : "Git working directory not set",
"data-failed": error !== null,
title,
onClick,
children: [
jsx.jsx("span", { className: "dsh-open-in-smerge__kind", children: "Git" }),
isSet ? jsx.jsxs(react.Fragment, { children: [
jsx.jsx("span", { className: "dsh-open-in-smerge__separator", "aria-hidden": true, children: "·" }),
jsx.jsx("span", { className: "dsh-open-in-smerge__directory", children: busy ? "Opening…" : name }),
] }) : null,
],
});
}
const inject = ["slots"];
function apply(ctx) {
installStyle();
ctx.slots.inject("conversation.session.header.utilities", () => ctx.slots.register({
name: "conversation.session.header.utilities",
id: "open-in-smerge",
order: 80,
label: "Git working directory",
inject: () => ({
target: (sessionId) => request(`/smerge/target?sessionId=${encodeURIComponent(sessionId)}`),
open: (sessionId) => request("/smerge/open", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionId }),
}),
}),
}, OpenInSmergeButton));
}
exports.apply = apply;
exports.inject = inject;
return module.exports;
},
});

View file

@ -0,0 +1,337 @@
import { execFile, spawn } from 'node:child_process'
import { mkdir, readFile, realpath, rename, stat, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { defineTool } from '@deepseek-ai/dsh-tools'
const execFileAsync = promisify(execFile)
const TARGET_FILE = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'open-in-smerge', 'targets.json')
const TARGET_GUIDANCE = 'Before editing source or configuration, briefly state the basic logic progression to the user: current behavior, intended behavior, and key implementation steps. Track your Git working directory at all times. Whenever you choose the repository or linked worktree where you will edit, build, or commit — including after a cd in a shell, switching branches or worktrees, or starting a new task — immediately call set_active_git_directory with that directory, and call it again whenever the effective working directory changes. The session-header Git control has no automatic fallback: until you set it, it stays empty, which the user reads as you not tracking your location. Keep it accurate.'
export const name = 'dsh-open-in-smerge'
export const inject = ['webServer', 'tools', 'systemPrompt']
const JSON_HEADERS = {
'content-type': 'application/json; charset=utf-8',
'referrer-policy': 'no-referrer',
}
function writeJson(response, status, body) {
response.writeHead(status, JSON_HEADERS)
response.end(JSON.stringify(body))
}
function isIPv4Loopback(address) {
const parts = address.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
function isLoopbackAddress(address) {
if (address === undefined) return false
const normalized = address.toLowerCase()
if (normalized === '::1') return true
if (normalized.startsWith('::ffff:')) return isIPv4Loopback(normalized.slice('::ffff:'.length))
return isIPv4Loopback(normalized)
}
function isLoopbackHostname(hostname) {
return hostname === 'localhost' || hostname === '[::1]' || isIPv4Loopback(hostname)
}
export function isLoopbackRequest(request) {
if (!isLoopbackAddress(request.socket?.remoteAddress)) return false
const host = request.headers.host
if (typeof host !== 'string') return false
let hostUrl
try {
hostUrl = new URL(`http://${host}`)
} catch {
return false
}
if (!isLoopbackHostname(hostUrl.hostname)) return false
if (request.headers['sec-fetch-site'] === 'cross-site') return false
const origin = request.headers.origin
if (origin === undefined) return true
try {
return new URL(origin).host === hostUrl.host
} catch {
return false
}
}
async function readJsonBody(request) {
const chunks = []
let size = 0
for await (const chunk of request) {
size += chunk.length
if (size > 64 * 1024) {
request.destroy()
return null
}
chunks.push(chunk)
}
if (chunks.length === 0) return null
try {
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'))
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) ? parsed : null
} catch {
return null
}
}
function targetRecord(value) {
if (typeof value !== 'object' || value === null) return undefined
if (typeof value.workingDirectory !== 'string' || value.workingDirectory === '') return undefined
if (typeof value.repository !== 'string' || value.repository === '') return undefined
return {
workingDirectory: value.workingDirectory,
repository: value.repository,
updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : new Date(0).toISOString(),
}
}
export class ActiveGitTargetStore {
constructor(filePath = TARGET_FILE) {
this.filePath = filePath
this.targets = new Map()
this.ready = this.load()
this.pendingWrite = Promise.resolve()
}
async load() {
try {
const parsed = JSON.parse(await readFile(this.filePath, 'utf8'))
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return
for (const [sessionId, value] of Object.entries(parsed)) {
const record = targetRecord(value)
if (record !== undefined) this.targets.set(sessionId, record)
}
} catch {
this.targets.clear()
}
}
async get(sessionId) {
await this.ready
return this.targets.get(sessionId)
}
async set(sessionId, target) {
await this.ready
const record = { ...target, updatedAt: new Date().toISOString() }
this.targets.set(sessionId, record)
this.pendingWrite = this.pendingWrite.then(() => this.persist())
await this.pendingWrite
return record
}
async persist() {
await mkdir(dirname(this.filePath), { recursive: true })
const temporaryPath = `${this.filePath}.tmp-${process.pid}`
const body = JSON.stringify(Object.fromEntries(this.targets), null, 2)
await writeFile(temporaryPath, `${body}\n`, 'utf8')
await rename(temporaryPath, this.filePath)
}
}
export async function resolveDirectoryRepository(directory) {
let canonicalWorkingDirectory
try {
canonicalWorkingDirectory = await realpath(directory)
const info = await stat(canonicalWorkingDirectory)
if (!info.isDirectory()) throw new Error('not a directory')
} catch {
throw Object.assign(new Error('working directory does not resolve'), { code: 'cwd-unavailable', status: 409 })
}
try {
const result = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
cwd: canonicalWorkingDirectory,
encoding: 'utf8',
timeout: 10_000,
windowsHide: true,
})
const repository = await realpath(result.stdout.trim())
return { workingDirectory: canonicalWorkingDirectory, repository }
} catch {
throw Object.assign(new Error('working directory is not inside a Git repository'), { code: 'not-a-repository', status: 409 })
}
}
export async function resolveSessionRepository(targetStore, sessionId) {
const selected = await targetStore.get(sessionId)
if (selected === undefined) return null
try {
const resolved = await resolveDirectoryRepository(selected.workingDirectory)
return { ...resolved, updatedAt: selected.updatedAt }
} catch {
return null
}
}
export async function openSublimeMerge(repository) {
const child = spawn('smerge', [repository], {
detached: true,
stdio: 'ignore',
})
await new Promise((resolveSpawn, rejectSpawn) => {
child.once('spawn', resolveSpawn)
child.once('error', rejectSpawn)
})
child.unref()
}
function sessionIdFromToolExecution(execution) {
const sessionId = execution.agent?.session.header.id
if (typeof sessionId !== 'string' || sessionId === '') {
throw new Error('set_active_git_directory requires an active agent session')
}
return sessionId
}
function registerTargetTool(context, targetStore) {
return context.tools.register(defineTool({
name: 'set_active_git_directory',
description: 'Set the Git working directory shown by the session-header Git control and opened in Sublime Merge. Call this whenever you choose or switch the repository or linked worktree where you edit, build, or commit, so the control stays accurate.',
parameters: {
path: {
type: 'string',
required: true,
description: 'Absolute path, or a path relative to the session cwd, inside the active Git repository or linked worktree.',
},
},
output: {
schema: {
type: 'object',
additionalProperties: false,
properties: {
workingDirectory: { type: 'string', required: true },
repository: { type: 'string', required: true },
},
},
render: (_arguments, value) => [{
type: 'text',
text: `Active Git directory: ${value.workingDirectory}\nRepository: ${value.repository}`,
}],
},
execute: async (argumentsValue, execution) => {
execution.signal.throwIfAborted()
const sessionId = sessionIdFromToolExecution(execution)
const base = execution.agent?.session.header.cwd
const requestedPath = argumentsValue.path.trim()
const absolutePath = isAbsolute(requestedPath) ? requestedPath : resolve(base || process.cwd(), requestedPath)
const target = await resolveDirectoryRepository(absolutePath)
await targetStore.set(sessionId, target)
return target
},
}))
}
function requestSessionId(requestUrl, host) {
try {
return new URL(requestUrl ?? '/', `http://${host}`).searchParams.get('sessionId')?.trim() || ''
} catch {
return ''
}
}
export function apply(context) {
const targetStore = new ActiveGitTargetStore()
const targetHandler = async (request, response) => {
if ((request.method ?? 'GET').toUpperCase() !== 'GET') {
writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
return
}
if (!isLoopbackRequest(request)) {
writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
return
}
const sessionId = requestSessionId(request.url, request.headers.host)
if (sessionId === '') {
writeJson(response, 400, { ok: false, error: { code: 'invalid-session', message: 'missing session id' } })
return
}
const resolved = await resolveSessionRepository(targetStore, sessionId)
if (resolved === null) {
writeJson(response, 200, { ok: true, value: { set: false } })
return
}
writeJson(response, 200, { ok: true, value: { set: true, ...resolved } })
}
const openHandler = async (request, response) => {
if ((request.method ?? 'GET').toUpperCase() !== 'POST') {
writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
return
}
if (!isLoopbackRequest(request)) {
writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
return
}
const body = await readJsonBody(request)
const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId.trim() : ''
if (sessionId === '') {
writeJson(response, 400, { ok: false, error: { code: 'invalid-session', message: 'missing session id' } })
return
}
const resolved = await resolveSessionRepository(targetStore, sessionId)
if (resolved === null) {
writeJson(response, 409, { ok: false, error: { code: 'not-set', message: 'no Git working directory set for this session' } })
return
}
await openSublimeMerge(resolved.repository)
writeJson(response, 200, { ok: true, value: { set: true, ...resolved } })
}
context.effect(() => {
const disposers = [
context.webServer.register({ kind: 'exact', path: '/smerge/target', handler: targetHandler }),
context.webServer.register({ kind: 'exact', path: '/smerge/open', handler: openHandler }),
registerTargetTool(context, targetStore),
context.systemPrompt.section({
name: 'plugin:open-in-smerge',
order: 145,
text: TARGET_GUIDANCE,
}),
]
return () => {
for (const dispose of disposers.reverse()) dispose()
}
}, 'open-in-smerge: routes, tool, and active Git target guidance')
}

View file

@ -0,0 +1,255 @@
{
"name": "dsh-open-in-smerge",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dsh-open-in-smerge",
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": "^22.19.0 || >=24.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-tools": "0.1.1-rc.2"
}
},
"node_modules/@deepseek-ai/cordis": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/cordis/-/cordis-4.0.2.tgz",
"integrity": "sha512-asOnXP1TzFSFQlHb1iegDZp0z/8WD1c7YNrwJR/Tx2bzNuMXfcekE/I67Iv6SQXeLB4csxqCngzQKANP7gdw0g==",
"license": "MIT",
"peer": true,
"dependencies": {
"@deepseek-ai/cosmokit": "^1.8.3",
"@standard-schema/spec": "^1.1.0"
},
"bin": {
"cordis": "bin.js"
},
"peerDependencies": {
"@deepseek-ai/cordis-plugin-include": "^1.0.7",
"@deepseek-ai/cordis-plugin-loader": "^1.0.3"
},
"peerDependenciesMeta": {
"@deepseek-ai/cordis-plugin-include": {
"optional": true
},
"@deepseek-ai/cordis-plugin-loader": {
"optional": true
}
}
},
"node_modules/@deepseek-ai/cosmokit": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/@deepseek-ai/cosmokit/-/cosmokit-1.8.3.tgz",
"integrity": "sha512-qBo+ronVM6Eu2WNVJXi8JcMiqZ19T9BRIpV+5qJUFPXjGH/Z0QKcQMC/IZJ7L394YTOtJgcovbk9qP0w2GsBXQ==",
"license": "MIT",
"peer": true
},
"node_modules/@deepseek-ai/dsh-agent": {
"version": "0.1.1-rc.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-agent/-/dsh-agent-0.1.1-rc.2.tgz",
"integrity": "sha512-cC7lnJe7JgPFcreNXxcxLMxQd78LnpVO9ZXROjZsGRQN1zGH6i/DduI892F1am85IfzzO+XTxMwwUHmfwamb0g==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
"@deepseek-ai/dsh-scope": "^0.1.1-rc.2",
"@deepseek-ai/dsh-session": "^0.1.1-rc.2",
"@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
"@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2"
}
},
"node_modules/@deepseek-ai/dsh-attachment": {
"version": "0.1.1-rc.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-attachment/-/dsh-attachment-0.1.1-rc.2.tgz",
"integrity": "sha512-rCYAt8QsawP1yfDCU7XxNwYT/XWvyFsxYrkwhLLkdfW83QVD0CQHizSkTQE7RFX74nKUD1z3sTLfnLr7xneArw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-brand": "^0.1.1-rc.2",
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2"
}
},
"node_modules/@deepseek-ai/dsh-brand": {
"version": "0.1.1-rc.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-brand/-/dsh-brand-0.1.1-rc.2.tgz",
"integrity": "sha512-8vXsAXoUdzKAgvd/E9DgyT6HKmR6ZM4rtJ3fs/XoJ6n2kBk4tWil8Lv+jxCMWJeMOnTJF55gu2+NWQ3ECFPtJw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2"
}
},
"node_modules/@deepseek-ai/dsh-code-runtime": {
"version": "0.1.1-rc.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-code-runtime/-/dsh-code-runtime-0.1.1-rc.2.tgz",
"integrity": "sha512-SgFresqH5UABzRQZ7tOfqzOLMHF7089VeH+mfcwNQH5peOavgEKrAGOYz/9RnISH0XmMrj/x177t8gfO8Uvo/w==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2"
}
},
"node_modules/@deepseek-ai/dsh-invariants": {
"version": "0.1.1-rc.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-invariants/-/dsh-invariants-0.1.1-rc.2.tgz",
"integrity": "sha512-l+1Om/EDFyMjhgSuEx2WDLLA2fia/+ga9mBTCoT/MMslsnWaK5G0/lWwbwlTBSaJ6OfmYc3DuBgox8DbgIGHRQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1"
}
},
"node_modules/@deepseek-ai/dsh-llm": {
"version": "0.1.1-rc.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-llm/-/dsh-llm-0.1.1-rc.2.tgz",
"integrity": "sha512-ASJfjIdZbIXvLwi3rGo+eZb/GxMVV/WO5/XVD3B96mT8EIzrlw3+nMR6/CvmJVzcycKQ2XN0wj7jD6TasPRySA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-attachment": "^0.1.1-rc.2",
"@deepseek-ai/dsh-brand": "^0.1.1-rc.2",
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
"@deepseek-ai/dsh-timeout": "^0.1.1-rc.2"
}
},
"node_modules/@deepseek-ai/dsh-scope": {
"version": "0.1.1-rc.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-scope/-/dsh-scope-0.1.1-rc.2.tgz",
"integrity": "sha512-Xy3ejL6dwVSluZL7XOWy76ya4pCw1uHwxodDK4O9XiQUiUV4FBXnt0aNJUtMeAFN0c1YujxxCmRniMvuuNn1Nw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2"
}
},
"node_modules/@deepseek-ai/dsh-session": {
"version": "0.1.1-rc.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-session/-/dsh-session-0.1.1-rc.2.tgz",
"integrity": "sha512-4/cv6X9HPhm47eyRhCu/WZwzrtJKegk5J+0xaxcZ9i8S0smdxP57tqy8a0jkSshLQn7BzMFxneQrlYExrLrDhQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-brand": "^0.1.1-rc.2",
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
"@deepseek-ai/dsh-scope": "^0.1.1-rc.2",
"@deepseek-ai/dsh-typert-protocol": "^0.1.1-rc.2"
}
},
"node_modules/@deepseek-ai/dsh-system-prompt": {
"version": "0.1.1-rc.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-system-prompt/-/dsh-system-prompt-0.1.1-rc.2.tgz",
"integrity": "sha512-on4hjAlYI5uX9q7Sf95YkMMBVe6heywtA/H50ksrIMUub8U2B98hO9iQpHhjwIO1F1vu+5pLcPvRr6yUGGmtXQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
"@deepseek-ai/dsh-scope": "^0.1.1-rc.2"
}
},
"node_modules/@deepseek-ai/dsh-timeout": {
"version": "0.1.1-rc.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-timeout/-/dsh-timeout-0.1.1-rc.2.tgz",
"integrity": "sha512-RrouVgU3G5gXr9zHhpThkMG6YKdcRJzXXdPm1dq3ioBxbvxlfMSfNY4tN8lWMJxLyGtvWkPra0HQX+YWxvdOOA==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2"
}
},
"node_modules/@deepseek-ai/dsh-tools": {
"version": "0.1.1-rc.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-tools/-/dsh-tools-0.1.1-rc.2.tgz",
"integrity": "sha512-0GGL4D55MwYDepzZMOI3L0ycu5b2qr96GL0Y7snwhAnpK2Di61rbX3fJE+PB3ZrovGX0csIRdt9n3iJZDVtDrw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
"@deepseek-ai/dsh-code-runtime": "^0.1.1-rc.2",
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
"@deepseek-ai/dsh-scope": "^0.1.1-rc.2",
"@deepseek-ai/dsh-session": "^0.1.1-rc.2",
"@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2",
"@deepseek-ai/dsh-user-approval": "^0.1.1-rc.2"
}
},
"node_modules/@deepseek-ai/dsh-typert-protocol": {
"version": "0.1.1-rc.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-typert-protocol/-/dsh-typert-protocol-0.1.1-rc.2.tgz",
"integrity": "sha512-lxBssDc5Pz1qBE5kuIyaArA7AvIPq9rpaVclylodiSzVJe95e2xruBg73tflyjtd8y00toet+DLgQ6tSSsq6Kw==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2"
}
},
"node_modules/@deepseek-ai/dsh-user-approval": {
"version": "0.1.1-rc.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/dsh-user-approval/-/dsh-user-approval-0.1.1-rc.2.tgz",
"integrity": "sha512-SdsO4Rs+NeJFoertkVilXBACREOLfkKPJJznYKqDhJxeRo38RJ56dtj0Xd0/6rERmsQiMck4Bwdrzg1ubUqPNA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@deepseek-ai/schemastery": "^3.18.1"
},
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-agent": "^0.1.1-rc.2",
"@deepseek-ai/dsh-brand": "^0.1.1-rc.2",
"@deepseek-ai/dsh-invariants": "^0.1.1-rc.2",
"@deepseek-ai/dsh-llm": "^0.1.1-rc.2",
"@deepseek-ai/dsh-scope": "^0.1.1-rc.2",
"@deepseek-ai/dsh-session": "^0.1.1-rc.2",
"@deepseek-ai/dsh-system-prompt": "^0.1.1-rc.2"
}
},
"node_modules/@deepseek-ai/schemastery": {
"version": "3.18.2",
"resolved": "https://registry.npmjs.org/@deepseek-ai/schemastery/-/schemastery-3.18.2.tgz",
"integrity": "sha512-njDtZsznjYxok7KLLlHOPyuv2efdWVbSflAHgztSfbMsg+CVraEoRe2DjOCgClYv3ZCSm7WXoaUkbB/+RY7tWQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@deepseek-ai/cosmokit": "^1.8.3",
"@standard-schema/spec": "^1.1.0"
}
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT",
"peer": true
}
}
}

View file

@ -0,0 +1,45 @@
{
"name": "dsh-open-in-smerge",
"description": "Open the active agent session's Git repository in Sublime Merge from the conversation header.",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": "^22.19.0 || >=24.0.0"
},
"main": "lib/index.js",
"exports": {
".": {
"default": "./lib/index.js"
},
"./client": {
"default": "./lib/client.js"
},
"./package.json": "./package.json"
},
"scripts": {
"test": "node --test"
},
"dsh": {
"engines": {
"dsh": ">=0.1.2-alpha.1"
},
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"files": [
"lib/**/*.js",
"cordis.patch.yml"
],
"peerDependencies": {
"@deepseek-ai/dsh-tools": "0.1.1-rc.2"
},
"license": "MIT"
}

View file

@ -0,0 +1,95 @@
import assert from 'node:assert/strict'
import { execFile } from 'node:child_process'
import { mkdtemp, mkdir, realpath, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import test from 'node:test'
import { ActiveGitTargetStore, isLoopbackRequest, resolveDirectoryRepository, resolveSessionRepository } from '../lib/index.js'
const execFileAsync = promisify(execFile)
async function repositoryAt(path) {
await mkdir(path, { recursive: true })
await execFileAsync('git', ['init', '--quiet'], { cwd: path })
return realpath(path)
}
test('resolves the repository containing a nested working directory', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-open-in-smerge-'))
const nested = join(root, 'modules', 'runtime')
try {
await mkdir(nested, { recursive: true })
await execFileAsync('git', ['init', '--quiet'], { cwd: root })
const resolved = await resolveDirectoryRepository(nested)
assert.equal(resolved.workingDirectory, await realpath(nested))
assert.equal(resolved.repository, await realpath(root))
} finally {
await rm(root, { recursive: true, force: true })
}
})
test('returns null when the agent has not set a target', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-open-in-smerge-'))
try {
const store = new ActiveGitTargetStore(join(root, 'targets.json'))
const resolved = await resolveSessionRepository(store, 'session-without-target')
assert.equal(resolved, null)
} finally {
await rm(root, { recursive: true, force: true })
}
})
test('agent-set working directory resolves and persists across store reloads', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-open-in-smerge-'))
const selectedRoot = join(root, 'runtime-worktrees', 'radio-manager-cli')
const targetFile = join(root, 'state', 'targets.json')
try {
await repositoryAt(selectedRoot)
const firstStore = new ActiveGitTargetStore(targetFile)
const selected = await resolveDirectoryRepository(selectedRoot)
await firstStore.set('session-a', selected)
const reloadedStore = new ActiveGitTargetStore(targetFile)
const resolved = await resolveSessionRepository(reloadedStore, 'session-a')
assert.equal(resolved.workingDirectory, await realpath(selectedRoot))
assert.equal(resolved.repository, await realpath(selectedRoot))
} finally {
await rm(root, { recursive: true, force: true })
}
})
test('returns null for a stale target whose directory no longer resolves', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-open-in-smerge-'))
const targetFile = join(root, 'targets.json')
try {
const store = new ActiveGitTargetStore(targetFile)
await store.set('session-a', { workingDirectory: join(root, 'deleted-worktree'), repository: join(root, 'deleted-worktree') })
const resolved = await resolveSessionRepository(store, 'session-a')
assert.equal(resolved, null)
} finally {
await rm(root, { recursive: true, force: true })
}
})
test('accepts only same-origin loopback requests', () => {
const request = {
socket: { remoteAddress: '::ffff:127.0.0.1' },
headers: { host: '127.0.0.1:3080', origin: 'http://127.0.0.1:3080' },
}
assert.equal(isLoopbackRequest(request), true)
assert.equal(isLoopbackRequest({ ...request, headers: { ...request.headers, host: 'example.com' } }), false)
assert.equal(isLoopbackRequest({ ...request, headers: { ...request.headers, 'sec-fetch-site': 'cross-site' } }), false)
})

View file

@ -0,0 +1,3 @@
- insert:
- id: dsh-project-tree
name: dsh-project-tree

View file

@ -0,0 +1,596 @@
window.__ModuleLoader__.load({
id: "dsh-project-tree",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const react = require("react");
const runtimeClient = require("@deepseek-ai/dsh-client-runtime/client");
const e = react.createElement;
const name = "dsh-project-tree";
const inject = ["slots", "sessions", "workspaces"];
const ROUTE_PREFIX = "/dsh-project-tree";
const VIEW_PERSIST_KEY = "dsh-project-tree.view";
const STYLE_ID = "dsh-project-tree-style";
const RELOAD_MS = 4000;
const INDENT = 10;
const STYLE_TEXT = [
".dpt{display:flex;flex-direction:column;height:100%;overflow:auto;font-family:var(--dsw-font-family);color:var(--dsw-alias-label-primary);font-size:13px;padding:6px 12px 24px 0}",
".dpt__hint{padding:8px 10px;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:1.5}",
".dpt__notice{margin:4px 8px;padding:6px 8px;border-radius:6px;background:var(--dsw-alias-status-error,#b34);color:#fff;font-size:12px}",
".dpt__bar{display:flex;align-items:center;justify-content:space-between;padding:2px 8px 6px;color:var(--dsw-alias-label-tertiary)}",
".dpt__bar b{font-size:11px;text-transform:uppercase;letter-spacing:.05em;font-weight:600}",
".dpt__iconbtn{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border-radius:5px;cursor:pointer;color:var(--dsw-alias-label-tertiary);background:transparent;border:none}",
".dpt__iconbtn:hover{background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-label-primary)}",
".dpt__row{position:relative;display:flex;align-items:center;height:28px;padding-left:4px;cursor:pointer;white-space:nowrap;user-select:none}",
".dpt__body{position:relative;flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:5px;height:24px;padding:0 6px;border-radius:6px;box-sizing:border-box}",
".dpt__row:hover .dpt__body{background:var(--dsw-alias-interactive-bg-hover)}",
".dpt__row[data-active=true] .dpt__body{box-shadow:inset 0 0 0 1px color-mix(in srgb, var(--dsw-alias-label-primary,#dddddd) 45%, transparent)}",
".dpt__row[data-drop=into] .dpt__body{background:var(--dsw-alias-interactive-bg-active,rgba(80,140,255,.18));box-shadow:inset 0 0 0 1px var(--dsw-alias-accent,#58f)}",
".dpt__row[data-drop=before] .dpt__body::before{content:'';position:absolute;left:0;right:0;top:-2px;height:2px;background:var(--dsw-alias-accent,#58f);border-radius:2px}",
".dpt__row[data-dragging=true]{opacity:.4}",
".dpt__twist{display:inline-flex;justify-content:center;width:14px;color:var(--dsw-alias-label-tertiary);flex:0 0 auto;transition:transform .1s}",
".dpt__ico{display:inline-flex;flex:0 0 auto;color:var(--dsw-alias-label-secondary,var(--dsw-alias-label-tertiary))}",
".dpt__ico[data-kind=session]{color:var(--dsw-alias-label-tertiary)}",
".dpt__label{overflow:hidden;text-overflow:ellipsis;flex:1 1 auto;min-width:0}",
".dpt__sub{display:none;color:var(--dsw-alias-label-tertiary);font-size:11px;flex:0 0 auto;max-width:110px;overflow:hidden;text-overflow:ellipsis}",
".dpt__row:hover .dpt__sub{display:inline-block}",
".dpt__actions{display:none;align-items:center;gap:2px;flex:0 0 auto}",
".dpt__row:hover .dpt__actions{display:inline-flex}",
".dpt__work{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:4px;flex:0 0 auto;color:var(--dsw-alias-label-tertiary);cursor:pointer;opacity:.55}",
".dpt__work:hover{background:var(--dsw-alias-interactive-bg-active,rgba(255,255,255,.08));color:var(--dsw-alias-label-primary);opacity:1}",
".dpt__work[data-on=true]{color:var(--dsw-alias-status-success,#3b9);opacity:1}",
".dpt__act{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:4px;color:var(--dsw-alias-label-tertiary)}",
".dpt__act:hover{background:var(--dsw-alias-interactive-bg-active,rgba(255,255,255,.08));color:var(--dsw-alias-label-primary)}",
".dpt__children{position:relative;margin-left:" + (INDENT + 6) + "px;border-left:1px solid var(--dsw-alias-border-l2,rgba(255,255,255,.12))}",
".dpt__dot{width:7px;height:7px;border-radius:50%;background:var(--dsw-alias-label-tertiary)}",
".dpt__dot[data-state=running]{background:var(--dsw-alias-status-success,#3b9)}",
".dpt__dot[data-state=waiting]{background:var(--dsw-alias-status-warning,#e94)}",
".dpt__dot[data-state=idle]{background:transparent;box-shadow:inset 0 0 0 1px var(--dsw-alias-label-tertiary)}",
".dpt__menu{position:fixed;z-index:9999;min-width:190px;max-height:70vh;overflow:auto;background:var(--dsw-alias-surface-overlay,#232323);border:1px solid var(--dsw-alias-border-l2,#444);border-radius:8px;padding:4px;box-shadow:0 8px 30px rgba(0,0,0,.45);font-size:13px}",
".dpt__mi{display:flex;align-items:center;gap:8px;padding:6px 10px;border-radius:5px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}",
".dpt__mi:hover{background:var(--dsw-alias-interactive-bg-hover)}",
".dpt__mi small{color:var(--dsw-alias-label-tertiary);margin-left:auto;padding-left:12px}",
".dpt__sep{height:1px;margin:4px 6px;background:var(--dsw-alias-border-l2,#444)}"
].join("");
function injectStyle() {
if (document.getElementById(STYLE_ID) !== null) return;
const style = document.createElement("style");
style.id = STYLE_ID;
style.dataset.plugin = name;
style.textContent = STYLE_TEXT;
document.head.appendChild(style);
}
function svg(paths, size) {
return e("svg", { width: size || 15, height: size || 15, viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: 1.4, strokeLinecap: "round", strokeLinejoin: "round" }, paths);
}
const ICON = {
chevron: (open) => e("span", { className: "dpt__twist", style: { transform: open ? "rotate(90deg)" : "none" } }, svg([e("path", { key: "p", d: "M6 4l4 4-4 4" })], 12)),
project: () => svg([e("path", { key: "a", d: "M2 4.2l6-2.2 6 2.2v7.6l-6 2.2-6-2.2z" }), e("path", { key: "b", d: "M2 4.2l6 2.3 6-2.3M8 6.5v7.5" })]),
group: () => svg([e("path", { key: "a", d: "M2 5.2a1 1 0 011-1h3l1.4 1.4H13a1 1 0 011 1v5.2a1 1 0 01-1 1H3a1 1 0 01-1-1z" })]),
task: () => svg([e("path", { key: "a", d: "M3 4h10M3 8h10M3 12h7" })]),
plus: () => svg([e("path", { key: "a", d: "M8 3.5v9M3.5 8h9" }), ], 13),
edit: () => svg([e("path", { key: "a", d: "M9.5 3.5l3 3L6 13l-3.2.7L3.5 10z" })], 13),
trash: () => svg([e("path", { key: "a", d: "M3 4.5h10M6 4.5V3h4v1.5M4.5 4.5l.6 8.5h5.8l.6-8.5" })], 13),
newchat: () => svg([e("path", { key: "a", d: "M13.5 8.5v3a1.5 1.5 0 01-1.5 1.5H5l-2.5 2V5A1.5 1.5 0 014 3.5h4" }), e("path", { key: "b", d: "M11 2.5v4M9 4.5h4" })], 14),
dir: () => svg([e("path", { key: "a", d: "M2 4.2a1 1 0 011-1h3l1.4 1.4H14v7a1 1 0 01-1 1H3a1 1 0 01-1-1z" })], 13),
work: (on) => svg([
e("circle", { key: "c", cx: 8, cy: 8, r: 5.2, fill: on ? "currentColor" : "none" }),
on ? e("path", { key: "k", d: "M5.7 8.1l1.6 1.6 3-3.4", stroke: "var(--dsw-alias-surface-overlay,#232323)" }) : null
], 14)
};
const TRANSPORT_ERROR = { code: "route-unavailable", message: "project-tree route unavailable" };
async function api(path, body) {
let response;
try {
response = await fetch(ROUTE_PREFIX + "/" + path, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body ?? {}) });
} catch { return { ok: false, error: TRANSPORT_ERROR }; }
let payload;
try { payload = await response.json(); } catch { return { ok: false, error: TRANSPORT_ERROR }; }
if (response.ok) return { ok: true, value: payload };
return { ok: false, error: payload?.error ?? TRANSPORT_ERROR };
}
function createViewStore() {
return runtimeClient.defineStore({
init: () => ({ collapsed: {} }),
persist: VIEW_PERSIST_KEY,
actions: { setCollapsed: (draft, key, collapsed) => { draft.collapsed[key] = collapsed; } }
});
}
function sessionVisible(summary, archived) {
return summary !== undefined && summary.origin !== "subagent" && !archived.has(summary.id) && !summary.blank;
}
function sessionState(summary) {
if (summary.running) return "running";
if (summary.pendingInteraction) return "waiting";
if (summary.completed) return "completed";
return "idle";
}
function byOrder(a, b) {
const ai = a.sortIndex ?? Number.MAX_SAFE_INTEGER;
const bi = b.sortIndex ?? Number.MAX_SAFE_INTEGER;
if (ai !== bi) return ai - bi;
return String(a.name).localeCompare(String(b.name));
}
function baseName(path) {
if (typeof path !== "string" || path.length === 0) return "";
const parts = path.replace(/[/\\]+$/, "").split(/[/\\]/);
return parts[parts.length - 1] || path;
}
function insertBefore(ids, movedId, beforeId) {
const without = ids.filter((id) => id !== movedId);
if (beforeId === null || beforeId === undefined) return [...without, movedId];
const at = without.indexOf(beforeId);
if (at < 0) return [...without, movedId];
return [...without.slice(0, at), movedId, ...without.slice(at)];
}
function endIndex(items) { return items.reduce((m, x) => Math.max(m, x.sortIndex ?? 0), 0) + 1; }
function applyOrder(items, orderedIds) { const idx = new Map(orderedIds.map((id, i) => [id, i])); for (const it of items) if (idx.has(it.id)) it.sortIndex = idx.get(it.id); }
const mut = {
assign: (sessionId, taskId) => (t) => {
for (const task of t.tasks) task.sessionIds = task.sessionIds.filter((s) => s !== sessionId);
if (taskId) { const task = t.tasks.find((x) => x.id === taskId); if (task) task.sessionIds = [sessionId, ...task.sessionIds]; }
return t;
},
taskMove: (taskId, groupId) => (t) => { const task = t.tasks.find((x) => x.id === taskId); if (task) { task.groupId = groupId; task.sortIndex = endIndex(t.tasks.filter((x) => x.groupId === groupId && x.id !== taskId)); } return t; },
tasksReorder: (groupId, orderedIds) => (t) => { applyOrder(t.tasks.filter((x) => x.groupId === groupId), orderedIds); return t; },
groupMove: (groupId, projectId) => (t) => { const group = t.groups.find((x) => x.id === groupId); if (group) { group.projectId = projectId; group.sortIndex = endIndex(t.groups.filter((x) => x.projectId === projectId && x.id !== groupId)); } return t; },
groupsReorder: (projectId, orderedIds) => (t) => { applyOrder(t.groups.filter((x) => x.projectId === projectId), orderedIds); return t; },
projectsReorder: (orderedIds) => (t) => { applyOrder(t.projects, orderedIds); return t; },
renameProject: (id, name) => (t) => { const p = t.projects.find((x) => x.id === id); if (p) p.name = name; return t; },
renameGroup: (id, name) => (t) => { const g = t.groups.find((x) => x.id === id); if (g) g.name = name; return t; },
renameTask: (id, name) => (t) => { const tk = t.tasks.find((x) => x.id === id); if (tk) tk.name = name; return t; },
deleteProject: (id) => (t) => { const groups = new Set(t.groups.filter((g) => g.projectId === id).map((g) => g.id)); t.projects = t.projects.filter((p) => p.id !== id); t.groups = t.groups.filter((g) => g.projectId !== id); t.tasks = t.tasks.filter((tk) => !groups.has(tk.groupId)); return t; },
deleteGroup: (id) => (t) => { t.groups = t.groups.filter((g) => g.id !== id); t.tasks = t.tasks.filter((tk) => tk.groupId !== id); return t; },
deleteTask: (id) => (t) => { t.tasks = t.tasks.filter((tk) => tk.id !== id); return t; },
inWork: (id, value) => (t) => { const has = (t.inWork || []).includes(id); t.inWork = value ? (has ? t.inWork : [...(t.inWork || []), id]) : (t.inWork || []).filter((x) => x !== id); return t; }
};
let DRAG = null;
function ProjectTreeBrowser(props) {
injectStyle();
const { useSessions, useWorkspaces, useStore, actions } = props;
const A = props;
const list = useSessions((state) => state);
const wsItems = useWorkspaces((state) => state.items);
const archivedSessionIds = useWorkspaces((state) => state.archivedSessionIds);
const collapsed = useStore((state) => state.collapsed);
const [tree, setTree] = react.useState(null);
const [menu, setMenu] = react.useState(null);
const [notice, setNotice] = react.useState(null);
const pendingAssign = react.useRef(null);
const opSeq = react.useRef(0);
const archived = react.useMemo(() => new Set(archivedSessionIds ?? []), [archivedSessionIds]);
const flash = react.useCallback((error) => {
const code = typeof error === "string" ? error : error?.code ?? error?.message ?? "error";
setNotice(String(code));
window.setTimeout(() => setNotice(null), 3500);
}, []);
// Guarded reload: drop the result if a newer mutation started while we
// were fetching, so a background poll never clobbers an optimistic edit.
const reload = react.useCallback(async () => {
const seq = opSeq.current;
const result = await A.listTree();
if (result.ok && opSeq.current === seq) setTree(result.value);
}, []);
react.useEffect(() => {
let active = true;
const tick = () => { if (active && document.visibilityState !== "hidden") void reload(); };
tick();
const timer = window.setInterval(tick, RELOAD_MS);
return () => { active = false; window.clearInterval(timer); };
}, [reload]);
// Optimistic mutation: patch local tree immediately, fire the request(s),
// then reconcile from the server (only if no newer op superseded us).
const commit = react.useCallback((mutator, apiCall) => {
const mySeq = ++opSeq.current;
if (mutator) setTree((prev) => prev ? mutator(JSON.parse(JSON.stringify(prev))) : prev);
Promise.resolve(apiCall()).then(async (res) => {
if (res && res.ok === false) flash(res.error);
const result = await A.listTree();
if (result.ok && opSeq.current === mySeq) setTree(result.value);
});
}, [flash]);
// Awaiting variant for flows that need the server result (create → id).
const run = react.useCallback(async (result) => {
opSeq.current++;
if (result && result.ok === false) { flash(result.error); return null; }
await reload();
return result ? result.value : true;
}, [reload, flash]);
const isCollapsed = (key) => collapsed[key] !== false;
const toggle = (key) => actions.setCollapsed(key, !isCollapsed(key));
const closeMenu = react.useCallback(() => setMenu(null), []);
react.useEffect(() => {
if (menu === null) return undefined;
const onDown = () => closeMenu();
window.addEventListener("mousedown", onDown);
window.addEventListener("resize", onDown);
return () => { window.removeEventListener("mousedown", onDown); window.removeEventListener("resize", onDown); };
}, [menu, closeMenu]);
const openMenu = (event, items) => {
event.preventDefault();
event.stopPropagation();
setMenu({ x: event.clientX, y: event.clientY, items: items.filter(Boolean) });
};
const sessionWorkspace = react.useMemo(() => {
const map = new Map();
for (const workspace of wsItems ?? []) for (const id of workspace.sessionIds) map.set(id, workspace);
return map;
}, [wsItems]);
const view = react.useMemo(() => {
const projects = (tree?.projects ?? []).slice().sort(byOrder);
const groups = tree?.groups ?? [];
const tasks = tree?.tasks ?? [];
const visible = (summary) => sessionVisible(summary, archived);
const groupsByProject = new Map();
for (const group of groups) {
const bucket = groupsByProject.get(group.projectId) ?? [];
bucket.push(group); groupsByProject.set(group.projectId, bucket);
}
const tasksByGroup = new Map();
for (const task of tasks) {
const bucket = tasksByGroup.get(task.groupId) ?? [];
bucket.push(task); tasksByGroup.set(task.groupId, bucket);
}
const claimed = new Set();
const renderedProjects = projects.map((project) => ({
id: project.id, name: project.name,
groups: (groupsByProject.get(project.id) ?? []).slice().sort(byOrder).map((group) => ({
id: group.id, name: group.name, projectId: project.id,
tasks: (tasksByGroup.get(group.id) ?? []).slice().sort(byOrder).map((task) => {
const sessions = task.sessionIds.map((id) => list.byId[id]).filter(visible);
for (const s of sessions) claimed.add(s.id);
return { id: task.id, name: task.name, groupId: group.id, sessionIds: task.sessionIds, sessions };
})
}))
}));
const unsorted = Object.values(list.byId).filter(visible).filter((s) => !claimed.has(s.id))
.sort((a, b) => String(a.displayTitle).localeCompare(String(b.displayTitle)));
return { projects: renderedProjects, unsorted, workspaces: tree?.workspaces ?? [] };
}, [tree, list, archived]);
react.useEffect(() => {
const p = pendingAssign.current;
if (p === null) return;
const now = new Set((wsItems ?? []).flatMap((w) => w.sessionIds));
const fresh = [...now].find((id) => !p.known.has(id) && (sessionWorkspace.get(id)?.workspaceId ?? sessionWorkspace.get(id)?.id) === p.workspaceId);
if (fresh !== undefined) {
pendingAssign.current = null;
void run(api("session-assign", { sessionId: fresh, taskId: p.taskId }));
}
}, [wsItems, sessionWorkspace, run]);
const groupTaskIds = (groupId) => view.projects.flatMap((p) => p.groups).filter((g) => g.id === groupId).flatMap((g) => g.tasks.map((t) => t.id));
const projectGroupIds = (projectId) => view.projects.filter((p) => p.id === projectId).flatMap((p) => p.groups.map((g) => g.id));
const projectIds = () => view.projects.map((p) => p.id);
const onDrop = (target) => {
const drag = DRAG; DRAG = null;
if (drag === null || (drag.type === target.type && drag.id === target.id)) return;
if (drag.type === "session") {
if (target.type === "task") return commit(mut.assign(drag.id, target.id), () => api("session-assign", { sessionId: drag.id, taskId: target.id }));
if (target.type === "unsorted") return commit(mut.assign(drag.id, null), () => api("session-assign", { sessionId: drag.id, taskId: null }));
return;
}
if (drag.type === "task") {
if (target.type === "group") return commit(mut.taskMove(drag.id, target.id), () => api("task-move", { taskId: drag.id, groupId: target.id }));
if (target.type === "task") {
const ordered = insertBefore(groupTaskIds(target.groupId).filter((id) => id !== drag.id), drag.id, target.id);
if (target.groupId !== drag.groupId) {
return commit((t) => mut.tasksReorder(target.groupId, ordered)(mut.taskMove(drag.id, target.groupId)(t)),
async () => { const r = await api("task-move", { taskId: drag.id, groupId: target.groupId }); return r.ok === false ? r : api("tasks-reorder", { groupId: target.groupId, orderedIds: ordered }); });
}
return commit(mut.tasksReorder(target.groupId, ordered), () => api("tasks-reorder", { groupId: target.groupId, orderedIds: ordered }));
}
return;
}
if (drag.type === "group") {
if (target.type === "project") return commit(mut.groupMove(drag.id, target.id), () => api("group-move", { groupId: drag.id, projectId: target.id }));
if (target.type === "group") {
const ordered = insertBefore(projectGroupIds(target.projectId).filter((id) => id !== drag.id), drag.id, target.id);
if (target.projectId !== drag.projectId) {
return commit((t) => mut.groupsReorder(target.projectId, ordered)(mut.groupMove(drag.id, target.projectId)(t)),
async () => { const r = await api("group-move", { groupId: drag.id, projectId: target.projectId }); return r.ok === false ? r : api("groups-reorder", { projectId: target.projectId, orderedIds: ordered }); });
}
return commit(mut.groupsReorder(target.projectId, ordered), () => api("groups-reorder", { projectId: target.projectId, orderedIds: ordered }));
}
return;
}
if (drag.type === "project" && target.type === "project") {
const ordered = insertBefore(projectIds(), drag.id, target.id);
return commit(mut.projectsReorder(ordered), () => api("projects-reorder", { orderedIds: ordered }));
}
};
const canDrop = (drag, node) =>
(drag.type === "session" && (node.type === "task" || node.type === "unsorted")) ||
(drag.type === "task" && (node.type === "group" || node.type === "task")) ||
(drag.type === "group" && (node.type === "project" || node.type === "group")) ||
(drag.type === "project" && node.type === "project");
const dropAttrs = (node) => ({
onDragOver: (ev) => {
if (DRAG === null || !canDrop(DRAG, node)) return;
if (DRAG.type === node.type && DRAG.id === node.id) return;
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
ev.currentTarget.dataset.drop = DRAG.type === node.type ? "before" : "into";
},
onDragLeave: (ev) => { delete ev.currentTarget.dataset.drop; },
onDrop: (ev) => { ev.preventDefault(); ev.stopPropagation(); delete ev.currentTarget.dataset.drop; void onDrop(node); }
});
const dragAttrs = (item) => ({
draggable: true,
onDragStart: (ev) => { DRAG = item; ev.dataTransfer.effectAllowed = "move"; try { ev.dataTransfer.setData("text/plain", item.id); } catch {} },
onDragEnd: () => { DRAG = null; }
});
const promptName = (label, current) => {
const v = window.prompt(label, current ?? "");
if (v === null) return null;
const t = v.trim();
return t.length === 0 ? null : t;
};
const newSessionInTask = (taskId) => {
const workspaces = view.workspaces ?? [];
if (workspaces.length === 0) return void flash("no-workspaces");
openMenuAt(lastMenuEvent.current, workspaces.map((w) => ({
icon: ICON.dir(), label: w.title, hint: baseName(w.path),
run: () => {
const known = new Set((wsItems ?? []).flatMap((x) => x.sessionIds));
pendingAssign.current = { taskId, workspaceId: w.id, known };
A.startSession(w.id);
flash("starting-session…");
}
})));
};
const lastMenuEvent = react.useRef(null);
const openMenuAt = (ev, items) => { if (ev) setMenu({ x: ev.clientX, y: ev.clientY, items: items.filter(Boolean) }); };
const actBtn = (icon, title, onClick) => e("span", { className: "dpt__act", title, onClick: (ev) => { ev.stopPropagation(); onClick(ev); } }, icon);
const inWorkSet = new Set(tree?.inWork ?? []);
const workToggle = (id) => e("span", {
key: "work",
className: "dpt__work",
"data-on": inWorkSet.has(id),
title: inWorkSet.has(id) ? "In work — click to stop" : "Mark as in work",
onClick: (ev) => { ev.stopPropagation(); const value = !inWorkSet.has(id); commit(mut.inWork(id, value), () => api("in-work", { id, value })); }
}, ICON.work(inWorkSet.has(id)));
const row = (opts) => {
const [twist, ...body] = opts.children;
return e("div", Object.assign({
className: "dpt__row",
"data-type": opts.dragType,
"data-id": opts.id,
"data-inwork": opts.inWork === true,
"data-active": opts.active === true,
"data-dragging": DRAG !== null && DRAG.type === opts.dragType && DRAG.id === opts.id,
onClick: opts.onClick,
onContextMenu: opts.onContextMenu
}, opts.drag ? dragAttrs({ type: opts.dragType, id: opts.id, groupId: opts.groupId, projectId: opts.projectId }) : {}, opts.drop || {}), [
twist,
e("div", { key: "body", className: "dpt__body" }, body)
]);
};
const sessionRow = (summary) => {
const workspace = sessionWorkspace.get(summary.id);
return row({
id: summary.id, dragType: "session", drag: true, inWork: inWorkSet.has(summary.id), active: list.current === summary.id,
onClick: () => A.openSession(summary.id),
onContextMenu: (ev) => { lastMenuEvent.current = ev; openMenu(ev, sessionMenuItems(summary.id)); },
children: [
e("span", { key: "t", className: "dpt__twist" }),
e("span", { key: "d", className: "dpt__ico", "data-kind": "session" }, e("span", { className: "dpt__dot", "data-state": sessionState(summary) })),
e("span", { key: "l", className: "dpt__label", title: summary.displayTitle }, summary.displayTitle || "Untitled"),
workspace && e("span", { key: "w", className: "dpt__sub", title: workspace.path }, baseName(workspace.path)),
workToggle(summary.id)
]
});
};
const taskNode = (task) => {
const key = "t:" + task.id;
const open = !isCollapsed(key);
return e(react.Fragment, { key: task.id }, [
row({
id: task.id, groupId: task.groupId, dragType: "task", drag: true,
drop: dropAttrs({ type: "task", id: task.id, groupId: task.groupId }),
onClick: () => toggle(key),
onContextMenu: (ev) => { lastMenuEvent.current = ev; openMenu(ev, [
{ icon: ICON.newchat(), label: "New session here…", run: () => newSessionInTask(task.id) },
{ icon: ICON.edit(), label: "Rename task", run: () => { const n = promptName("Rename task", task.name); if (n) commit(mut.renameTask(task.id, n), () => api("task-rename", { taskId: task.id, name: n })); } },
{ icon: ICON.trash(), label: "Delete task", run: () => { if (window.confirm(`Delete task "${task.name}"? Its sessions return to Unsorted.`)) commit(mut.deleteTask(task.id), () => api("task-delete", { taskId: task.id })); } }
]); },
children: [
ICON.chevron(open),
e("span", { key: "i", className: "dpt__ico" }, ICON.task()),
e("span", { key: "l", className: "dpt__label" }, task.name),
e("span", { key: "a", className: "dpt__actions" }, [
actBtn(ICON.newchat(), "New session here", () => newSessionInTask(task.id))
])
]
}),
open && e("div", { key: "c", className: "dpt__children", ...dropAttrs({ type: "task", id: task.id, groupId: task.groupId }) }, task.sessions.map((s) => sessionRow(s)))
]);
};
const groupNode = (group) => {
const key = "g:" + group.id;
const open = !isCollapsed(key);
return e(react.Fragment, { key: group.id }, [
row({
id: group.id, projectId: group.projectId, dragType: "group", drag: true,
drop: dropAttrs({ type: "group", id: group.id, projectId: group.projectId }),
onClick: () => toggle(key),
onContextMenu: (ev) => { lastMenuEvent.current = ev; openMenu(ev, [
{ icon: ICON.plus(), label: "New task…", run: async () => { const n = promptName("New task name"); if (n) await run(api("task-create", { groupId: group.id, name: n })); } },
{ icon: ICON.edit(), label: "Rename group", run: () => { const n = promptName("Rename group", group.name); if (n) commit(mut.renameGroup(group.id, n), () => api("group-rename", { groupId: group.id, name: n })); } },
{ icon: ICON.trash(), label: "Delete group", run: () => { if (window.confirm(`Delete group "${group.name}"? Its tasks are removed; sessions return to Unsorted.`)) commit(mut.deleteGroup(group.id), () => api("group-delete", { groupId: group.id })); } }
]); },
children: [
ICON.chevron(open),
e("span", { key: "i", className: "dpt__ico" }, ICON.group()),
e("span", { key: "l", className: "dpt__label" }, group.name),
e("span", { key: "a", className: "dpt__actions" }, [
actBtn(ICON.plus(), "New task", async () => { const n = promptName("New task name"); if (n) await run(api("task-create", { groupId: group.id, name: n })); })
])
]
}),
open && e("div", { key: "c", className: "dpt__children", ...dropAttrs({ type: "group", id: group.id, projectId: group.projectId }) }, group.tasks.map((t) => taskNode(t)))
]);
};
const projectNode = (project) => {
const key = "p:" + project.id;
const open = !isCollapsed(key);
return e(react.Fragment, { key: project.id }, [
row({
id: project.id, dragType: "project", drag: true,
drop: dropAttrs({ type: "project", id: project.id }),
onClick: () => toggle(key),
onContextMenu: (ev) => { lastMenuEvent.current = ev; openMenu(ev, [
{ icon: ICON.plus(), label: "New group…", run: async () => { const n = promptName("New group name"); if (n) await run(api("group-create", { projectId: project.id, name: n })); } },
{ icon: ICON.edit(), label: "Rename project", run: () => { const n = promptName("Rename project", project.name); if (n) commit(mut.renameProject(project.id, n), () => api("project-rename", { projectId: project.id, name: n })); } },
{ icon: ICON.trash(), label: "Delete project", run: () => { if (window.confirm(`Delete project "${project.name}"? Its groups and tasks are removed; sessions return to Unsorted.`)) commit(mut.deleteProject(project.id), () => api("project-delete", { projectId: project.id })); } }
]); },
children: [
ICON.chevron(open),
e("span", { key: "i", className: "dpt__ico" }, ICON.project()),
e("span", { key: "l", className: "dpt__label" }, project.name),
e("span", { key: "a", className: "dpt__actions" }, [
actBtn(ICON.plus(), "New group", async () => { const n = promptName("New group name"); if (n) await run(api("group-create", { projectId: project.id, name: n })); })
])
]
}),
open && e("div", { key: "c", className: "dpt__children", ...dropAttrs({ type: "project", id: project.id }) }, project.groups.map((g) => groupNode(g)))
]);
};
const renameSession = (sessionId) => {
const current = list.byId[sessionId]?.displayTitle ?? "";
const n = promptName("Rename session", current);
if (n && n !== current) A.renameSession(sessionId, n).then((r) => { if (r && r.ok === false) flash(r.error); });
};
const sessionMenuItems = (sessionId) => {
const items = [{ icon: ICON.edit(), label: "Rename session", run: () => renameSession(sessionId) }, { separator: true }];
for (const project of view.projects) for (const group of project.groups) for (const task of group.tasks) {
items.push({ icon: ICON.task(), label: task.name, hint: project.name + " / " + group.name, run: () => commit(mut.assign(sessionId, task.id), () => api("session-assign", { sessionId, taskId: task.id })) });
}
if (items.length > 0) items.push({ separator: true });
items.push({ label: "Remove from task (Unsorted)", run: () => commit(mut.assign(sessionId, null), () => api("session-assign", { sessionId, taskId: null })) });
return items;
};
const unsortedKey = "unsorted";
const unsortedOpen = !isCollapsed(unsortedKey);
const menuNode = menu === null ? null : e("div", { className: "dpt__menu", style: { left: Math.min(menu.x, window.innerWidth - 210), top: Math.min(menu.y, window.innerHeight - 40) }, onMouseDown: (ev) => ev.stopPropagation() },
menu.items.map((item, index) => item.separator
? e("div", { key: "s" + index, className: "dpt__sep" })
: e("div", { key: index, className: "dpt__mi", onClick: () => { closeMenu(); void item.run(); } }, [
item.icon ? e("span", { key: "i", className: "dpt__ico" }, item.icon) : null,
e("span", { key: "l", style: { overflow: "hidden", textOverflow: "ellipsis" } }, item.label),
item.hint ? e("small", { key: "h" }, item.hint) : null
])
));
const workItems = [];
for (const id of (tree?.inWork ?? [])) {
const s = list.byId[id];
if (s && sessionVisible(s, archived)) {
workItems.push({ id, name: s.displayTitle || "Untitled", icon: e("span", { className: "dpt__dot", "data-state": sessionState(s) }) });
}
}
const workSection = workItems.length === 0 ? null : e(react.Fragment, { key: "work" }, [
e("div", { key: "wb", className: "dpt__bar" }, [e("b", { key: "t" }, "In work")]),
workItems.map((it) => e("div", { key: it.id, className: "dpt__row", "data-active": it.id === list.current, onClick: () => A.openSession(it.id), onContextMenu: (ev) => { lastMenuEvent.current = ev; openMenu(ev, sessionMenuItems(it.id)); } }, [
e("span", { key: "t", className: "dpt__twist" }),
e("div", { key: "body", className: "dpt__body" }, [
e("span", { key: "i", className: "dpt__ico", "data-kind": "session" }, it.icon),
e("span", { key: "l", className: "dpt__label", title: it.name }, it.name),
workToggle(it.id)
])
]))
]);
return e("div", { className: "dpt" }, [
notice && e("div", { key: "notice", className: "dpt__notice" }, notice),
workSection,
e("div", { key: "bar", className: "dpt__bar" }, [
e("b", { key: "t" }, "Projects"),
e("span", { key: "add", className: "dpt__iconbtn", title: "New project", onClick: async () => { const n = promptName("New project name"); if (n) await run(api("project-create", { name: n })); } }, ICON.plus())
]),
tree === null
? e("div", { key: "hint", className: "dpt__hint" }, "Loading…")
: e(react.Fragment, { key: "tree" }, [
view.projects.map((p) => projectNode(p)),
e(react.Fragment, { key: "uns" }, [
row({
id: unsortedKey, dragType: "_", drag: false,
drop: dropAttrs({ type: "unsorted" }),
onClick: () => toggle(unsortedKey),
children: [
ICON.chevron(unsortedOpen),
e("span", { key: "i", className: "dpt__ico" }, ICON.group()),
e("span", { key: "l", className: "dpt__label", style: { color: "var(--dsw-alias-label-tertiary)" } }, "Unsorted")
]
}),
unsortedOpen && e("div", { key: "c", className: "dpt__children", ...dropAttrs({ type: "unsorted" }) }, view.unsorted.map((s) => sessionRow(s)))
])
]),
menuNode
]);
}
function apply(ctx) {
const injected = {
openSession: (sessionId) => { ctx.sessions.open(sessionId); },
startSession: (workspaceId) => ctx.workspaces.startSession(workspaceId),
renameSession: async (sessionId, title) => {
const session = ctx.sessions.binding(sessionId)?.session;
if (session === undefined) return { ok: false, error: { code: "unknown-session" } };
const result = await session.rename(title);
return result?.ok === true ? { ok: true } : { ok: false, error: result?.error ?? { code: "rename-failed" } };
},
listTree: () => api("list", {})
};
ctx.slots.inject("sidebar.workspaces", () => ctx.slots.register({
name: "sidebar.workspaces",
priority: -1,
store: createViewStore(),
children: {},
inject: () => injected
}, ProjectTreeBrowser));
}
module.exports = { name, inject, apply };
return module.exports;
}
});

View file

@ -0,0 +1,406 @@
import { defineDomain } from "@deepseek-ai/dsh-storage-domain";
import { z } from "zod";
import { randomUUID } from "node:crypto";
import { isExactIdSet, nextSortIndex, parseName } from "./tree-utils.js";
const name = "dsh-project-tree";
const inject = ["webServer", "storageDomain", "workspaceRegistry"];
const ROUTE_PREFIX = "/dsh-project-tree";
const MAX_BODY_BYTES = 65536;
const SESSION_ID_RE = /^(session-)?[0-9a-fA-F-]+$/;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const projectSchema = z.object({
id: z.string(),
name: z.string(),
sortIndex: z.number().int().min(0).optional()
});
const groupSchema = z.object({
id: z.string(),
projectId: z.string().optional(),
workspaceId: z.string().optional(),
name: z.string(),
sortIndex: z.number().int().min(0).optional()
});
const taskSchema = z.object({
id: z.string(),
groupId: z.string(),
name: z.string(),
sessionIds: z.array(z.string()),
sortIndex: z.number().int().min(0).optional()
});
const treeDomainSpec = defineDomain({
name: "dsh_project_tree",
version: 1,
global: {
schema: z.object({
projects: z.array(projectSchema).optional(),
groups: z.array(groupSchema),
tasks: z.array(taskSchema),
inWork: z.array(z.string()).optional()
}),
initial: { projects: [], groups: [], tasks: [], inWork: [] }
},
tables: {}
});
function readJsonBody(req) {
return new Promise((resolve, reject) => {
let data = "";
req.on("data", (chunk) => {
data += chunk;
if (data.length > MAX_BODY_BYTES) {
req.destroy();
const err = new Error("request body too large");
err.clientError = true;
reject(err);
}
});
req.on("end", () => {
if (data.length === 0) return resolve({});
try {
resolve(JSON.parse(data));
} catch {
const err = new Error("invalid JSON body");
err.clientError = true;
reject(err);
}
});
req.on("error", reject);
});
}
function respond(res, status, payload) {
const body = JSON.stringify(payload);
res.writeHead(status, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(body)
});
res.end(body);
}
function normalize(record) {
return {
projects: Array.isArray(record?.projects) ? record.projects : [],
groups: Array.isArray(record?.groups) ? record.groups : [],
tasks: Array.isArray(record?.tasks) ? record.tasks : [],
inWork: Array.isArray(record?.inWork) ? record.inWork : []
};
}
function migrate(ctx, record) {
const next = normalize(record);
const workspaceToProject = new Map();
let changed = !Array.isArray(record?.projects);
for (const group of next.groups) {
if (typeof group.projectId === "string" && group.projectId.length > 0) continue;
const workspaceId = group.workspaceId ?? "__legacy__";
let projectId = workspaceToProject.get(workspaceId);
if (projectId === undefined) {
const workspace = ctx.workspaceRegistry.list().find((candidate) => candidate.id === workspaceId);
projectId = randomUUID();
next.projects.push({ id: projectId, name: workspace?.title ?? "Imported", sortIndex: nextSortIndex(next.projects) });
workspaceToProject.set(workspaceId, projectId);
}
group.projectId = projectId;
delete group.workspaceId;
changed = true;
}
return { record: next, changed };
}
function apply(ctx) {
return ctx.storageDomain.open(treeDomainSpec).then(async (treeDomain) => {
const migrated = migrate(ctx, treeDomain.global.get());
if (migrated.changed) await treeDomain.global.set(migrated.record);
const readRecord = () => normalize(treeDomain.global.get());
const writeRecord = (record) => treeDomain.global.set(normalize(record));
let mutationTail = Promise.resolve();
const withMutationLock = (operation) => {
const result = mutationTail.then(operation, operation);
mutationTail = result.then(() => void 0, () => void 0);
return result;
};
const ws = ctx.webServer;
const route = (path, handler) => {
ws.register({
kind: "exact",
path: ROUTE_PREFIX + "/" + path,
handler: async (req, res) => {
if (req.method !== "POST") return respond(res, 405, { error: "method-not-allowed" });
const contentType = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase();
if (contentType !== "application/json") return respond(res, 400, { error: "bad-request" });
if (req.headers["sec-fetch-site"] === "cross-site") return respond(res, 403, { error: "cross-origin-denied" });
const origin = req.headers["origin"];
if (typeof origin === "string" && origin.length > 0) {
try {
if (new URL(origin).host !== req.headers.host) return respond(res, 403, { error: "cross-origin-denied" });
} catch {
return respond(res, 403, { error: "cross-origin-denied" });
}
}
try {
const body = await readJsonBody(req);
await handler(body, res);
} catch (error) {
if (error.clientError === true) return respond(res, 400, { error: "bad-request" });
ctx.logger.warn("[dsh-project-tree] route failed:", error);
respond(res, 500, { error: "internal-error" });
}
}
});
};
route("list", async (_body, res) => {
const record = readRecord();
const aliveProjects = new Set(record.projects.map((project) => project.id));
const groups = record.groups.filter((group) => aliveProjects.has(group.projectId));
const aliveGroups = new Set(groups.map((group) => group.id));
const tasks = record.tasks.filter((task) => aliveGroups.has(task.groupId));
const workspaces = ctx.workspaceRegistry.list().map((workspace) => ({
id: workspace.id,
title: workspace.title,
path: workspace.path
}));
respond(res, 200, { projects: record.projects, groups, tasks, workspaces, inWork: record.inWork });
});
route("in-work", async (body, res) => {
const id = body?.id;
const value = body?.value;
if (typeof id !== "string" || !SESSION_ID_RE.test(id) || typeof value !== "boolean") return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
const has = record.inWork.includes(id);
if (value === has) return respond(res, 200, { ok: true });
const inWork = value ? [...record.inWork, id] : record.inWork.filter((candidate) => candidate !== id);
await writeRecord({ ...record, inWork });
respond(res, 200, { ok: true });
});
});
route("project-create", async (body, res) => {
const projectName = parseName(body?.name);
if (projectName === undefined) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
let id;
do { id = randomUUID(); } while (record.projects.some((project) => project.id === id));
await writeRecord({ ...record, projects: [...record.projects, { id, name: projectName, sortIndex: nextSortIndex(record.projects) }] });
respond(res, 200, { id });
});
});
route("project-rename", async (body, res) => {
const projectId = body?.projectId;
const projectName = parseName(body?.name);
if (typeof projectId !== "string" || projectId.length === 0 || projectName === undefined) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.projects.some((project) => project.id === projectId)) return respond(res, 404, { error: "project-not-found" });
await writeRecord({ ...record, projects: record.projects.map((project) => project.id === projectId ? { ...project, name: projectName } : project) });
respond(res, 200, { ok: true });
});
});
route("project-delete", async (body, res) => {
const projectId = body?.projectId;
if (typeof projectId !== "string" || projectId.length === 0) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
const doomedGroups = new Set(record.groups.filter((group) => group.projectId === projectId).map((group) => group.id));
const doomedTasks = new Set(record.tasks.filter((task) => doomedGroups.has(task.groupId)).map((task) => task.id));
const doomed = new Set([projectId, ...doomedGroups, ...doomedTasks]);
await writeRecord({
projects: record.projects.filter((project) => project.id !== projectId),
groups: record.groups.filter((group) => group.projectId !== projectId),
tasks: record.tasks.filter((task) => !doomedGroups.has(task.groupId)),
inWork: record.inWork.filter((id) => !doomed.has(id))
});
respond(res, 200, { ok: true });
});
});
route("projects-reorder", async (body, res) => {
const orderedIds = body?.orderedIds;
if (!Array.isArray(orderedIds) || !orderedIds.every((id) => typeof id === "string" && id.length > 0)) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!isExactIdSet(orderedIds, new Set(record.projects.map((project) => project.id)))) return respond(res, 400, { error: "bad-request" });
const indexOf = new Map(orderedIds.map((id, index) => [id, index]));
await writeRecord({ ...record, projects: record.projects.map((project) => ({ ...project, sortIndex: indexOf.get(project.id) })) });
respond(res, 200, { ok: true });
});
});
route("group-create", async (body, res) => {
const projectId = body?.projectId;
const groupName = parseName(body?.name);
if (typeof projectId !== "string" || projectId.length === 0 || groupName === undefined) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.projects.some((project) => project.id === projectId)) return respond(res, 404, { error: "project-not-found" });
const siblings = record.groups.filter((group) => group.projectId === projectId);
let id;
do { id = randomUUID(); } while (record.groups.some((group) => group.id === id));
await writeRecord({ ...record, groups: [...record.groups, { id, projectId, name: groupName, sortIndex: nextSortIndex(siblings) }] });
respond(res, 200, { id });
});
});
route("group-rename", async (body, res) => {
const groupId = body?.groupId;
const groupName = parseName(body?.name);
if (typeof groupId !== "string" || groupId.length === 0 || groupName === undefined) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" });
await writeRecord({ ...record, groups: record.groups.map((group) => group.id === groupId ? { ...group, name: groupName } : group) });
respond(res, 200, { ok: true });
});
});
route("group-delete", async (body, res) => {
const groupId = body?.groupId;
if (typeof groupId !== "string" || groupId.length === 0) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" });
const doomedTasks = new Set(record.tasks.filter((task) => task.groupId === groupId).map((task) => task.id));
const doomed = new Set([groupId, ...doomedTasks]);
await writeRecord({
...record,
groups: record.groups.filter((group) => group.id !== groupId),
tasks: record.tasks.filter((task) => task.groupId !== groupId),
inWork: record.inWork.filter((id) => !doomed.has(id))
});
respond(res, 200, { ok: true });
});
});
route("group-move", async (body, res) => {
const groupId = body?.groupId;
const projectId = body?.projectId;
if (typeof groupId !== "string" || typeof projectId !== "string" || groupId.length === 0 || projectId.length === 0) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" });
if (!record.projects.some((project) => project.id === projectId)) return respond(res, 404, { error: "project-not-found" });
const targetSiblings = record.groups.filter((group) => group.projectId === projectId && group.id !== groupId);
await writeRecord({ ...record, groups: record.groups.map((group) => group.id === groupId ? { ...group, projectId, sortIndex: nextSortIndex(targetSiblings) } : group) });
respond(res, 200, { ok: true });
});
});
route("groups-reorder", async (body, res) => {
const projectId = body?.projectId;
const orderedIds = body?.orderedIds;
if (typeof projectId !== "string" || projectId.length === 0 || !Array.isArray(orderedIds) || !orderedIds.every((id) => typeof id === "string" && id.length > 0)) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
const ownedIds = new Set(record.groups.filter((group) => group.projectId === projectId).map((group) => group.id));
if (!isExactIdSet(orderedIds, ownedIds)) return respond(res, 400, { error: "bad-request" });
const indexOf = new Map(orderedIds.map((id, index) => [id, index]));
await writeRecord({ ...record, groups: record.groups.map((group) => group.projectId === projectId ? { ...group, sortIndex: indexOf.get(group.id) } : group) });
respond(res, 200, { ok: true });
});
});
route("task-create", async (body, res) => {
const groupId = body?.groupId;
const taskName = parseName(body?.name);
if (typeof groupId !== "string" || groupId.length === 0 || taskName === undefined) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" });
const siblings = record.tasks.filter((task) => task.groupId === groupId);
let id;
do { id = randomUUID(); } while (record.tasks.some((task) => task.id === id));
await writeRecord({ ...record, tasks: [...record.tasks, { id, groupId, name: taskName, sessionIds: [], sortIndex: nextSortIndex(siblings) }] });
respond(res, 200, { id });
});
});
route("task-rename", async (body, res) => {
const taskId = body?.taskId;
const taskName = parseName(body?.name);
if (typeof taskId !== "string" || taskId.length === 0 || taskName === undefined) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.tasks.some((task) => task.id === taskId)) return respond(res, 404, { error: "task-not-found" });
await writeRecord({ ...record, tasks: record.tasks.map((task) => task.id === taskId ? { ...task, name: taskName } : task) });
respond(res, 200, { ok: true });
});
});
route("task-delete", async (body, res) => {
const taskId = body?.taskId;
if (typeof taskId !== "string" || taskId.length === 0) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.tasks.some((task) => task.id === taskId)) return respond(res, 404, { error: "task-not-found" });
await writeRecord({ ...record, tasks: record.tasks.filter((task) => task.id !== taskId), inWork: record.inWork.filter((id) => id !== taskId) });
respond(res, 200, { ok: true });
});
});
route("task-move", async (body, res) => {
const taskId = body?.taskId;
const groupId = body?.groupId;
if (typeof taskId !== "string" || typeof groupId !== "string" || taskId.length === 0 || groupId.length === 0) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
if (!record.tasks.some((task) => task.id === taskId)) return respond(res, 404, { error: "task-not-found" });
if (!record.groups.some((group) => group.id === groupId)) return respond(res, 404, { error: "group-not-found" });
const targetSiblings = record.tasks.filter((task) => task.groupId === groupId && task.id !== taskId);
await writeRecord({ ...record, tasks: record.tasks.map((task) => task.id === taskId ? { ...task, groupId, sortIndex: nextSortIndex(targetSiblings) } : task) });
respond(res, 200, { ok: true });
});
});
route("tasks-reorder", async (body, res) => {
const groupId = body?.groupId;
const orderedIds = body?.orderedIds;
if (typeof groupId !== "string" || groupId.length === 0 || !Array.isArray(orderedIds) || !orderedIds.every((id) => typeof id === "string" && id.length > 0)) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
const ownedIds = new Set(record.tasks.filter((task) => task.groupId === groupId).map((task) => task.id));
if (!isExactIdSet(orderedIds, ownedIds)) return respond(res, 400, { error: "bad-request" });
const indexOf = new Map(orderedIds.map((id, index) => [id, index]));
await writeRecord({ ...record, tasks: record.tasks.map((task) => task.groupId === groupId ? { ...task, sortIndex: indexOf.get(task.id) } : task) });
respond(res, 200, { ok: true });
});
});
route("session-assign", async (body, res) => {
const sessionId = body?.sessionId;
const taskId = body?.taskId;
if (typeof sessionId !== "string" || !SESSION_ID_RE.test(sessionId)) return respond(res, 400, { error: "bad-request" });
if (taskId !== null && (typeof taskId !== "string" || !UUID_RE.test(taskId))) return respond(res, 400, { error: "bad-request" });
return withMutationLock(async () => {
const record = readRecord();
const without = record.tasks.map((task) => ({ ...task, sessionIds: task.sessionIds.filter((id) => id !== sessionId) }));
if (taskId === null) {
await writeRecord({ ...record, tasks: without });
return respond(res, 200, { ok: true });
}
if (!without.some((task) => task.id === taskId)) return respond(res, 404, { error: "task-not-found" });
await writeRecord({ ...record, tasks: without.map((task) => task.id === taskId ? { ...task, sessionIds: [sessionId, ...task.sessionIds] } : task) });
respond(res, 200, { ok: true });
});
});
return () => treeDomain.close();
});
}
export { apply, inject, name };

View file

@ -0,0 +1,31 @@
export const MAX_NAME_LENGTH = 80;
export function parseName(raw) {
if (typeof raw !== "string") return undefined;
const trimmed = raw.trim();
if (trimmed.length === 0 || trimmed.length > MAX_NAME_LENGTH) return undefined;
return trimmed;
}
export function hasNameConflict(records, sameParent, name, exceptId) {
const needle = name.toLowerCase();
return records.some((record) =>
sameParent(record) &&
record.id !== exceptId &&
record.name.toLowerCase() === needle
);
}
export function nextSortIndex(siblings) {
return siblings.reduce((max, record) => Math.max(max, record.sortIndex ?? 0), 0) + 1;
}
export function isExactIdSet(ids, allowed) {
if (ids.length !== allowed.size) return false;
const seen = new Set();
for (const id of ids) {
if (!allowed.has(id) || seen.has(id)) return false;
seen.add(id);
}
return true;
}

View file

@ -0,0 +1,52 @@
{
"name": "dsh-project-tree",
"version": "0.1.0",
"description": "Four-level sidebar tree for the DSH web UI: Projects (workspaces) > Groups > Tasks > Sessions, with server-side persistence. Opt-in sidebar takeover gated behind a localStorage flag.",
"private": true,
"type": "module",
"main": "lib/index.js",
"engines": {
"node": "^22.19.0 || >=24.0.0"
},
"exports": {
".": {
"default": "./lib/index.js"
},
"./client": {
"default": "./lib/client.js"
},
"./package.json": "./package.json"
},
"scripts": {
"test": "node --test"
},
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-primitives",
"@deepseek-ai/dsh-client-ui-settings",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-api-remotes",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
}
},
"files": [
"lib",
"cordis.patch.yml"
],
"peerDependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"zod": "^4.4.3",
"@deepseek-ai/dsh-storage-domain": ">=0.1.0-rc.6 <0.2.0 || >=0.1.1-rc.1 <0.2.0-0",
"@deepseek-ai/dsh-workspace": ">=0.1.0-rc.6 <0.2.0 || >=0.1.1-rc.1 <0.2.0-0"
},
"license": "MIT"
}

View file

@ -0,0 +1,36 @@
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { hasNameConflict, isExactIdSet, nextSortIndex, parseName } from "../lib/tree-utils.js";
test("parseName trims and rejects empty/oversized", () => {
assert.equal(parseName(" hello "), "hello");
assert.equal(parseName(""), undefined);
assert.equal(parseName(" "), undefined);
assert.equal(parseName(42), undefined);
assert.equal(parseName("x".repeat(81)), undefined);
assert.equal(parseName("x".repeat(80)).length, 80);
});
test("hasNameConflict is case-insensitive and parent-scoped", () => {
const records = [
{ id: "a", parent: "p1", name: "Backend" },
{ id: "b", parent: "p2", name: "Backend" }
];
const inP1 = (r) => r.parent === "p1";
assert.equal(hasNameConflict(records, inP1, "backend"), true);
assert.equal(hasNameConflict(records, inP1, "backend", "a"), false);
assert.equal(hasNameConflict(records, (r) => r.parent === "p3", "backend"), false);
});
test("nextSortIndex returns max+1, tolerating missing indices", () => {
assert.equal(nextSortIndex([]), 1);
assert.equal(nextSortIndex([{ sortIndex: 2 }, {}, { sortIndex: 5 }]), 6);
});
test("isExactIdSet requires a permutation", () => {
const allowed = new Set(["a", "b", "c"]);
assert.equal(isExactIdSet(["c", "a", "b"], allowed), true);
assert.equal(isExactIdSet(["a", "b"], allowed), false);
assert.equal(isExactIdSet(["a", "b", "b"], allowed), false);
assert.equal(isExactIdSet(["a", "b", "d"], allowed), false);
});

View file

@ -0,0 +1,7 @@
# dsh-sidebar-match-bg bundle patch: inserts the plugin row into the profile roster.
# The node half (exports ".") is a no-op; all behavior lives in the browser half
# (exports "./client"), which injects a <style> aliasing --dsw-specific-sidebar-fill
# to --dsw-alias-bg-base so the left sidebar shares the main content background.
- insert:
- id: sidebar-match-bg
name: dsh-sidebar-match-bg

View file

@ -0,0 +1,67 @@
/**
* dsh-sidebar-match-bg — browser half.
*
* The left sidebar root (SidebarRoot.module.css `._root`) paints
* `background: var(--dsw-specific-sidebar-fill)`, while the main content column
* paints `background: var(--dsw-alias-bg-base)`. Those two design tokens differ
* slightly, which is why the sidebar reads as a separate, lighter panel.
*
* To merge them we redefine the sidebar-fill token so it resolves to the base
* background, set on every element with `!important`. `!important` beats the
* app's normal declaration regardless of selector specificity, and because the
* variable inherits, the sidebar root that consumes it picks up the new value.
* We alias one token to another (not a hardcoded hex), so this stays correct
* across the dark/light themes and any future palette changes.
*
* Change the target color by editing SIDEBAR_FILL below.
* @module dsh-sidebar-match-bg/client
*/
window.__ModuleLoader__.load({
id: "dsh-sidebar-match-bg",
factory: (require) => {
var module = { exports: {} };
var exports = module.exports;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
// ---- CONFIG (edit here) -----------------------------------------------
// Fill the sidebar should take. Default: the main content background, so
// the sidebar becomes seamless with the transcript. Use any color or var,
// e.g. "var(--dsw-alias-bg-layer-2)" for a subtler tint, or "#151517".
const SIDEBAR_FILL = "var(--dsw-alias-bg-base)";
// -----------------------------------------------------------------------
const STYLE_ID = "dsh-sidebar-match-bg-style";
const cssText = "*{--dsw-specific-sidebar-fill:" + SIDEBAR_FILL + " !important;}";
/** Insert (or update) the override <style>, returning a cleanup fn. */
function install() {
let el = document.getElementById(STYLE_ID);
if (!el) {
el = document.createElement("style");
el.id = STYLE_ID;
(document.head || document.documentElement).appendChild(el);
}
el.textContent = cssText;
return () => {
const node = document.getElementById(STYLE_ID);
if (node) node.remove();
};
}
/** No client services required — this is pure CSS. */
const inject = [];
/** Client plugin body: inject the fill override (with HMR-safe cleanup). */
function apply(ctx) {
if (ctx && typeof ctx.effect === "function") {
ctx.effect(install, "dsh-sidebar-match-bg: sidebar fill override");
} else {
install();
}
}
exports.apply = apply;
exports.inject = inject;
return module.exports;
},
});

View file

@ -0,0 +1,17 @@
/**
* dsh-sidebar-match-bg — host half.
*
* This plugin is client-only: every behavior lives in the browser half
* (exports "./client"), which injects a <style> aliasing the sidebar fill CSS
* variable to the base background. The host half exists solely so the profile
* row loads cleanly; it registers nothing.
* @module dsh-sidebar-match-bg
*/
export const name = 'dsh-sidebar-match-bg'
/** No host services required. */
export const inject = []
/** No-op host apply. */
export function apply() {}

View file

@ -0,0 +1,37 @@
{
"name": "dsh-sidebar-match-bg",
"description": "dsh web plugin: paint the left sidebar with the same fill as the main content background by aliasing the --dsw-specific-sidebar-fill CSS variable to --dsw-alias-bg-base. Client-only, theme-agnostic.",
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": "^22.19.0 || >=24.0.0"
},
"main": "lib/index.js",
"exports": {
".": {
"default": "./lib/index.js"
},
"./client": {
"default": "./lib/client.js"
},
"./package.json": "./package.json"
},
"dsh": {
"engines": {
"dsh": ">=0.1.2-alpha.1"
},
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [],
"platform": "web"
}
},
"files": [
"lib/**/*.js",
"cordis.patch.yml"
],
"license": "MIT"
}

2
plugins/dsh-touched-git/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
runtime/

View file

@ -0,0 +1,125 @@
# DSH Git workspaces
The **Git Working Dirs** dropdown in the session header lists directories recorded by the agent's existing add/remove/get tools. It sits immediately left of the right-aligned `…` utilities menu. Each valid directory has an **Open VS Code** button that opens a separate browser tab using OpenVSCode Server (Code OSS, not Microsoft's branded VS Code distribution).
## Startup and resource contract
- Importing the plugin, registering tools, adding a directory, listing directories, polling, and mounting the Git Working Dirs header action never start an editor.
- Only a successful explicit **Open VS Code** action calls the POST launch endpoint.
- A blocked popup does not request startup.
- One loopback server is shared by all workspaces in this DSH host process. Concurrent requests share one startup promise.
- Each opened browser workspace may have its own extension host, language servers, Git watchers, and terminals. Keep unnecessary tabs and extensions closed.
- Reopening a directory focuses its existing browser window when the DSH client still has that window reference. Refreshing DSH loses these in-memory references; reopening may create another tab. It never deliberately reloads an existing editor whose reference was lost, preserving unsaved work.
- Closing an editor tab does not stop the shared server. VS Code owns extension-host reconnection and cleanup. The launcher does not force idle shutdown, which could terminate active terminals or other workspaces.
- Server exit or startup failure does not trigger an automatic restart. A subsequent explicit click may start it again.
- Plugin disposal requests server shutdown. Do not restart DSH while editor terminals or unsaved work are active without saving first.
## Git-scope guard for write and edit
The plugin checks both structured file-changing tools before dispatch:
- A target outside Git is allowed with an explicit warning.
- A target in a Git repository or linked worktree is blocked unless that exact root is registered in the current session's Git Working Dirs menu.
- New, untracked files inside a registered repository are allowed; this is repository-scope enforcement, not an index-membership requirement.
- Nested repositories, submodules, and linked worktrees are separate scopes. The active-header repository and another session's registrations do not grant permission.
- Paths are resolved through DSH's filesystem provider and checked physically, including new parent directories and symlinks. Broken Git metadata, probe failures, missing session identity, and uncertain scope fail closed.
- Registration must finish before a dependent write. A blocked mutation must not be rerouted through a shell or another tool.
An async preflight supplies a per-call certificate to DSH's synchronous monotonic tool guard. A secondary check validates the actual filesystem-intent target and preserves the existing observation intent. DSH's sandbox, approval, read-before-write, and stale-version checks are not replaced.
**Boundary:** this guards registry calls named `write` and `edit`, including those dispatched through Code Mode. It does not police arbitrary shell/Python commands, external editors, other mutation tools, or direct plugin filesystem calls. Like the existing provider boundary, it is not immune to malicious filesystem changes racing between path validation and use. It is a workflow guard, not a kernel filesystem sandbox or a hostile-plugin security boundary.
## Expanded tool diffs and file actions
Both file-tool rows reuse the installed `dsh-diff-viewer` split renderer, expanded by default with no line-count clipping and horizontal scrolling for long lines. Settled successful results show their actual supplied hunks; running calls label proposed changes, and errors remain errors rather than appearing as applied diffs. Warnings remain visible alongside the diff.
The installed `dsh-diff-viewer` 0.1.1 profile package originally kept `DiffViewer` private. Its source entry and generated client bundle currently expose that component for this integration. Replacing or recomposing that package can overwrite the compatibility export; upstreaming the public export is the durable fix.
Every row has an **Open in VS Code** button followed by a native **Open** button that uses the same Host file-opening callback as read-tool path links. Running/failed calls and historical calls without a successful mutation record show a disabled button with an explanation. New successful calls record only session/call identity, canonical path, and repository in `touched-git/mutations.json`, not file contents. The button never guesses a target from today's working directory or the active Git selection.
Rendering diffs and retrieving button metadata do not launch an editor. On click, the host revalidates the recorded regular file, its physical path, and current Git registration. A non-Git file remains openable with a warning, in its parent-folder workspace; no repository is initialized or auto-registered.
For an existing workspace, the bundled workspace extension receives an acknowledged file-open request through a private local Unix socket and uses the public VS Code document API. It never saves, reverts, closes, or reloads an existing editor. The socket directory is private per runtime launch, and each socket is mode `0600`. New workspaces receive a file-open startup payload instead. If an existing bridge fails, the action reports the error instead of reloading or opening a replacement workspace. After DSH-page refresh loses browser window references, an existing workspace can still receive the file; the UI asks the user to switch to that tab when it cannot focus it.
## Install
Requires Linux x64, `curl`, `tar`, Python 3, and Node.js. The runtime is a pinned portable archive with its original license files preserved. It is installed beside the plugin, not globally and not as a service.
```sh
node scripts/install-editor.mjs
```
The installer reads `editor-release.json`, downloads from the official GitHub release, verifies the pinned SHA-256 before extraction, and installs into ignored `runtime/current/`. It also installs the local preferences and file-bridge extensions. It never starts the editor. A matching installation is reused. Updating the pin does not silently overwrite an existing runtime.
If the release host is unreachable, download `openvscode-server-v1.109.5-linux-x64.tar.gz` separately and install with the same mandatory checksum verification:
```sh
node scripts/install-editor.mjs --archive /path/to/openvscode-server-v1.109.5-linux-x64.tar.gz
```
The downloader honors curl's standard proxy environment variables. No proxy is required by the running editor.
Release provenance: https://github.com/gitpod-io/openvscode-server/releases/tag/openvscode-server-v1.109.5
After plugin changes, save active editor work, restart your existing DSH web service yourself, and refresh `http://127.0.0.1:3080`. Reopen editor workspaces from the refreshed Git Working Dirs menu so the new runtime receives its private bridge directory. Tabs connected to a runtime started before the bridge update cannot gain that startup environment through a page refresh alone. No replacement DSH server is needed. Client HMR is not assumed.
To install/update the two bundled extensions in an already installed runtime without starting a server:
```sh
node scripts/install-editor-extensions.mjs
```
Then open **Git Working Dirs → Open VS Code**. Select **Source Control** (`Ctrl+Shift+G`) for Git status, staging, commits, and diffs. Extension availability follows OpenVSCode Server's configured extension registry; Microsoft Marketplace compatibility is not guaranteed.
## Security and data
The editor binds only to `127.0.0.1` on an OS-assigned port and requires a fresh random connection token for each server lifetime. The token is stored in a private per-launch directory with file mode `0600`, passed via `--connection-token-file`, and returned only by the no-store launch response. Do not share the editor URL: it contains its access token. Normal editor authentication may retain browser cookies or history.
The launch route requires POST, a same-origin custom header, the plugin's existing loopback/Host/Origin fence, and a valid directory tracked by that session. It revalidates the repository before startup. This is a trusted local-user editor, not a sandbox: a user with editor access can use terminals and access the local account's files beyond the initially opened folder. Do not expose the editor port through a tunnel or public reverse proxy.
At the user's request, the bundled preferences extension disables workspace trust by default, selects Dark Modern and JetBrains Mono, and hides the secondary sidebar. Trust prompts and restricted-mode protections therefore no longer protect unfamiliar folders after those defaults are applied. Telemetry is disabled. Only a limited environment needed by local tools is passed to the editor, rather than copying DSH's entire environment.
Server data and installed extensions live in `${DSH_HOME:-~/.dsh}/touched-git/editor/`. OpenVSCode's web user settings are browser-local. Runtime binaries are ignored by Git. Tracked directory state remains in the existing store and is unchanged by this integration.
## Editor preferences
`editor-preferences/extension.js` is the authoritative default-settings declaration. The installer packages this small web extension locally and installs it through OpenVSCode's extension CLI, without starting a server. It uses the public VS Code configuration API instead of writing an unused server-side user settings file.
On first activation it applies the requested settings. Versioned per-setting updates apply newly requested defaults once without resetting unrelated preferences. Later activations preserve explicit user changes and seed only missing defaults, including after a new browser origin is created. It closes the auxiliary sidebar on workspace startup, but does not disable chat features; the user can reopen them. The extension supports untrusted workspaces so it can apply the trust preference there too.
For an existing runtime, install or update just the preferences with:
```sh
node scripts/install-editor-preferences.mjs
```
Refresh the VS Code tab to load a newly installed preferences extension. No DSH service restart is needed.
## Validation
```sh
npm test
node --check lib/index.js
node --check lib/client.js
node --check lib/editor-runtime.js
node --check lib/editor-route.js
node --check scripts/install-editor.mjs
git diff --check
```
Tests use fake editor processes and browser windows. They do not start a real editor or restart DSH.
Live acceptance after the user restarts DSH:
1. Before opening a workspace, verify no OpenVSCode server is running.
2. Open the Git Working Dirs menu and allow directory polling; verify it still has not started.
3. Click **Open VS Code** for a tracked repository; verify that folder opens and Source Control shows its Git state.
4. Open another tracked directory; verify a distinct workspace uses the same server port.
5. Click the first directory again; verify its existing tab is focused.
6. Attempt `write` and `edit` inside an unregistered test repository; verify denial and unchanged contents. Register it, then verify a read-before-write-aware retry succeeds.
7. Verify an outside-Git write succeeds with a warning.
8. Verify both file-tool rows show all diff hunks expanded and retain errors/warnings.
9. Click a recorded file's Open button, then repeat with an unsaved buffer in that workspace; verify file navigation without reloading or losing unsaved contents.
10. Verify old calls without recorded provenance remain readable, with disabled file actions rather than guessed targets.
The old read-only Git helper routes and vendored libraries remain available for compatibility, but the new tab does not fetch or render them. The previous custom UI is preserved in baseline commit `0e8c9d2`.

View file

@ -0,0 +1,7 @@
# dsh-touched-git bundle patch: inserts the dual-face plugin row. The node half
# (exports ".") registers the add/remove/get_touched_git_directory tools and the
# /touched-git/* routes; the dsh.client declaration loads the browser half
# (exports "./client"), which renders the right-side directory panel.
- insert:
- id: touched-git
name: dsh-touched-git

View file

@ -0,0 +1,126 @@
const net = require('node:net')
const { createHash, randomUUID } = require('node:crypto')
const { chmod, lstat, realpath, stat, unlink } = require('node:fs/promises')
const { isAbsolute, join } = require('node:path')
const MAX_MESSAGE_BYTES = 16384
const REQUEST_TIMEOUT_MILLISECONDS = 5000
function workspaceSocketPrefix(workspaceDirectory) {
return `${createHash('sha256').update(workspaceDirectory).digest('hex').slice(0, 24)}-`
}
async function validateBridgeDirectory(directory) {
if (!isAbsolute(directory)) throw new Error('The editor bridge directory must be absolute.')
const information = await lstat(directory)
if (!information.isDirectory() || information.isSymbolicLink()) throw new Error('The editor bridge directory is not a private directory.')
if ((information.mode & 0o077) !== 0 || information.uid !== process.getuid()) throw new Error('The editor bridge directory must be owned by this user with mode 0700.')
return realpath(directory)
}
function sendAcknowledgement(socket, response) {
if (!socket.destroyed) socket.end(`${JSON.stringify(response)}\n`)
}
async function openRequestedFile(vscode, workspaceDirectory, request) {
if (request.kind !== 'open-file' || request.workspaceDirectory !== workspaceDirectory) throw new Error('The requested workspace does not match this editor.')
if (typeof request.requestId !== 'string' || !request.requestId || request.requestId.length > 128) throw new Error('The editor request identity is invalid.')
if (typeof request.filePath !== 'string' || !isAbsolute(request.filePath)) throw new Error('The requested file path must be absolute.')
const currentFolder = vscode.workspace.workspaceFolders?.[0]
if (!currentFolder || await realpath(currentFolder.uri.fsPath) !== workspaceDirectory) throw new Error('The editor workspace has changed.')
const filePath = await realpath(request.filePath)
if (!(await stat(filePath)).isFile()) throw new Error('The requested path is not a regular file.')
const document = await vscode.workspace.openTextDocument(vscode.Uri.file(filePath))
await vscode.window.showTextDocument(document, { preview: false, preserveFocus: false })
}
function acceptRequest(socket, vscode, workspaceDirectory) {
let buffered = Buffer.alloc(0)
let received = false
socket.setTimeout(REQUEST_TIMEOUT_MILLISECONDS, () => socket.destroy())
socket.on('error', () => {})
socket.on('data', chunk => {
if (received) return
buffered = Buffer.concat([buffered, chunk])
if (buffered.length > MAX_MESSAGE_BYTES) {
received = true
sendAcknowledgement(socket, { ok: false, error: 'The editor request is too large.' })
return
}
const newline = buffered.indexOf(10)
if (newline === -1) return
received = true
let request
try {
request = JSON.parse(buffered.subarray(0, newline).toString('utf8'))
if (!request || typeof request !== 'object' || Array.isArray(request)) throw new Error('Invalid request.')
} catch {
sendAcknowledgement(socket, { ok: false, error: 'The editor request is not valid JSON.' })
return
}
Promise.resolve().then(() => openRequestedFile(vscode, workspaceDirectory, request)).then(
() => sendAcknowledgement(socket, { requestId: request.requestId, ok: true }),
error => sendAcknowledgement(socket, { requestId: request.requestId, ok: false, error: error.message || 'Unable to open the requested file.' })
)
})
}
async function startWorkspaceBridge({ vscode, bridgeDirectory }) {
const folder = vscode.workspace.workspaceFolders?.[0]
if (!folder || !bridgeDirectory) return null
const workspaceDirectory = await realpath(folder.uri.fsPath)
const directory = await validateBridgeDirectory(bridgeDirectory)
const socketPath = join(directory, `${workspaceSocketPrefix(workspaceDirectory)}${randomUUID()}.sock`)
if (Buffer.byteLength(socketPath) > 103) throw new Error('The editor bridge directory is too long for a Unix socket.')
const connections = new Set()
const server = net.createServer(socket => {
connections.add(socket)
socket.once('close', () => connections.delete(socket))
acceptRequest(socket, vscode, workspaceDirectory)
})
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(socketPath, () => {
server.off('error', reject)
resolve()
})
})
server.on('error', () => {})
let disposal
const dispose = () => {
if (!disposal) {
disposal = new Promise(resolve => {
server.close(resolve)
for (const socket of connections) socket.end()
}).then(() => unlink(socketPath).catch(error => {
if (error.code !== 'ENOENT') throw error
}))
}
return disposal
}
try {
await chmod(socketPath, 0o600)
} catch (error) {
await dispose()
throw error
}
return { workspaceDirectory, socketPath, dispose }
}
let activeBridge
async function activate(context) {
const vscode = require('vscode')
try {
activeBridge = await startWorkspaceBridge({ vscode, bridgeDirectory: process.env.DSH_EDITOR_BRIDGE_DIRECTORY })
if (activeBridge) context.subscriptions.push({ dispose: () => { void activeBridge.dispose().catch(() => {}) } })
} catch {
void vscode.window.showWarningMessage('The DSH file bridge is unavailable. Existing editor tabs will not be reloaded to open files.')
}
}
function deactivate() {
return activeBridge?.dispose()
}
module.exports = { activate, deactivate, startWorkspaceBridge, workspaceSocketPrefix, validateBridgeDirectory, MAX_MESSAGE_BYTES, REQUEST_TIMEOUT_MILLISECONDS }

View file

@ -0,0 +1,17 @@
{
"name": "dsh-editor-file-bridge",
"displayName": "DSH Editor File Bridge",
"description": "Open explicitly requested files in an existing DSH workspace without reloading editors.",
"publisher": "dsh-local",
"version": "0.1.0",
"type": "commonjs",
"engines": { "vscode": "^1.109.0" },
"license": "MIT",
"main": "./extension.js",
"extensionKind": ["workspace"],
"activationEvents": ["onStartupFinished"],
"capabilities": {
"untrustedWorkspaces": { "supported": true },
"virtualWorkspaces": false
}
}

View file

@ -0,0 +1,23 @@
const vscode = require('vscode')
const DEFAULT_SETTINGS = {
'security.workspace.trust.enabled': { value: false, version: 1 },
'workbench.colorTheme': { value: 'Default Dark Modern', version: 1 },
'workbench.secondarySideBar.defaultVisibility': { value: 'hidden', version: 1 },
'editor.fontFamily': { value: "'JetBrains Mono', monospace", version: 2 },
}
exports.activate = async context => {
const configuration = vscode.workspace.getConfiguration()
const appliedVersion = context.globalState.get('defaultsVersion') || 0
for (const [setting, preference] of Object.entries(DEFAULT_SETTINGS)) {
if (appliedVersion < preference.version || configuration.inspect(setting)?.globalValue === undefined) {
await configuration.update(setting, preference.value, vscode.ConfigurationTarget.Global)
}
}
const latestVersion = Math.max(...Object.values(DEFAULT_SETTINGS).map(preference => preference.version))
await context.globalState.update('defaultsVersion', latestVersion)
await vscode.commands.executeCommand('workbench.action.closeAuxiliaryBar')
}

View file

@ -0,0 +1,16 @@
{
"name": "dsh-editor-preferences",
"displayName": "DSH Editor Preferences",
"description": "Apply the requested defaults to DSH's browser-local VS Code settings.",
"publisher": "dsh-local",
"version": "0.2.0",
"engines": { "vscode": "^1.109.0" },
"license": "MIT",
"browser": "./extension.js",
"extensionKind": ["ui"],
"activationEvents": ["onStartupFinished"],
"capabilities": {
"untrustedWorkspaces": { "supported": true },
"virtualWorkspaces": true
}
}

View file

@ -0,0 +1,8 @@
{
"version": "1.109.5",
"platform": "linux",
"architecture": "x64",
"archiveDirectory": "openvscode-server-v1.109.5-linux-x64",
"url": "https://api.github.com/repos/gitpod-io/openvscode-server/releases/assets/359323543",
"sha256": "b433bf4f0227321a7014d8460d10a8f958adc0f45aa79bd889e84e65e8f88363"
}

View file

@ -0,0 +1,357 @@
window.__ModuleLoader__.load({
id: "dsh-touched-git",
factory: (require) => {
const react = require("react");
const jsx = require("react/jsx-runtime");
const { DiffViewer } = require("@dsh-external/dsh-diff-viewer");
const workspaceWindows = new Map();
const STYLE_ID = "dsh-touched-git-workspaces-style";
const STYLE_TEXT = `
[class*="_headerActions"]:has(.dsh-git-working-dirs){flex:1}
[class*="_titleRow"]:has(.dsh-git-working-dirs)>[class*="_headerUtilities"]{margin-left:8px}
.dsh-git-working-dirs{position:relative;margin-left:auto;color:var(--dsw-alias-label-primary);font-family:var(--dsw-font-family)}
.dsh-git-working-dirs__trigger{height:32px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.25));border-radius:18px;padding:5px 12px;background:transparent;color:inherit;font:13px var(--dsw-font-family);white-space:nowrap;cursor:pointer}
.dsh-git-working-dirs__trigger:hover,.dsh-git-working-dirs__trigger[aria-expanded=true]{background:var(--dsw-alias-interactive-bg-hover,#303846)}
.dsh-git-working-dirs__backdrop{position:fixed;inset:0;z-index:99;border:0;background:transparent;padding:0}
.dsh-git-working-dirs__menu{position:absolute;z-index:100;box-sizing:border-box;top:calc(100% + 6px);right:0;width:min(440px,calc(100vw - 32px));max-height:min(520px,calc(100vh - 100px));overflow:auto;padding:8px;background:var(--dsw-specific-menu,var(--dsw-alias-bg-base,#20242b));border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.25));border-radius:12px;box-shadow:var(--dsw-shadow-lv3,0 12px 32px rgba(0,0,0,.45))}
.dsh-git-working-dirs__hint{color:var(--dsw-alias-label-secondary);font-size:12px;line-height:1.45;margin:2px 4px 8px}
.dsh-git-working-dirs__list{display:flex;flex-direction:column;gap:6px}
.dsh-git-working-dirs__directory{display:flex;align-items:center;gap:10px;padding:8px;border-radius:8px}
.dsh-git-working-dirs__directory:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.1))}
.dsh-git-working-dirs__details{flex:1;min-width:0;overflow-wrap:anywhere}
.dsh-git-working-dirs__name{font-size:13px;font-weight:600;margin-bottom:2px}
.dsh-git-working-dirs__path{font:11px var(--dsw-font-mono,monospace);color:var(--dsw-alias-label-secondary)}
.dsh-git-working-dirs__menu button{font:inherit;font-size:12px;border:1px solid var(--dsw-alias-border-l2,#666);border-radius:5px;padding:5px 9px;background:var(--dsw-alias-bg-base,#20242b);color:inherit;cursor:pointer;white-space:nowrap}
.dsh-git-working-dirs__menu button:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#303846)}
.dsh-git-working-dirs__menu button:disabled{opacity:.5;cursor:default}
.dsh-git-working-dirs__error{color:#ef7878;font-size:12px;margin:7px 4px;overflow-wrap:anywhere}
.dsh-git-working-dirs__notice{font-size:12px;margin:7px 4px;color:var(--dsw-alias-label-secondary)}
.dsh-git-mutation{margin:8px 0;border:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.25));border-radius:8px;padding:12px;min-width:0;color:var(--dsw-alias-label-primary)}
.dsh-git-mutation__header{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:10px}
.dsh-git-mutation__title{flex:1;min-width:120px;overflow-wrap:anywhere;font-size:13px;font-weight:600}
.dsh-git-mutation__status{font-size:12px;color:var(--dsw-alias-label-secondary)}
.dsh-git-mutation[data-state=error] .dsh-git-mutation__status{color:#ef7878}
.dsh-git-mutation button{font:inherit;font-size:12px;border:1px solid var(--dsw-alias-border-l2,#666);border-radius:5px;padding:5px 9px;background:var(--dsw-alias-bg-base,#20242b);color:inherit;cursor:pointer}
.dsh-git-mutation button:disabled{opacity:.5;cursor:default}
.dsh-git-mutation__output{white-space:pre-wrap;overflow-wrap:anywhere;font:12px var(--dsw-font-mono,monospace);margin:10px 0}
.dsh-git-mutation__warning{white-space:pre-wrap;overflow-wrap:anywhere;color:var(--dsw-alias-state-warning-primary,#d9a84c);font-size:12px;margin:8px 0}
.dsh-git-mutation__reason{font-size:12px;color:var(--dsw-alias-label-secondary);margin:8px 0}
.dsh-git-mutation__diff{max-height:none}
`;
function installStyle() {
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement("style");
style.id = STYLE_ID;
document.head.appendChild(style);
}
style.textContent = STYLE_TEXT;
}
async function request(path, options) {
try {
const response = await fetch(path, options);
const envelope = await response.json();
if (envelope?.ok === true) return envelope;
return { ok: false, error: { message: envelope?.error?.message || "Git workspace request failed." } };
} catch {
return { ok: false, error: { message: "Cannot reach the Git workspace route. After installing changes, restart DSH and refresh this page." } };
}
}
function GitWorkingDirsMenu(props) {
const [menuOpen, setMenuOpen] = react.useState(false);
const [directories, setDirectories] = react.useState([]);
const [loaded, setLoaded] = react.useState(false);
const [error, setError] = react.useState("");
const [opening, setOpening] = react.useState(null);
const [notice, setNotice] = react.useState("");
const openingRef = react.useRef(false);
react.useEffect(() => {
let active = true;
let fetching = false;
const refresh = async () => {
if (!active || fetching || document.visibilityState === "hidden") return;
fetching = true;
try {
const result = await props.list();
if (!active) return;
setLoaded(true);
if (result.ok) setDirectories(result.value.entries || []);
else setError(result.error.message);
} finally {
fetching = false;
}
};
void refresh();
const timer = window.setInterval(() => { void refresh(); }, 4000);
return () => {
active = false;
window.clearInterval(timer);
};
}, [props.list]);
const openWorkspace = async (entry) => {
if (openingRef.current || !entry.ok) return;
const directory = entry.workingDirectory || entry.repository;
const existingWindow = workspaceWindows.get(directory);
if (existingWindow && !existingWindow.closed) {
existingWindow.focus();
setNotice(`Focused VS Code for ${directory}`);
return;
}
const editorWindow = window.open("about:blank", "_blank");
if (!editorWindow) {
setError("Allow popups for DSH, then click Open VS Code again. No editor was started.");
return;
}
openingRef.current = true;
setOpening(directory);
setError("");
setNotice("Starting the shared editor server if needed…");
try {
const result = await props.open(entry.id);
if (!result.ok) throw new Error(result.error.message);
if (editorWindow.closed) {
setNotice("The editor tab was closed. Click Open VS Code to reopen it.");
return;
}
const editorUrl = new URL(result.value.url);
if (editorUrl.protocol !== "http:" || editorUrl.hostname !== "127.0.0.1") {
throw new Error("The editor returned an unexpected non-local URL.");
}
editorWindow.location.href = editorUrl.href;
workspaceWindows.set(directory, editorWindow);
setNotice("VS Code opened in another tab. Select Source Control (Ctrl+Shift+G) to view Git changes.");
} catch (error) {
editorWindow.close();
setError(error.message || "Unable to open VS Code.");
setNotice("");
} finally {
openingRef.current = false;
setOpening(null);
}
};
return jsx.jsxs("div", { className: "dsh-git-working-dirs", children: [
jsx.jsx("button", { type: "button", className: "dsh-git-working-dirs__trigger", "aria-haspopup": "menu", "aria-expanded": menuOpen, onClick: () => setMenuOpen(current => !current), children: "Git Working Dirs" }),
menuOpen ? jsx.jsx("button", { type: "button", className: "dsh-git-working-dirs__backdrop", "aria-label": "Close Git Working Dirs", onClick: () => setMenuOpen(false) }) : null,
menuOpen ? jsx.jsxs("div", { className: "dsh-git-working-dirs__menu", role: "menu", children: [
jsx.jsx("p", { className: "dsh-git-working-dirs__hint", children: "Tracked directories. VS Code starts only when you click Open VS Code." }),
error ? jsx.jsx("div", { className: "dsh-git-working-dirs__error", role: "alert", children: error }) : null,
notice ? jsx.jsx("div", { className: "dsh-git-working-dirs__notice", role: "status", children: notice }) : null,
!loaded ? jsx.jsx("p", { className: "dsh-git-working-dirs__hint", children: "Loading tracked directories…" }) : null,
loaded && directories.length === 0 ? jsx.jsx("p", { className: "dsh-git-working-dirs__hint", children: "No Git directories tracked yet. The agent adds them with add_touched_git_directory." }) : null,
jsx.jsx("div", { className: "dsh-git-working-dirs__list", children: directories.map(entry => {
const directory = entry.workingDirectory || entry.repository || entry.path;
return jsx.jsxs("div", { className: "dsh-git-working-dirs__directory", role: "menuitem", children: [
jsx.jsxs("div", { className: "dsh-git-working-dirs__details", children: [
jsx.jsx("div", { className: "dsh-git-working-dirs__name", children: directory.split("/").filter(Boolean).pop() || directory }),
jsx.jsx("div", { className: "dsh-git-working-dirs__path", children: directory }),
!entry.ok ? jsx.jsx("div", { className: "dsh-git-working-dirs__error", children: entry.error?.message || "Directory is unavailable." }) : null,
] }),
jsx.jsx("button", { type: "button", disabled: !entry.ok || opening !== null, onClick: () => { void openWorkspace(entry); }, children: opening === directory ? "Opening…" : "Open VS Code" }),
] }, entry.id);
}) }),
] }) : null,
] });
}
function mutationDiffs(block) {
if ("kind" in block && (block.isError || block.error?.code === "interrupted")) return null;
const view = "kind" in block ? block.resultView : block.callView;
if (view?.card !== "diff" || !Array.isArray(view.diffs) || view.diffs.length === 0) return null;
if (!view.diffs.every(diff => diff && typeof diff.path === "string" && (diff.oldText === null || typeof diff.oldText === "string") && typeof diff.newText === "string")) return null;
return view.diffs;
}
function mutationOutput(block) {
if (!("kind" in block)) return "";
const text = (Array.isArray(block.content) ? block.content : []).filter(part => part?.type === "text" && typeof part.text === "string").map(part => part.text).join("\n");
if (text) return text;
return block.error?.message || [block.error?.name, block.error?.code].filter(Boolean).join(": ");
}
function mutationDisplayPath(block, diffs) {
if (diffs) return diffs[0].path;
try {
const args = JSON.parse(("kind" in block ? block.call?.argsRaw : block.argsRaw) || "{}");
return typeof args.file_path === "string" ? args.file_path : "";
} catch {
return "";
}
}
function localEditorUrl(value) {
const url = new URL(value);
if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || url.username || url.password) {
throw new Error("The editor returned an unexpected non-local URL.");
}
return url.href;
}
function MutationToolCard(props) {
const block = props.block;
const settled = "kind" in block;
const failed = settled && (block.isError || block.error?.code === "interrupted");
const state = !settled ? "running" : block.error?.code === "interrupted" ? "stopped" : failed ? "error" : "ok";
const status = { running: "Running", stopped: "Interrupted", error: "Error", ok: "Success" }[state];
const diffs = mutationDiffs(block);
const output = mutationOutput(block);
const displayPath = mutationDisplayPath(block, diffs);
const [expanded, setExpanded] = react.useState(true);
const [record, setRecord] = react.useState(null);
const [error, setError] = react.useState("");
const [notice, setNotice] = react.useState("");
const [opening, setOpening] = react.useState(false);
const openingRef = react.useRef(false);
react.useEffect(() => {
let active = true;
setRecord(null);
setError("");
setNotice("");
if (settled) {
void props.mutation(props.callId).then(result => {
if (!active) return;
setRecord({ callId: props.callId, ...(result.ok ? { value: result.value } : { reason: result.error?.message || "Mutation metadata is unavailable." }) });
}).catch(() => {
if (active) setRecord({ callId: props.callId, reason: "Mutation metadata is unavailable." });
});
}
return () => { active = false; };
}, [props.callId, settled, props.mutation]);
const metadata = record?.callId === props.callId ? record.value : null;
const canOpen = settled && !failed && metadata?.canOpen === true && typeof metadata.workspaceDirectory === "string" && metadata.workspaceDirectory !== "" && typeof metadata.filePath === "string" && metadata.filePath !== "";
const disabledReason = !settled ? "The tool is still running. Files can be opened only after a successful recorded mutation."
: failed ? "This call did not succeed. No successful mutation is available to open."
: !record || record.callId !== props.callId ? "Loading recorded mutation metadata…"
: !canOpen ? record.reason || metadata?.reason || "No successful mutation record is available for this call. Historical calls cannot be opened from this card."
: "";
const openMutation = async () => {
if (!canOpen || openingRef.current) return;
const directory = metadata.workspaceDirectory;
const remembered = workspaceWindows.get(directory);
const existing = remembered && !remembered.closed ? remembered : null;
let popup = null;
if (existing) {
try {
existing.focus();
} catch {
setError("The browser could not focus the existing VS Code window. It was left unchanged; switch to its tab and try again.");
return;
}
} else {
popup = window.open("about:blank", "_blank");
if (!popup) {
setError("Allow popups for DSH, then click Open in VS Code again. No editor request was sent.");
return;
}
}
openingRef.current = true;
setOpening(true);
setError("");
setNotice(existing ? "Opening the file in the existing VS Code workspace…" : "Opening the recorded file in VS Code…");
try {
const result = await props.openMutation(props.callId, Boolean(existing));
if (!result.ok) throw new Error(result.error?.message || "Unable to open the recorded file.");
const delivery = result.value;
if (existing) {
if (delivery.delivery !== "existing") throw new Error("The existing editor could not receive the file. Its window was left unchanged.");
setNotice("File opened in the existing VS Code workspace without reloading it.");
} else if (delivery.delivery === "existing") {
popup.close();
setNotice("File opened in an existing VS Code workspace. Switch to its tab; DSH no longer has a window reference to focus it.");
} else if (delivery.delivery === "new") {
const url = localEditorUrl(delivery.url);
if (typeof delivery.directory !== "string" || !delivery.directory) throw new Error("The editor returned no workspace directory.");
if (popup.closed) {
setNotice("The reserved tab was closed. Click Open in VS Code again to open the file.");
return;
}
popup.location.href = url;
workspaceWindows.set(delivery.directory, popup);
setNotice("File opened in a new VS Code workspace tab.");
} else {
throw new Error("The editor returned an unknown file-delivery mode.");
}
if (delivery.warning) setNotice(current => `${current}\n${delivery.warning}`);
} catch (failure) {
if (popup && !popup.closed) popup.close();
setError(failure.message || "Unable to open VS Code.");
setNotice("");
} finally {
openingRef.current = false;
setOpening(false);
}
};
return jsx.jsxs("section", { className: "dsh-git-mutation", "data-tool": props.toolName, "data-state": state, children: [
jsx.jsxs("header", { className: "dsh-git-mutation__header", children: [
jsx.jsx("span", { className: "dsh-git-mutation__title", children: `${props.toolName === "edit" ? "Edit" : "Write"}${displayPath ? ` · ${displayPath}` : ""}` }),
jsx.jsx("span", { className: "dsh-git-mutation__status", role: "status", children: status }),
diffs ? jsx.jsx("button", { type: "button", "aria-expanded": expanded, onClick: () => setExpanded(current => !current), children: expanded ? "Collapse diff" : "Expand diff" }) : null,
typeof props.inspect === "function" ? jsx.jsx("button", { type: "button", onClick: props.inspect, children: "Inspect" }) : null,
jsx.jsx("button", { type: "button", disabled: !canOpen || opening, title: disabledReason || undefined, onClick: () => { void openMutation(); }, children: opening ? "Opening…" : "Open in VS Code" }),
jsx.jsx("button", { type: "button", disabled: !canOpen || opening, title: disabledReason || undefined, onClick: () => { if (canOpen) props.openFile(metadata.filePath); }, children: "Open" }),
] }),
diffs && expanded ? jsx.jsxs("div", { children: [
jsx.jsx("div", { className: "dsh-git-mutation__reason", children: settled ? "Applied changes" : "Proposed changes · tool is still running" }),
jsx.jsx(DiffViewer, { diffs, viewMode: "split", maxLines: Infinity, className: "dsh-git-mutation__diff" }),
] }) : null,
output ? jsx.jsx("pre", { className: failed ? "dsh-git-mutation__output dsh-git-workspaces__error" : "dsh-git-mutation__output", children: output }) : null,
metadata?.warning ? jsx.jsx("div", { className: "dsh-git-mutation__warning", children: metadata.warning }) : null,
disabledReason ? jsx.jsx("div", { className: "dsh-git-mutation__reason", children: disabledReason }) : null,
error ? jsx.jsx("div", { className: "dsh-git-workspaces__error", role: "alert", children: error }) : null,
notice ? jsx.jsx("div", { className: "dsh-git-mutation__output", role: "status", children: notice }) : null,
] });
}
function apply(context) {
installStyle();
context.slots.inject("conversation.session.header.actions", () => context.slots.register({
name: "conversation.session.header.actions",
id: "touched-git-working-dirs",
order: 10000,
inject: (sessionId) => {
const query = `sessionId=${encodeURIComponent(sessionId)}`;
return {
list: () => request(`/touched-git/list?${query}`),
open: (path) => request(`/touched-git/editor/open?${query}&path=${encodeURIComponent(path)}`, {
method: "POST",
headers: { "x-dsh-editor-action": "open" },
}),
};
},
}, GitWorkingDirsMenu));
context.slots.inject("tool.call.toolview", function* () {
for (const key of ["write", "edit"]) {
yield context.slots.register({
name: "tool.call.toolview",
key,
priority: -10,
inject: (sessionId) => {
const query = `sessionId=${encodeURIComponent(sessionId)}`;
return {
mutation: (callId) => request(`/touched-git/mutation?${query}&callId=${encodeURIComponent(callId)}`),
openMutation: (callId, requireExisting) => request(`/touched-git/editor/open-file?${query}&callId=${encodeURIComponent(callId)}${requireExisting ? "&requireExisting=true" : ""}`, {
method: "POST",
headers: { "x-dsh-editor-action": "open" },
}),
};
},
}, MutationToolCard);
}
});
}
return { apply, inject: ["slots"] };
},
});

View file

@ -0,0 +1,92 @@
import net from 'node:net'
import { randomUUID } from 'node:crypto'
import { readdir, realpath } from 'node:fs/promises'
import { isAbsolute, join } from 'node:path'
import bridgeProtocol from '../editor-file-bridge/extension.js'
const { workspaceSocketPrefix, validateBridgeDirectory, MAX_MESSAGE_BYTES, REQUEST_TIMEOUT_MILLISECONDS } = bridgeProtocol
function requestFileOpen(socketPath, request, signal) {
return new Promise((resolve, reject) => {
if (signal.aborted) {
reject(signal.reason)
return
}
const socket = net.createConnection(socketPath)
let connected = false
let settled = false
let buffered = Buffer.alloc(0)
const finish = (error, opened) => {
if (settled) return
settled = true
signal.removeEventListener('abort', abort)
socket.destroy()
if (error) reject(error)
else resolve(opened)
}
const abort = () => finish(signal.reason || new Error('The editor request was cancelled.'))
signal.addEventListener('abort', abort, { once: true })
socket.once('connect', () => {
connected = true
socket.write(`${JSON.stringify(request)}\n`)
})
socket.on('data', chunk => {
buffered = Buffer.concat([buffered, chunk])
if (buffered.length > MAX_MESSAGE_BYTES) {
finish(new Error('The editor acknowledgement is too large.'))
return
}
const newline = buffered.indexOf(10)
if (newline === -1) return
let response
try {
response = JSON.parse(buffered.subarray(0, newline).toString('utf8'))
} catch {
finish(new Error('The editor returned an invalid acknowledgement.'))
return
}
if (response?.requestId !== request.requestId || typeof response.ok !== 'boolean') {
finish(new Error('The editor returned an unexpected acknowledgement.'))
} else if (!response.ok) {
finish(new Error(typeof response.error === 'string' ? response.error : 'The existing editor could not open the file.'))
} else {
finish(null, true)
}
})
socket.once('error', error => {
if (!connected && ['ECONNREFUSED', 'ENOENT'].includes(error.code)) finish(null, false)
else finish(error)
})
socket.once('close', () => {
if (!settled) finish(new Error('The existing editor disconnected before acknowledging the file.'))
})
})
}
export async function openFileInExistingWorkspace({ bridgeDirectory, workspaceDirectory, filePath, signal }) {
signal?.throwIfAborted()
if (!bridgeDirectory) return { opened: false }
if (!isAbsolute(workspaceDirectory) || !isAbsolute(filePath)) throw new Error('Editor workspace and file paths must be absolute.')
const canonicalWorkspace = await realpath(workspaceDirectory)
let directory
let entries
try {
directory = await validateBridgeDirectory(bridgeDirectory)
entries = await readdir(directory, { withFileTypes: true })
} catch (error) {
if (error.code === 'ENOENT') return { opened: false }
throw error
}
const prefix = workspaceSocketPrefix(canonicalWorkspace)
const sockets = entries.filter(entry => entry.isSocket() && entry.name.startsWith(prefix) && entry.name.endsWith('.sock')).map(entry => entry.name).sort()
const request = { kind: 'open-file', workspaceDirectory: canonicalWorkspace, filePath, requestId: randomUUID() }
if (Buffer.byteLength(JSON.stringify(request)) + 1 > MAX_MESSAGE_BYTES) throw new Error('The editor request is too large.')
const deadline = AbortSignal.timeout(REQUEST_TIMEOUT_MILLISECONDS)
const requestSignal = signal ? AbortSignal.any([signal, deadline]) : deadline
for (const name of sockets) {
requestSignal.throwIfAborted()
if (await requestFileOpen(join(directory, name), request, requestSignal)) return { opened: true }
}
requestSignal.throwIfAborted()
return { opened: false }
}

View file

@ -0,0 +1,42 @@
export function createEditorOpenHandler({ store, resolveDirectoryRepository, isLoopbackRequest, editor, writeJson }) {
return async (request, response) => {
response.setHeader('cache-control', 'no-store')
if (request.method !== 'POST') {
return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'Use POST to open a workspace.' } })
}
if (!isLoopbackRequest(request) || request.headers['x-dsh-editor-action'] !== 'open') {
return writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'Open workspaces from the local Git Working Dirs menu.' } })
}
const url = new URL(request.url, `http://${request.headers.host}`)
const sessionId = url.searchParams.get('sessionId') || ''
const path = url.searchParams.get('path') || ''
if (!sessionId || !path) {
return writeJson(response, 400, { ok: false, error: { code: 'invalid-request', message: 'Missing session or directory.' } })
}
try {
const entries = await store.list(sessionId)
const entry = entries.find(entry => entry.ok && [entry.id, entry.path, entry.repository, entry.workingDirectory].includes(path))
if (!entry) {
return writeJson(response, 403, { ok: false, error: { code: 'not-tracked', message: 'Directory is not tracked by this session.' } })
}
const directory = entry.workingDirectory || entry.repository
const resolved = await resolveDirectoryRepository(directory)
if (resolved.repository !== entry.repository) {
return writeJson(response, 409, { ok: false, error: { code: 'directory-changed', message: 'Repository location changed. Track it again before opening.' } })
}
const workspace = await editor.open(resolved.workingDirectory)
return writeJson(response, 200, { ok: true, value: workspace })
} catch (error) {
return writeJson(response, 503, { ok: false, error: { code: 'editor-unavailable', message: error.message || 'Unable to open VS Code.' } })
}
}
}

View file

@ -0,0 +1,167 @@
import { spawn } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { constants } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { openFileInExistingWorkspace } from './editor-file-bridge.js'
const PLUGIN_DIRECTORY = dirname(dirname(fileURLToPath(import.meta.url)))
export class EditorWorkspaces {
constructor(startRuntime = startEditorRuntime, openExistingFile = openFileInExistingWorkspace) {
this.startRuntime = startRuntime
this.openExistingFile = openExistingFile
this.starting = null
this.disposed = false
}
async ensureRuntime() {
if (this.disposed) throw new Error('The editor launcher has stopped.')
if (!this.starting) {
const starting = this.startRuntime()
this.starting = starting
starting.then(runtime => {
runtime.closed.finally(() => {
if (this.starting === starting) this.starting = null
})
}, () => {
if (this.starting === starting) this.starting = null
})
}
const runtime = await this.starting
if (this.disposed) throw new Error('The editor launcher has stopped.')
return runtime
}
async open(directory) {
const runtime = await this.ensureRuntime()
return { url: workspaceUrl(runtime, directory).href, directory }
}
async openFile(directory, filePath, { requireExisting = false, signal } = {}) {
signal?.throwIfAborted()
if (requireExisting && !this.starting) throw new Error('The existing workspace is not connected to this DSH host. Save it and reopen it from the Git Working Dirs menu.')
const runtime = await this.ensureRuntime()
signal?.throwIfAborted()
const delivery = await this.openExistingFile({ bridgeDirectory: runtime.bridgeDirectory, workspaceDirectory: directory, filePath, signal })
signal?.throwIfAborted()
if (this.disposed) throw new Error('The editor launcher has stopped.')
if (delivery.opened) return { delivery: 'existing', directory }
if (requireExisting) throw new Error('The workspace file bridge is not ready. Refresh that VS Code tab after installing the bridge, then try again.')
const url = workspaceUrl(runtime, directory)
const remoteFile = new URL(`vscode-remote://${url.host}`)
remoteFile.pathname = filePath.split('/').map(encodeURIComponent).join('/')
url.searchParams.set('payload', JSON.stringify([['openFile', remoteFile.href]]))
return { delivery: 'new', directory, url: url.href }
}
async dispose() {
this.disposed = true
if (!this.starting) return
const runtime = await this.starting.catch(() => null)
if (runtime) await runtime.stop()
}
}
function workspaceUrl(runtime, directory) {
const url = new URL(runtime.url)
url.searchParams.set('tkn', runtime.token)
url.searchParams.set('folder', directory)
return url
}
function editorEnvironment() {
const allowedNames = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'TZ', 'TMPDIR', 'DISPLAY', 'WAYLAND_DISPLAY', 'DBUS_SESSION_BUS_ADDRESS', 'SSH_AUTH_SOCK'])
return Object.fromEntries(Object.entries(process.env).filter(([name]) => allowedNames.has(name) || name.startsWith('LC_') || name.startsWith('XDG_')))
}
export async function startEditorRuntime() {
const executable = join(PLUGIN_DIRECTORY, 'runtime', 'current', 'node')
const serverEntry = join(PLUGIN_DIRECTORY, 'runtime', 'current', 'out', 'server-main.js')
try {
await access(executable, constants.X_OK)
await access(serverEntry, constants.R_OK)
} catch {
throw new Error(`VS Code runtime is not installed. Run node ${join(PLUGIN_DIRECTORY, 'scripts', 'install-editor.mjs')} and try again.`)
}
const dataDirectory = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'touched-git', 'editor')
await mkdir(dataDirectory, { recursive: true, mode: 0o700 })
const launchDirectory = await mkdtemp(join(dataDirectory, 'launch-'))
const bridgeDirectory = await mkdtemp(join(tmpdir(), 'dshb-'))
const token = randomBytes(32).toString('hex')
const tokenFile = join(launchDirectory, 'connection-token')
await writeFile(tokenFile, token, { mode: 0o600 })
const child = spawn(executable, [
serverEntry,
'--host', '127.0.0.1',
'--port', '0',
'--connection-token-file', tokenFile,
'--server-data-dir', join(dataDirectory, 'data'),
'--extensions-dir', join(dataDirectory, 'extensions'),
'--telemetry-level', 'off',
], {
cwd: PLUGIN_DIRECTORY,
env: { ...editorEnvironment(), DSH_EDITOR_BRIDGE_DIRECTORY: bridgeDirectory },
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
})
const closed = new Promise(resolve => {
child.once('close', async () => {
await Promise.all([
rm(launchDirectory, { recursive: true, force: true }).catch(() => {}),
rm(bridgeDirectory, { recursive: true, force: true }).catch(() => {}),
])
resolve()
})
})
const stop = async () => {
if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM')
await closed
}
try {
const url = await waitForEditorReady(child)
return { url, token, bridgeDirectory, closed, stop }
} catch (error) {
await stop()
throw error
}
}
export function waitForEditorReady(child, timeoutMilliseconds = 30000) {
return new Promise((resolve, reject) => {
let output = ''
const timer = setTimeout(() => fail(new Error('VS Code did not become ready within 30 seconds.')), timeoutMilliseconds)
const cleanup = () => {
clearTimeout(timer)
child.stdout.off('data', onOutput)
child.stderr.off('data', onOutput)
child.off('error', onError)
child.off('exit', onExit)
}
const fail = error => {
cleanup()
reject(error)
}
const onError = () => fail(new Error('Unable to execute the installed VS Code runtime.'))
const onExit = code => fail(new Error(`VS Code exited before becoming ready (exit ${code ?? 'signal'}).`))
const onOutput = chunk => {
output = (output + chunk.toString()).slice(-16384)
const match = output.match(/Web UI available at http:\/\/(?:localhost|127\.0\.0\.1):(\d+)(?=[/?\s])/)
if (!match || Number(match[1]) < 1 || Number(match[1]) > 65535) return
cleanup()
resolve(`http://127.0.0.1:${match[1]}`)
}
child.stdout.on('data', onOutput)
child.stderr.on('data', onOutput)
child.once('error', onError)
child.once('exit', onExit)
})
}

View file

@ -0,0 +1,200 @@
import { randomUUID } from 'node:crypto'
import { canonicalWriteTarget, checkGitWriteScope } from './git-write-scope.js'
function isFileMutation(execution) {
return execution?.name === 'write' || execution?.name === 'edit'
}
function sessionIdentity(execution) {
return {
sessionId: execution.agent?.session.header.id,
cwd: execution.agent?.session.header.cwd,
}
}
function failureMessage(error) {
return error instanceof Error ? error.message : String(error)
}
function throwIfCancelled(execution) {
execution.signal?.throwIfAborted()
}
function warningContext(warnings) {
return {
id: randomUUID(),
role: 'user',
content: warnings.map(text => ({ type: 'text', text })),
source: { kind: 'plugin', plugin: 'dsh-touched-git' },
}
}
function augmentDecision(execution, result, decision, warnings) {
if (warnings.length === 0) return decision
const needsContext = execution.parent !== undefined || decision.kind === 'block' || Object.hasOwn(decision, 'value')
const additionalContexts = needsContext
? [...decision.additionalContexts ?? [], warningContext(warnings)]
: decision.additionalContexts
if (decision.kind === 'block' || Object.hasOwn(decision, 'value')) {
return { ...decision, additionalContexts }
}
return {
...decision,
content: [...decision.content ?? result.content, ...warnings.map(text => ({ type: 'text', text }))],
...(additionalContexts === undefined ? {} : { additionalContexts }),
}
}
export function registerGitWriteGuard(context, { store, onSuccessfulMutation }) {
const certificates = new Map()
let disposed = false
function denialReason(execution) {
if (!isFileMutation(execution)) return undefined
const certificate = certificates.get(execution.token)
if (!certificate || disposed) return 'Write blocked: the Git scope pre-execution check did not complete.'
if (certificate.denial) return certificate.denial
const { sessionId, cwd } = sessionIdentity(execution)
if (sessionId !== certificate.sessionId || cwd !== certificate.cwd) {
return 'Write blocked: the session or working directory changed after the Git scope check.'
}
try {
if (certificate.repository && !store.isRegistered(sessionId, certificate.repository)) {
return `Write blocked: ${certificate.repository} is not registered in this session’s Git Working Dirs menu. Call add_touched_git_directory for this repository or worktree before writing.`
}
} catch (error) {
return `Write blocked: Git registration could not be checked: ${failureMessage(error)}`
}
return undefined
}
async function classify(execution, target) {
throwIfCancelled(execution)
const identity = sessionIdentity(execution)
if (typeof identity.sessionId !== 'string' || identity.sessionId === '' || typeof identity.cwd !== 'string' || identity.cwd === '') {
throw new Error('Write blocked: an active session with a working directory is required.')
}
const filePath = await context.fs.processPath(target)
throwIfCancelled(execution)
const scope = await checkGitWriteScope({ filePath, sessionId: identity.sessionId, store, signal: execution.signal })
throwIfCancelled(execution)
if (disposed) throw new Error('Write blocked: the Git scope guard was disposed during the check.')
const current = sessionIdentity(execution)
if (identity.sessionId !== current.sessionId || identity.cwd !== current.cwd) {
throw new Error('Write blocked: the session or working directory changed during the Git scope check.')
}
const certificate = { ...identity, ...scope }
certificates.set(execution.token, certificate)
const denial = denialReason(execution)
if (denial) throw new Error(denial)
return certificate
}
async function beforeExecution(execution, next) {
if (!isFileMutation(execution)) return next()
certificates.delete(execution.token)
const decision = await next()
if (decision.kind === 'deny') return decision
try {
throwIfCancelled(execution)
const identity = sessionIdentity(execution)
if (typeof identity.sessionId !== 'string' || identity.sessionId === '' || typeof identity.cwd !== 'string' || identity.cwd === '') {
throw new Error('Write blocked: an active session with a working directory is required.')
}
const requestedPath = execution.arguments.file_path
const parentSegment = /(?:^|[\\/])\.\.(?:[\\/]|$)/
const cwd = parentSegment.test(identity.cwd) || parentSegment.test(requestedPath)
? await canonicalWriteTarget(identity.cwd, execution.signal)
: identity.cwd
const target = await context.fs.resolve(requestedPath, { cwd, signal: execution.signal })
if (sessionIdentity(execution).sessionId !== identity.sessionId || sessionIdentity(execution).cwd !== identity.cwd) {
throw new Error('Write blocked: the session or working directory changed during path resolution.')
}
await classify(execution, target)
} catch (error) {
if (!disposed) certificates.set(execution.token, { denial: failureMessage(error) })
throwIfCancelled(execution)
}
return decision
}
async function beforeMutation(target, execution, next) {
if (!isFileMutation(execution)) return next()
throwIfCancelled(execution)
const denial = denialReason(execution)
if (denial) throw new Error(denial)
try {
await classify(execution, target)
} catch (error) {
if (!disposed) certificates.set(execution.token, { denial: failureMessage(error) })
throw error
}
const intent = await next()
throwIfCancelled(execution)
const finalDenial = denialReason(execution)
if (finalDenial) throw new Error(finalDenial)
return intent
}
async function afterExecution(execution, result, next) {
const decision = await next()
if (!isFileMutation(execution)) return decision
const certificate = certificates.get(execution.token)
if (!certificate || certificate.denial) return decision
const warnings = certificate.warning ? [certificate.warning] : []
if (!result.isError && !certificate.reported && onSuccessfulMutation) {
certificate.reported = true
try {
await onSuccessfulMutation({
sessionId: certificate.sessionId,
callId: execution.callId,
toolName: execution.name,
filePath: certificate.filePath,
repository: certificate.repository,
warning: certificate.warning,
})
} catch (error) {
warnings.push(`WARNING: The ${execution.name} operation completed, but its Git mutation record could not be saved: ${failureMessage(error)}`)
}
}
return augmentDecision(execution, result, decision, warnings)
}
const disposers = []
try {
disposers.push(context.on('tools/pre-execute', beforeExecution))
disposers.push(context.tools.guard(denialReason))
disposers.push(context.on('fs/write-intent', beforeMutation, true))
disposers.push(context.on('fs/edit-intent', beforeMutation, true))
disposers.push(context.on('tools/post-execute', afterExecution, true))
disposers.push(context.on('tools/result', execution => certificates.delete(execution.token)))
} catch (error) {
disposed = true
certificates.clear()
for (const dispose of disposers.reverse()) dispose()
throw error
}
return () => {
if (disposed) return
disposed = true
certificates.clear()
for (const dispose of disposers.reverse()) dispose()
}
}

View file

@ -0,0 +1,138 @@
import { execFile } from 'node:child_process'
import { lstat, readlink, realpath } from 'node:fs/promises'
import { dirname, isAbsolute, join, parse, relative, sep } from 'node:path'
import { promisify } from 'node:util'
const execute = promisify(execFile)
export class GitWriteScopeError extends Error {
constructor(code, message) {
super(message)
this.name = 'GitWriteScopeError'
this.code = code
}
}
export async function canonicalWriteTarget(absolutePath, signal) {
if (!isAbsolute(absolutePath)) throw new GitWriteScopeError('invalid-write-path', 'Git scope checks require an absolute file path.')
let current = parse(absolutePath).root
let components = absolutePath.slice(current.length).split(sep)
let followedLinks = 0
while (components.length) {
signal?.throwIfAborted()
const component = components.shift()
if (component === '' || component === '.') continue
if (component === '..') {
current = dirname(current)
continue
}
const candidate = join(current, component)
let info
try {
info = await lstat(candidate)
} catch (error) {
if (error.code !== 'ENOENT') throw error
current = candidate
continue
}
if (info.isSymbolicLink()) {
if (++followedLinks > 40) throw new GitWriteScopeError('symlink-loop', 'Cannot determine Git scope through a symlink loop.')
const link = await readlink(candidate)
const root = parse(link).root
if (root) current = root
components = [...link.slice(root.length).split(sep), ...components]
} else {
current = candidate
}
}
signal?.throwIfAborted()
return current
}
async function existingParentDirectory(filePath, signal) {
let directory = dirname(filePath)
while (true) {
signal?.throwIfAborted()
try {
const info = await lstat(directory)
if (!info.isDirectory()) throw new GitWriteScopeError('invalid-write-parent', 'The write target has a non-directory parent.')
return directory
} catch (error) {
if (error.code !== 'ENOENT') throw error
const parent = dirname(directory)
if (parent === directory) throw error
directory = parent
}
}
}
function gitEnvironment() {
return { ...Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_'))), LC_ALL: 'C' }
}
async function rejectUnresolvedGitMetadata(directory, signal) {
let current = directory
while (true) {
signal?.throwIfAborted()
try {
await lstat(join(current, '.git'))
throw new GitWriteScopeError('git-scope-unavailable', 'Git metadata exists but its repository could not be resolved. Fix it before writing.')
} catch (error) {
if (error.code !== 'ENOENT') throw error
}
const parent = dirname(current)
if (parent === current) return
current = parent
}
}
export async function repositoryForWriteTarget(filePath, signal) {
const canonicalPath = await canonicalWriteTarget(filePath, signal)
const directory = await existingParentDirectory(canonicalPath, signal)
let result
try {
result = await execute('git', ['-C', directory, 'rev-parse', '--show-toplevel'], {
encoding: 'utf8',
env: gitEnvironment(),
timeout: 10000,
maxBuffer: 1024 * 1024,
signal,
})
} catch (error) {
signal?.throwIfAborted()
if (error.code === 128 && /^fatal: not a git repository \(or any /.test(error.stderr || '')) {
await rejectUnresolvedGitMetadata(directory, signal)
return { filePath: canonicalPath, repository: null }
}
throw new GitWriteScopeError('git-scope-unavailable', 'Write refused because Git scope could not be verified.')
}
signal?.throwIfAborted()
const repository = await realpath(result.stdout.replace(/\r?\n$/, ''))
const relativePath = relative(repository, canonicalPath)
if (relativePath === '..' || relativePath.startsWith(`..${process.platform === 'win32' ? '\\' : '/'}`) || isAbsolute(relativePath)) {
throw new GitWriteScopeError('git-scope-mismatch', 'Git returned a repository that does not contain the physical write target.')
}
return { filePath: canonicalPath, repository }
}
export async function checkGitWriteScope({ filePath, sessionId, store, signal }) {
signal?.throwIfAborted()
if (!sessionId) throw new GitWriteScopeError('missing-write-session', 'An active session is required to check the write scope.')
const target = await repositoryForWriteTarget(filePath, signal)
if (!target.repository) {
return { ...target, warning: `WARNING: ${target.filePath} is outside a Git repository. This write is not protected by Git history. Consider creating a Git repository for this directory. If this path should belong to an existing repository, verify the path and call add_touched_git_directory for that exact repository or worktree.` }
}
const entries = await store.list(sessionId)
signal?.throwIfAborted()
if (!entries.some(entry => entry.ok && entry.repository === target.repository)) {
throw new GitWriteScopeError('unregistered-git-write', `Write blocked: ${target.repository} is not registered in this session’s Git Working Dirs menu. Call add_touched_git_directory for this repository or worktree before writing.`)
}
return { ...target, warning: null }
}

View file

@ -0,0 +1,546 @@
/**
* dsh-touched-git — host half.
*
* Tracks the set of git-revisioned directories an agent session has "touched",
* exposed through three model-facing tools and shown in a right-side panel.
*
* Tools:
* add_touched_git_directory — the agent calls this whenever it starts
* working in a git-revisioned directory.
* remove_touched_git_directory — the agent calls this ONLY after the user
* explicitly says a directory is no longer
* tracked.
* get_touched_git_directories — the agent reads the list; invalid earlier
* add calls are returned as error entries.
*
* Routes (loopback-only):
* GET /touched-git/list?sessionId= — entries for the panel.
* GET /touched-git/status?sessionId=&path= — `git status` for one entry.
* @module dsh-touched-git
*/
import { execFile } from 'node:child_process'
import { mkdir, readFile, realpath, rename, stat, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { EditorWorkspaces } from './editor-runtime.js'
import { createEditorOpenHandler } from './editor-route.js'
import { registerGitWriteGuard } from './git-write-guard.js'
import { MutationStore } from './mutation-store.js'
import { createMutationHandlers } from './mutation-routes.js'
const execFileAsync = promisify(execFile)
const VENDOR_DIR = join(dirname(fileURLToPath(import.meta.url)), 'vendor')
const STORE_FILE = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'touched-git', 'store.json')
const GUIDANCE = [
'Track the git-revisioned directories you work in for this session.',
'Whenever you START working in a directory that is inside a git repository or linked worktree',
'(after a cd, switching worktrees/branches, or beginning a new sub-task in another repo),',
'immediately call add_touched_git_directory with that directory.',
'Only call remove_touched_git_directory after the user explicitly tells you to stop tracking a',
"directory (e.g. \"we don't track that directory anymore\") — never remove one on your own.",
'Call get_touched_git_directories when you want to review the directories you have touched;',
'it also surfaces any earlier add calls that were invalid so you can correct them.',
'The write and edit tools block mutations inside Git repositories or linked worktrees that',
'are not registered in the current session. Register the exact repository or worktree first.',
'Writes outside Git are allowed with an explicit warning. Do not evade a Git-scope denial',
'through bash, another tool, or an alternate path; correct the session registration instead.',
].join(' ')
export const name = 'dsh-touched-git'
export const inject = ['webServer', 'tools', 'systemPrompt', 'fs']
const JSON_HEADERS = { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' }
function writeJson(response, status, body) {
response.writeHead(status, JSON_HEADERS)
response.end(JSON.stringify(body))
}
// ---- loopback trust fence (desktop only) ----
function isIPv4Loopback(address) {
const parts = address.split('.')
return parts.length === 4 && parts[0] === '127' && parts.every(p => /^\d{1,3}$/.test(p) && Number(p) <= 255)
}
function isLoopbackAddress(address) {
if (address === undefined) return false
const a = address.toLowerCase()
if (a === '::1') return true
if (a.startsWith('::ffff:')) return isIPv4Loopback(a.slice('::ffff:'.length))
return isIPv4Loopback(a)
}
function isLoopbackHostname(h) { return h === 'localhost' || h === '[::1]' || isIPv4Loopback(h) }
function isLoopbackRequest(request) {
if (!isLoopbackAddress(request.socket?.remoteAddress)) return false
const host = request.headers.host
if (typeof host !== 'string') return false
let hostUrl
try { hostUrl = new URL(`http://${host}`) } catch { return false }
if (!isLoopbackHostname(hostUrl.hostname)) return false
if (request.headers['sec-fetch-site'] === 'cross-site') return false
const origin = request.headers.origin
if (origin === undefined) return true
try { return new URL(origin).host === hostUrl.host } catch { return false }
}
/** Resolve a directory to its canonical path + git repository root. Throws a coded error otherwise. */
export async function resolveDirectoryRepository(directory) {
let workingDirectory
try {
workingDirectory = await realpath(directory)
const info = await stat(workingDirectory)
if (!info.isDirectory()) throw new Error('not a directory')
} catch {
throw Object.assign(new Error('directory does not resolve on disk'), { code: 'cwd-unavailable' })
}
try {
const result = await execFileAsync('git', ['rev-parse', '--show-toplevel'], {
cwd: workingDirectory, encoding: 'utf8', timeout: 10_000, windowsHide: true,
})
const repository = await realpath(result.stdout.trim())
return { workingDirectory, repository }
} catch {
throw Object.assign(new Error('directory is not inside a git repository'), { code: 'not-a-repository' })
}
}
/** Parse `git status --porcelain=v1 --branch` into a small structured shape. */
export async function gitStatus(repository) {
const result = await execFileAsync('git', ['-C', repository, 'status', '--porcelain=v1', '--branch'], {
encoding: 'utf8', timeout: 10_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
})
const lines = result.stdout.split('\n').filter(Boolean)
let branch = ''
const files = []
for (const line of lines) {
if (line.startsWith('## ')) { branch = line.slice(3); continue }
files.push({ xy: line.slice(0, 2), path: line.slice(3) })
}
const ns = await numstatMap(repository, ['diff', 'HEAD', '--numstat']).catch(() => new Map())
for (const f of files) { const s = ns.get(f.path); if (s) { f.add = s.add; f.del = s.del } }
return { branch, files, clean: files.length === 0 }
}
/** Unified diff for one file in a repository (all uncommitted changes vs HEAD; whole file for untracked). */
export async function gitFileDiff(repository, file) {
let headDiff = ''
try {
const r = await execFileAsync('git', ['-C', repository, 'diff', 'HEAD', '--', file], {
encoding: 'utf8', timeout: 10_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true,
})
headDiff = r.stdout
} catch { /* repo may have no commits yet; fall through to --no-index */ }
if (headDiff.trim() !== '') return { diff: headDiff }
// Untracked (or no HEAD): render the whole file as additions.
try {
const r = await execFileAsync('git', ['-C', repository, 'diff', '--no-index', '--', '/dev/null', join(repository, file)], {
encoding: 'utf8', timeout: 10_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true,
})
return { diff: r.stdout }
} catch (error) {
// `git diff --no-index` exits 1 WITH the diff in stdout when the files differ.
if (error && typeof error.stdout === 'string' && error.stdout.trim() !== '') return { diff: error.stdout }
return { diff: '' }
}
}
/** Linear commit history (first-parent) in git2json shape (newest first) for @gitgraph/js import. */
export async function gitLog(repository, limit = 200) {
const SEP = '\x1f'
const fmt = ['%H', '%P', '%an', '%ae', '%at', '%D', '%s'].join(SEP)
const r = await execFileAsync('git', ['-C', repository, 'log', '--first-parent', '-n', String(limit), '--pretty=format:' + fmt], {
encoding: 'utf8', timeout: 15_000, maxBuffer: 16 * 1024 * 1024, windowsHide: true,
})
const commits = []
for (const line of r.stdout.split('\n')) {
if (line.trim() === '') continue
const [hash, parents, an, ae, at, refs, subject] = line.split(SEP)
// Keep only the first parent so the graph is a single straight line.
const first = parents ? parents.split(' ').filter(Boolean)[0] : undefined
commits.push({
hash,
parents: first ? [first] : [],
author: { name: an || '', email: ae || '', timestamp: (Number(at) || 0) * 1000 },
refs: refs ? refs.split(', ').map(s => s.trim()).filter(Boolean) : [],
subject: subject || '',
})
}
return commits
}
/** path -> { add, del } line counts from a `git ... --numstat` invocation. */
async function numstatMap(repository, args) {
const r = await execFileAsync('git', ['-C', repository, ...args], {
encoding: 'utf8', timeout: 12_000, maxBuffer: 16 * 1024 * 1024, windowsHide: true,
})
const map = new Map()
for (const line of r.stdout.split('\n')) {
if (line.trim() === '') continue
const cols = line.split('\t')
if (cols.length < 3) continue
const add = cols[0] === '-' ? null : Number(cols[0])
const del = cols[1] === '-' ? null : Number(cols[1])
map.set(cols[cols.length - 1], { add, del })
}
return map
}
/** Files changed in one commit, in the same {xy, path} shape as `git status`. */
export async function gitCommitFiles(repository, ref) {
const r = await execFileAsync('git', ['-C', repository, 'diff-tree', '--no-commit-id', '--name-status', '-r', '-M', ref], {
encoding: 'utf8', timeout: 10_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true,
})
const files = []
for (const line of r.stdout.split('\n')) {
if (line.trim() === '') continue
const parts = line.split('\t')
files.push({ xy: `${(parts[0] || '?')[0]} `, path: parts[parts.length - 1] })
}
const ns = await numstatMap(repository, ['diff-tree', '--no-commit-id', '-r', '-M', '--numstat', ref]).catch(() => new Map())
for (const f of files) { const s = ns.get(f.path); if (s) { f.add = s.add; f.del = s.del } }
return { branch: `commit ${ref.slice(0, 10)}`, files, clean: files.length === 0 }
}
/** Patch for one file at one commit (no commit-message header). */
export async function gitFileDiffAtRef(repository, ref, file) {
const r = await execFileAsync('git', ['-C', repository, 'diff-tree', '--no-commit-id', '-p', '-r', '-M', ref, '--', file], {
encoding: 'utf8', timeout: 10_000, maxBuffer: 8 * 1024 * 1024, windowsHide: true,
})
return { diff: r.stdout }
}
/** Commit metadata (subject + full body + author) for the status header. */
export async function gitCommitMeta(repository, ref) {
const SEP = '\x1f'
const r = await execFileAsync('git', ['-C', repository, 'show', '-s', '--date=short', '--format=' + ['%H', '%an', '%ae', '%ad', '%s', '%b'].join(SEP), ref], {
encoding: 'utf8', timeout: 10_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
})
const parts = r.stdout.split(SEP)
return { hash: parts[0] || ref, author: { name: parts[1] || '', email: parts[2] || '' }, date: parts[3] || '', subject: parts[4] || '', body: (parts[5] || '').trim() }
}
/** Whole diff: every file in a commit, or the whole working tree vs HEAD. */
export async function gitWholeDiff(repository, ref) {
const args = ref
? ['-C', repository, 'diff-tree', '--no-commit-id', '-p', '-r', '-M', ref]
: ['-C', repository, 'diff', 'HEAD']
const r = await execFileAsync('git', args, { encoding: 'utf8', timeout: 20_000, maxBuffer: 32 * 1024 * 1024, windowsHide: true })
return { diff: r.stdout }
}
/** A commit hash we accept as a ref (never an option/flag). */
function isCommitRef(ref) { return typeof ref === 'string' && /^[0-9a-fA-F]{4,64}$/.test(ref) }
/** Reject path segments that escape the repository. */
function isSafeRelative(file) {
if (typeof file !== 'string' || file === '' || isAbsolute(file)) return false
return !file.split(/[/\\]/).includes('..')
}
function nowIso() { return new Date().toISOString() }
/** Per-session store of touched git directories, persisted atomically to STORE_FILE. */
export class TouchedGitStore {
constructor(filePath = STORE_FILE) {
this.filePath = filePath
this.sessions = new Map() // sessionId -> Map(id -> entry)
this.ready = this.load()
this.pendingWrite = Promise.resolve()
}
async load() {
try {
const parsed = JSON.parse(await readFile(this.filePath, 'utf8'))
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return
for (const [sessionId, list] of Object.entries(parsed)) {
if (!Array.isArray(list)) continue
const map = new Map()
for (const e of list) if (e && typeof e.id === 'string') map.set(e.id, e)
this.sessions.set(sessionId, map)
}
} catch { /* fresh */ }
}
async list(sessionId) {
await this.ready
return [...(this.sessions.get(sessionId)?.values() ?? [])]
}
isRegistered(sessionId, repository) {
return [...(this.sessions.get(sessionId)?.values() ?? [])].some(entry => entry.ok && entry.repository === repository)
}
async upsert(sessionId, entry) {
await this.ready
let map = this.sessions.get(sessionId)
if (map === undefined) { map = new Map(); this.sessions.set(sessionId, map) }
map.set(entry.id, entry)
await this.flush()
return entry
}
async remove(sessionId, predicate) {
await this.ready
const map = this.sessions.get(sessionId)
if (map === undefined) return []
const removed = []
for (const [id, entry] of [...map]) if (predicate(entry)) { map.delete(id); removed.push(entry) }
if (map.size === 0) this.sessions.delete(sessionId)
await this.flush()
return removed
}
async flush() {
this.pendingWrite = this.pendingWrite.then(() => this.persist())
await this.pendingWrite
}
async persist() {
await mkdir(dirname(this.filePath), { recursive: true })
const obj = {}
for (const [sessionId, map] of this.sessions) obj[sessionId] = [...map.values()]
const tmp = `${this.filePath}.tmp-${process.pid}`
await writeFile(tmp, `${JSON.stringify(obj, null, 2)}\n`, 'utf8')
await rename(tmp, this.filePath)
}
}
function sessionIdOf(execution) {
const sessionId = execution.agent?.session.header.id
if (typeof sessionId !== 'string' || sessionId === '') {
throw new Error('an active agent session is required')
}
return sessionId
}
function absoluteArgPath(execution, raw) {
const base = execution.agent?.session.header.cwd
const trimmed = String(raw ?? '').trim()
return isAbsolute(trimmed) ? trimmed : resolve(base || process.cwd(), trimmed)
}
function registerTools(context, store) {
const add = context.tools.register(defineTool({
name: 'add_touched_git_directory',
description: 'Record a git-revisioned directory this session is now working in. Call it whenever you start editing/building/committing in a repository or linked worktree, including after a cd or worktree switch. Invalid directories are still recorded as errors so you can review them later with get_touched_git_directories.',
parameters: {
path: { type: 'string', required: true, description: 'Absolute path, or a path relative to the session cwd, inside a git repository or linked worktree.' },
},
output: {
schema: { type: 'object', additionalProperties: true },
render: (_arguments, value) => [{
type: 'text',
text: value.ok
? `Tracking git directory: ${value.repository} (${value.tracked} tracked)`
: `Could not track ${value.path}: ${value.error?.message ?? 'invalid directory'} (recorded as an error)`,
}],
},
execute: async (args, execution) => {
execution.signal.throwIfAborted()
const sessionId = sessionIdOf(execution)
const absolutePath = absoluteArgPath(execution, args.path)
try {
const { workingDirectory, repository } = await resolveDirectoryRepository(absolutePath)
const entry = { id: repository, path: absolutePath, workingDirectory, repository, ok: true, addedAt: nowIso() }
await store.upsert(sessionId, entry)
return { ok: true, repository, workingDirectory, tracked: (await store.list(sessionId)).length }
} catch (error) {
const err = { code: error?.code || 'error', message: error?.message || String(error) }
const entry = { id: absolutePath, path: absolutePath, ok: false, error: err, addedAt: nowIso() }
await store.upsert(sessionId, entry)
return { ok: false, path: absolutePath, error: err }
}
},
}))
const remove = context.tools.register(defineTool({
name: 'remove_touched_git_directory',
description: 'Stop tracking a directory. ONLY call this after the user explicitly asks to remove/untrack a directory (e.g. "we don\'t track that directory anymore"). Never remove a directory on your own initiative.',
parameters: {
path: { type: 'string', required: true, description: 'The directory (or its repository root) to stop tracking. Matches by repository root or the recorded path.' },
},
output: {
schema: { type: 'object', additionalProperties: true },
render: (_arguments, value) => [{
type: 'text',
text: value.removed
? `Stopped tracking ${value.removed} director${value.removed === 1 ? 'y' : 'ies'}: ${value.entries.join(', ')}`
: 'No matching tracked directory to remove.',
}],
},
execute: async (args, execution) => {
execution.signal.throwIfAborted()
const sessionId = sessionIdOf(execution)
const absolutePath = absoluteArgPath(execution, args.path)
let repository
try { ({ repository } = await resolveDirectoryRepository(absolutePath)) } catch { /* may be an invalid entry */ }
const removed = await store.remove(sessionId, (e) =>
e.id === absolutePath || e.path === absolutePath || (repository !== undefined && e.repository === repository))
return { removed: removed.length, entries: removed.map(e => e.repository || e.path) }
},
}))
const get = context.tools.register(defineTool({
name: 'get_touched_git_directories',
description: 'List the git-revisioned directories tracked for this session, so you can review where you have been working. Entries whose earlier add call was invalid are returned with an error field.',
parameters: {},
output: {
schema: { type: 'object', additionalProperties: true },
render: (_arguments, value) => [{
type: 'text',
text: [
`Tracked git directories (${value.count}):`,
...value.directories.map(d => `- ${d.repository}`),
...(value.errors.length ? ['Invalid earlier adds:', ...value.errors.map(e => `- ${e.path}: ${e.error?.message ?? 'error'}`)] : []),
].join('\n'),
}],
},
execute: async (_args, execution) => {
execution.signal.throwIfAborted()
const sessionId = sessionIdOf(execution)
const entries = await store.list(sessionId)
const valid = entries.filter(e => e.ok)
const errors = entries.filter(e => !e.ok).map(e => ({ path: e.path, error: e.error }))
return {
count: entries.length,
directories: valid.map(e => ({ repository: e.repository, workingDirectory: e.workingDirectory, addedAt: e.addedAt })),
errors,
}
},
}))
return [add, remove, get]
}
function requestSessionId(requestUrl, host) {
try { return new URL(requestUrl ?? '/', `http://${host}`).searchParams.get('sessionId')?.trim() || '' } catch { return '' }
}
function requestParam(requestUrl, host, key) {
try { return new URL(requestUrl ?? '/', `http://${host}`).searchParams.get(key)?.trim() || '' } catch { return '' }
}
export function apply(context) {
const store = new TouchedGitStore()
const editor = new EditorWorkspaces()
const editorOpenHandler = createEditorOpenHandler({ store, resolveDirectoryRepository, isLoopbackRequest, editor, writeJson })
const mutations = new MutationStore(join(dirname(STORE_FILE), 'mutations.json'))
const mutationHandlers = createMutationHandlers({ mutations, store, editor, resolveDirectoryRepository, isLoopbackRequest, writeJson })
const listHandler = async (request, response) => {
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
if (!isLoopbackRequest(request)) return writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
const sessionId = requestSessionId(request.url, request.headers.host)
if (sessionId === '') return writeJson(response, 400, { ok: false, error: { code: 'invalid-session', message: 'missing session id' } })
const stored = await store.list(sessionId)
// Re-validate each tracked directory so the panel reflects reality.
const entries = await Promise.all(stored.map(async (e) => {
if (!e.ok) return e
try {
await resolveDirectoryRepository(e.repository)
return e
} catch (error) {
return { ...e, ok: false, error: { code: error?.code || 'error', message: error?.message || String(error) } }
}
}))
writeJson(response, 200, { ok: true, value: { entries } })
}
const statusHandler = async (request, response) => {
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
if (!isLoopbackRequest(request)) return writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
const sessionId = requestSessionId(request.url, request.headers.host)
const path = requestParam(request.url, request.headers.host, 'path')
if (sessionId === '' || path === '') return writeJson(response, 400, { ok: false, error: { code: 'invalid-request', message: 'missing session id or path' } })
// Only allow status for a directory this session actually tracks.
const tracked = (await store.list(sessionId)).some(e => e.repository === path || e.path === path || e.id === path)
if (!tracked) return writeJson(response, 403, { ok: false, error: { code: 'not-tracked', message: 'path is not a tracked directory' } })
const ref = requestParam(request.url, request.headers.host, 'ref')
try {
const { repository } = await resolveDirectoryRepository(path)
// A commit ref -> that commit's changed files + message; otherwise the working tree.
let status
if (ref !== '' && isCommitRef(ref)) {
status = await gitCommitFiles(repository, ref)
try { status.commit = await gitCommitMeta(repository, ref) } catch { /* meta optional */ }
} else {
status = await gitStatus(repository)
}
writeJson(response, 200, { ok: true, value: { repository, ...status } })
} catch (error) {
writeJson(response, 200, { ok: false, error: { code: error?.code || 'error', message: error?.message || String(error) } })
}
}
const logHandler = async (request, response) => {
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
if (!isLoopbackRequest(request)) return writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
const sessionId = requestSessionId(request.url, request.headers.host)
const path = requestParam(request.url, request.headers.host, 'path')
if (sessionId === '' || path === '') return writeJson(response, 400, { ok: false, error: { code: 'invalid-request', message: 'missing session id or path' } })
const tracked = (await store.list(sessionId)).some(e => e.repository === path || e.path === path || e.id === path)
if (!tracked) return writeJson(response, 403, { ok: false, error: { code: 'not-tracked', message: 'path is not a tracked directory' } })
const limit = Math.max(1, Math.min(2000, Number(requestParam(request.url, request.headers.host, 'limit')) || 20))
try {
const { repository } = await resolveDirectoryRepository(path)
const commits = await gitLog(repository, limit)
writeJson(response, 200, { ok: true, value: { commits } })
} catch (error) {
writeJson(response, 200, { ok: false, error: { code: error?.code || 'error', message: error?.message || String(error) } })
}
}
const diffHandler = async (request, response) => {
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
if (!isLoopbackRequest(request)) return writeJson(response, 403, { ok: false, error: { code: 'forbidden', message: 'loopback only' } })
const sessionId = requestSessionId(request.url, request.headers.host)
const path = requestParam(request.url, request.headers.host, 'path')
const file = requestParam(request.url, request.headers.host, 'file')
if (sessionId === '' || path === '') return writeJson(response, 400, { ok: false, error: { code: 'invalid-request', message: 'missing session id or path' } })
if (file !== '' && !isSafeRelative(file)) return writeJson(response, 400, { ok: false, error: { code: 'invalid-file', message: 'file must be a repo-relative path' } })
const tracked = (await store.list(sessionId)).some(e => e.repository === path || e.path === path || e.id === path)
if (!tracked) return writeJson(response, 403, { ok: false, error: { code: 'not-tracked', message: 'path is not a tracked directory' } })
const ref = requestParam(request.url, request.headers.host, 'ref')
const commitRef = ref !== '' && isCommitRef(ref) ? ref : ''
try {
const { repository } = await resolveDirectoryRepository(path)
let out
if (file === '') out = await gitWholeDiff(repository, commitRef) // whole commit / working tree
else if (commitRef) out = await gitFileDiffAtRef(repository, commitRef, file)
else out = await gitFileDiff(repository, file)
writeJson(response, 200, { ok: true, value: { file, diff: out.diff } })
} catch (error) {
writeJson(response, 200, { ok: false, error: { code: error?.code || 'error', message: error?.message || String(error) } })
}
}
const staticHandler = (fileName, contentType) => async (request, response) => {
if ((request.method ?? 'GET').toUpperCase() !== 'GET') return writeJson(response, 405, { ok: false, error: { code: 'method-not-allowed', message: 'method not allowed' } })
try {
const buffer = await readFile(join(VENDOR_DIR, fileName))
response.writeHead(200, { 'content-type': contentType, 'cache-control': 'public, max-age=86400' })
response.end(buffer)
} catch {
response.writeHead(404, { 'content-type': 'text/plain' })
response.end('not found')
}
}
context.effect(() => {
const disposers = [
context.webServer.register({ kind: 'exact', path: '/touched-git/list', handler: listHandler }),
context.webServer.register({ kind: 'exact', path: '/touched-git/editor/open', handler: editorOpenHandler }),
context.webServer.register({ kind: 'exact', path: '/touched-git/mutation', handler: mutationHandlers.describeHandler }),
context.webServer.register({ kind: 'exact', path: '/touched-git/editor/open-file', handler: mutationHandlers.openFileHandler }),
registerGitWriteGuard(context, { store, onSuccessfulMutation: mutation => mutations.record(mutation) }),
context.webServer.register({ kind: 'exact', path: '/touched-git/status', handler: statusHandler }),
context.webServer.register({ kind: 'exact', path: '/touched-git/log', handler: logHandler }),
context.webServer.register({ kind: 'exact', path: '/touched-git/diff', handler: diffHandler }),
context.webServer.register({ kind: 'exact', path: '/touched-git/vendor/gitgraph.js', handler: staticHandler('gitgraph.umd.min.js', 'application/javascript; charset=utf-8') }),
context.webServer.register({ kind: 'exact', path: '/touched-git/vendor/diff2html.js', handler: staticHandler('diff2html.min.js', 'application/javascript; charset=utf-8') }),
context.webServer.register({ kind: 'exact', path: '/touched-git/vendor/diff2html.css', handler: staticHandler('diff2html.min.css', 'text/css; charset=utf-8') }),
...registerTools(context, store),
context.systemPrompt.section({ name: 'plugin:touched-git', order: 146, text: GUIDANCE }),
]
return async () => {
for (const dispose of disposers.reverse()) dispose()
await editor.dispose()
}
}, 'dsh-touched-git: tools + routes + guidance')
}

View file

@ -0,0 +1,83 @@
import { stat } from 'node:fs/promises'
import { dirname } from 'node:path'
import { repositoryForWriteTarget } from './git-write-scope.js'
export function createMutationHandlers({ mutations, store, editor, resolveDirectoryRepository, isLoopbackRequest, writeJson }) {
function authorize(request, response, method) {
response.setHeader('cache-control', 'no-store')
if (request.method !== method) {
writeJson(response, 405, { ok: false, error: { message: `Use ${method} for this operation.` } })
return null
}
if (!isLoopbackRequest(request) || (method === 'POST' && request.headers['x-dsh-editor-action'] !== 'open')) {
writeJson(response, 403, { ok: false, error: { message: 'Use the file action in the local DSH GUI.' } })
return null
}
const url = new URL(request.url, `http://${request.headers.host}`)
const sessionId = url.searchParams.get('sessionId') || ''
const callId = url.searchParams.get('callId') || ''
if (!sessionId || !callId) {
writeJson(response, 400, { ok: false, error: { message: 'Missing session or tool call identity.' } })
return null
}
return { sessionId, callId, requireExisting: url.searchParams.get('requireExisting') === 'true' }
}
async function describe(sessionId, callId) {
const record = await mutations.get(sessionId, callId)
if (!record) return { canOpen: false, reason: 'No successful file record is available for this call. Older results predate file-action tracking.' }
const entries = await store.list(sessionId)
const tracked = record.repository ? entries.find(entry => entry.ok && entry.repository === record.repository) : null
return {
filePath: record.filePath,
repository: record.repository,
workspaceDirectory: tracked?.workingDirectory || record.repository || dirname(record.filePath),
canOpen: !record.repository || Boolean(tracked),
warning: record.warning,
...(record.repository && !tracked ? { reason: 'Register this repository in the session’s Git Working Dirs menu before opening it.' } : {}),
}
}
async function describeHandler(request, response) {
const identity = authorize(request, response, 'GET')
if (!identity) return
try {
writeJson(response, 200, { ok: true, value: await describe(identity.sessionId, identity.callId) })
} catch (error) {
writeJson(response, 503, { ok: false, error: { message: error.message || 'Cannot read the file action record.' } })
}
}
async function openFileHandler(request, response) {
const identity = authorize(request, response, 'POST')
if (!identity) return
try {
const record = await mutations.get(identity.sessionId, identity.callId)
if (!record) return writeJson(response, 403, { ok: false, error: { message: 'This session has no successful mutation record for that call.' } })
const target = await repositoryForWriteTarget(record.filePath)
if (target.filePath !== record.filePath) throw new Error('The recorded file target changed through a symlink. Open it from a new verified tool result instead.')
if (!(await stat(target.filePath)).isFile()) throw new Error('The recorded target is not a regular file.')
let directory = dirname(target.filePath)
if (target.repository) {
const entries = await store.list(identity.sessionId)
const entry = entries.find(entry => entry.ok && entry.repository === target.repository)
if (!entry) return writeJson(response, 403, { ok: false, error: { message: 'Register this repository or worktree in this session before opening the file.' } })
const workspace = await resolveDirectoryRepository(entry.workingDirectory || entry.repository)
if (workspace.repository !== target.repository) throw new Error('The tracked workspace changed repositories. Track it again before opening.')
directory = workspace.workingDirectory
}
const opened = await editor.openFile(directory, target.filePath, { requireExisting: identity.requireExisting })
writeJson(response, 200, { ok: true, value: {
...opened,
filePath: target.filePath,
warning: target.repository ? null : 'This file is outside Git and opens in its parent-folder workspace.',
} })
} catch (error) {
writeJson(response, 503, { ok: false, error: { message: error.message || 'Cannot open the recorded file.' } })
}
}
return { describeHandler, openFileHandler }
}

View file

@ -0,0 +1,60 @@
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname, isAbsolute } from 'node:path'
export class MutationStore {
constructor(filePath) {
this.filePath = filePath
this.sessions = new Map()
this.loadError = null
this.ready = this.load().catch(error => { this.loadError = error })
this.pendingWrite = Promise.resolve()
}
async load() {
let content
try {
content = await readFile(this.filePath, 'utf8')
} catch (error) {
if (error.code === 'ENOENT') return
throw error
}
const sessions = JSON.parse(content)
if (!sessions || typeof sessions !== 'object' || Array.isArray(sessions)) throw new Error('Invalid mutation record store.')
for (const [sessionId, entries] of Object.entries(sessions)) {
if (!Array.isArray(entries)) throw new Error('Invalid mutation record list.')
const records = new Map()
for (const entry of entries) {
if (!entry || typeof entry.callId !== 'string' || !isAbsolute(entry.filePath || '')) throw new Error('Invalid mutation record.')
records.set(entry.callId, entry)
}
this.sessions.set(sessionId, records)
}
}
async get(sessionId, callId) {
await this.ready
if (this.loadError) throw this.loadError
return this.sessions.get(sessionId)?.get(callId) || null
}
async record(mutation) {
await this.ready
if (this.loadError) throw this.loadError
let records = this.sessions.get(mutation.sessionId)
if (!records) {
records = new Map()
this.sessions.set(mutation.sessionId, records)
}
records.set(mutation.callId, { ...mutation, recordedAt: new Date().toISOString() })
this.pendingWrite = this.pendingWrite.catch(() => {}).then(() => this.persist())
await this.pendingWrite
}
async persist() {
await mkdir(dirname(this.filePath), { recursive: true, mode: 0o700 })
const data = Object.fromEntries([...this.sessions].map(([sessionId, records]) => [sessionId, [...records.values()]]))
const temporaryPath = `${this.filePath}.tmp-${process.pid}`
await writeFile(temporaryPath, `${JSON.stringify(data)}\n`, { mode: 0o600 })
await rename(temporaryPath, this.filePath)
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,57 @@
{
"name": "dsh-touched-git",
"description": "Track session Git directories and open VS Code web workspaces only on explicit user action.",
"scripts": {
"test": "node --test test/*.test.js",
"install:editor": "node scripts/install-editor.mjs",
"install:preferences": "node scripts/install-editor-preferences.mjs",
"install:extensions": "node scripts/install-editor-extensions.mjs"
},
"version": "0.1.0",
"private": true,
"type": "module",
"engines": {
"node": "^22.19.0 || >=24.0.0"
},
"main": "lib/index.js",
"exports": {
".": {
"default": "./lib/index.js"
},
"./client": {
"default": "./lib/client.js"
},
"./package.json": "./package.json"
},
"dsh": {
"engines": {
"dsh": ">=0.1.2-alpha.1"
},
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"@deepseek-ai/dsh-client-ui-conversation",
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-client-ui-tool",
"@dsh-external/dsh-diff-viewer"
],
"platform": "web"
}
},
"files": [
"lib/**/*",
"scripts/*.mjs",
"scripts/*.py",
"editor-preferences/*",
"editor-file-bridge/*",
"editor-release.json",
"README.md",
"cordis.patch.yml"
],
"dependencies": {
"@deepseek-ai/dsh-tools": "0.1.1-rc.2"
},
"license": "MIT"
}

254
plugins/dsh-touched-git/pnpm-lock.yaml generated Normal file
View file

@ -0,0 +1,254 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@deepseek-ai/dsh-tools':
specifier: 0.1.1-rc.2
version: 0.1.1-rc.2(fce27d0fcc1cba3231d79c0a47b47475)
packages:
'@deepseek-ai/cordis@4.0.2':
resolution: {integrity: sha512-asOnXP1TzFSFQlHb1iegDZp0z/8WD1c7YNrwJR/Tx2bzNuMXfcekE/I67Iv6SQXeLB4csxqCngzQKANP7gdw0g==}
hasBin: true
peerDependencies:
'@deepseek-ai/cordis-plugin-include': ^1.0.7
'@deepseek-ai/cordis-plugin-loader': ^1.0.3
peerDependenciesMeta:
'@deepseek-ai/cordis-plugin-include':
optional: true
'@deepseek-ai/cordis-plugin-loader':
optional: true
'@deepseek-ai/cosmokit@1.8.3':
resolution: {integrity: sha512-qBo+ronVM6Eu2WNVJXi8JcMiqZ19T9BRIpV+5qJUFPXjGH/Z0QKcQMC/IZJ7L394YTOtJgcovbk9qP0w2GsBXQ==}
'@deepseek-ai/dsh-agent@0.1.1-rc.2':
resolution: {integrity: sha512-cC7lnJe7JgPFcreNXxcxLMxQd78LnpVO9ZXROjZsGRQN1zGH6i/DduI892F1am85IfzzO+XTxMwwUHmfwamb0g==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.1
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
'@deepseek-ai/dsh-llm': ^0.1.1-rc.2
'@deepseek-ai/dsh-scope': ^0.1.1-rc.2
'@deepseek-ai/dsh-session': ^0.1.1-rc.2
'@deepseek-ai/dsh-system-prompt': ^0.1.1-rc.2
'@deepseek-ai/dsh-typert-protocol': ^0.1.1-rc.2
'@deepseek-ai/dsh-attachment@0.1.1-rc.2':
resolution: {integrity: sha512-rCYAt8QsawP1yfDCU7XxNwYT/XWvyFsxYrkwhLLkdfW83QVD0CQHizSkTQE7RFX74nKUD1z3sTLfnLr7xneArw==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.1
'@deepseek-ai/dsh-brand': ^0.1.1-rc.2
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
'@deepseek-ai/dsh-brand@0.1.1-rc.2':
resolution: {integrity: sha512-8vXsAXoUdzKAgvd/E9DgyT6HKmR6ZM4rtJ3fs/XoJ6n2kBk4tWil8Lv+jxCMWJeMOnTJF55gu2+NWQ3ECFPtJw==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.1
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
'@deepseek-ai/dsh-code-runtime@0.1.1-rc.2':
resolution: {integrity: sha512-SgFresqH5UABzRQZ7tOfqzOLMHF7089VeH+mfcwNQH5peOavgEKrAGOYz/9RnISH0XmMrj/x177t8gfO8Uvo/w==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.1
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
'@deepseek-ai/dsh-invariants@0.1.1-rc.2':
resolution: {integrity: sha512-l+1Om/EDFyMjhgSuEx2WDLLA2fia/+ga9mBTCoT/MMslsnWaK5G0/lWwbwlTBSaJ6OfmYc3DuBgox8DbgIGHRQ==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.1
'@deepseek-ai/dsh-llm@0.1.1-rc.2':
resolution: {integrity: sha512-ASJfjIdZbIXvLwi3rGo+eZb/GxMVV/WO5/XVD3B96mT8EIzrlw3+nMR6/CvmJVzcycKQ2XN0wj7jD6TasPRySA==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.1
'@deepseek-ai/dsh-attachment': ^0.1.1-rc.2
'@deepseek-ai/dsh-brand': ^0.1.1-rc.2
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
'@deepseek-ai/dsh-timeout': ^0.1.1-rc.2
'@deepseek-ai/dsh-scope@0.1.1-rc.2':
resolution: {integrity: sha512-Xy3ejL6dwVSluZL7XOWy76ya4pCw1uHwxodDK4O9XiQUiUV4FBXnt0aNJUtMeAFN0c1YujxxCmRniMvuuNn1Nw==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.1
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
'@deepseek-ai/dsh-session@0.1.1-rc.2':
resolution: {integrity: sha512-4/cv6X9HPhm47eyRhCu/WZwzrtJKegk5J+0xaxcZ9i8S0smdxP57tqy8a0jkSshLQn7BzMFxneQrlYExrLrDhQ==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.1
'@deepseek-ai/dsh-brand': ^0.1.1-rc.2
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
'@deepseek-ai/dsh-llm': ^0.1.1-rc.2
'@deepseek-ai/dsh-scope': ^0.1.1-rc.2
'@deepseek-ai/dsh-typert-protocol': ^0.1.1-rc.2
'@deepseek-ai/dsh-system-prompt@0.1.1-rc.2':
resolution: {integrity: sha512-on4hjAlYI5uX9q7Sf95YkMMBVe6heywtA/H50ksrIMUub8U2B98hO9iQpHhjwIO1F1vu+5pLcPvRr6yUGGmtXQ==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.1
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
'@deepseek-ai/dsh-llm': ^0.1.1-rc.2
'@deepseek-ai/dsh-scope': ^0.1.1-rc.2
'@deepseek-ai/dsh-timeout@0.1.1-rc.2':
resolution: {integrity: sha512-RrouVgU3G5gXr9zHhpThkMG6YKdcRJzXXdPm1dq3ioBxbvxlfMSfNY4tN8lWMJxLyGtvWkPra0HQX+YWxvdOOA==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.1
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
'@deepseek-ai/dsh-tools@0.1.1-rc.2':
resolution: {integrity: sha512-0GGL4D55MwYDepzZMOI3L0ycu5b2qr96GL0Y7snwhAnpK2Di61rbX3fJE+PB3ZrovGX0csIRdt9n3iJZDVtDrw==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.1
'@deepseek-ai/dsh-agent': ^0.1.1-rc.2
'@deepseek-ai/dsh-code-runtime': ^0.1.1-rc.2
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
'@deepseek-ai/dsh-llm': ^0.1.1-rc.2
'@deepseek-ai/dsh-scope': ^0.1.1-rc.2
'@deepseek-ai/dsh-session': ^0.1.1-rc.2
'@deepseek-ai/dsh-system-prompt': ^0.1.1-rc.2
'@deepseek-ai/dsh-user-approval': ^0.1.1-rc.2
'@deepseek-ai/dsh-typert-protocol@0.1.1-rc.2':
resolution: {integrity: sha512-lxBssDc5Pz1qBE5kuIyaArA7AvIPq9rpaVclylodiSzVJe95e2xruBg73tflyjtd8y00toet+DLgQ6tSSsq6Kw==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.1
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
'@deepseek-ai/dsh-user-approval@0.1.1-rc.2':
resolution: {integrity: sha512-SdsO4Rs+NeJFoertkVilXBACREOLfkKPJJznYKqDhJxeRo38RJ56dtj0Xd0/6rERmsQiMck4Bwdrzg1ubUqPNA==}
peerDependencies:
'@deepseek-ai/cordis': ^4.0.1
'@deepseek-ai/dsh-agent': ^0.1.1-rc.2
'@deepseek-ai/dsh-brand': ^0.1.1-rc.2
'@deepseek-ai/dsh-invariants': ^0.1.1-rc.2
'@deepseek-ai/dsh-llm': ^0.1.1-rc.2
'@deepseek-ai/dsh-scope': ^0.1.1-rc.2
'@deepseek-ai/dsh-session': ^0.1.1-rc.2
'@deepseek-ai/dsh-system-prompt': ^0.1.1-rc.2
'@deepseek-ai/schemastery@3.18.2':
resolution: {integrity: sha512-njDtZsznjYxok7KLLlHOPyuv2efdWVbSflAHgztSfbMsg+CVraEoRe2DjOCgClYv3ZCSm7WXoaUkbB/+RY7tWQ==}
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
snapshots:
'@deepseek-ai/cordis@4.0.2':
dependencies:
'@deepseek-ai/cosmokit': 1.8.3
'@standard-schema/spec': 1.1.0
'@deepseek-ai/cosmokit@1.8.3': {}
'@deepseek-ai/dsh-agent@0.1.1-rc.2(3c98189bacd1cf866c0f2ac4684d266f)':
dependencies:
'@deepseek-ai/cordis': 4.0.2
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-llm': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-attachment@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-timeout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))
'@deepseek-ai/dsh-scope': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-session': 0.1.1-rc.2(8df7545087af19146363c9f963e40f9c)
'@deepseek-ai/dsh-system-prompt': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-attachment@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-timeout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))
'@deepseek-ai/dsh-typert-protocol': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-attachment@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))':
dependencies:
'@deepseek-ai/cordis': 4.0.2
'@deepseek-ai/dsh-brand': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))':
dependencies:
'@deepseek-ai/cordis': 4.0.2
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-code-runtime@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))':
dependencies:
'@deepseek-ai/cordis': 4.0.2
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)':
dependencies:
'@deepseek-ai/cordis': 4.0.2
'@deepseek-ai/schemastery': 3.18.2
'@deepseek-ai/dsh-llm@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-attachment@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-timeout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))':
dependencies:
'@deepseek-ai/cordis': 4.0.2
'@deepseek-ai/dsh-attachment': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-brand': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-timeout': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/schemastery': 3.18.2
'@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))':
dependencies:
'@deepseek-ai/cordis': 4.0.2
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-session@0.1.1-rc.2(8df7545087af19146363c9f963e40f9c)':
dependencies:
'@deepseek-ai/cordis': 4.0.2
'@deepseek-ai/dsh-brand': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-llm': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-attachment@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-timeout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))
'@deepseek-ai/dsh-scope': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-typert-protocol': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-system-prompt@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-attachment@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-timeout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))':
dependencies:
'@deepseek-ai/cordis': 4.0.2
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-llm': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-attachment@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-timeout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))
'@deepseek-ai/dsh-scope': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/schemastery': 3.18.2
'@deepseek-ai/dsh-timeout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))':
dependencies:
'@deepseek-ai/cordis': 4.0.2
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-tools@0.1.1-rc.2(fce27d0fcc1cba3231d79c0a47b47475)':
dependencies:
'@deepseek-ai/cordis': 4.0.2
'@deepseek-ai/dsh-agent': 0.1.1-rc.2(3c98189bacd1cf866c0f2ac4684d266f)
'@deepseek-ai/dsh-code-runtime': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-llm': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-attachment@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-timeout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))
'@deepseek-ai/dsh-scope': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-session': 0.1.1-rc.2(8df7545087af19146363c9f963e40f9c)
'@deepseek-ai/dsh-system-prompt': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-attachment@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-timeout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))
'@deepseek-ai/dsh-user-approval': 0.1.1-rc.2(a10a28888be22f3eb1fe5cbc6aa13111)
'@deepseek-ai/schemastery': 3.18.2
'@deepseek-ai/dsh-typert-protocol@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))':
dependencies:
'@deepseek-ai/cordis': 4.0.2
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-user-approval@0.1.1-rc.2(a10a28888be22f3eb1fe5cbc6aa13111)':
dependencies:
'@deepseek-ai/cordis': 4.0.2
'@deepseek-ai/dsh-agent': 0.1.1-rc.2(3c98189bacd1cf866c0f2ac4684d266f)
'@deepseek-ai/dsh-brand': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-invariants': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)
'@deepseek-ai/dsh-llm': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-attachment@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-timeout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))
'@deepseek-ai/dsh-scope': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))
'@deepseek-ai/dsh-session': 0.1.1-rc.2(8df7545087af19146363c9f963e40f9c)
'@deepseek-ai/dsh-system-prompt': 0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-llm@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-attachment@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-brand@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))(@deepseek-ai/dsh-timeout@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2))))(@deepseek-ai/dsh-scope@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)(@deepseek-ai/dsh-invariants@0.1.1-rc.2(@deepseek-ai/cordis@4.0.2)))
'@deepseek-ai/schemastery': 3.18.2
'@deepseek-ai/schemastery@3.18.2':
dependencies:
'@deepseek-ai/cosmokit': 1.8.3
'@standard-schema/spec': 1.1.0
'@standard-schema/spec@1.1.0': {}

View file

@ -0,0 +1,29 @@
import { execFile } from 'node:child_process'
import { mkdir, readFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
const execute = promisify(execFile)
const pluginDirectory = dirname(dirname(fileURLToPath(import.meta.url)))
export async function installEditorExtensions(sourceDirectories = ['editor-preferences', 'editor-file-bridge']) {
const extensionsDirectory = join(process.env.DSH_HOME?.trim() || join(homedir(), '.dsh'), 'touched-git', 'editor', 'extensions')
for (const sourceDirectory of sourceDirectories) {
const sourcePath = join(pluginDirectory, sourceDirectory)
const manifest = JSON.parse(await readFile(join(sourcePath, 'package.json'), 'utf8'))
const archive = join(pluginDirectory, 'runtime', `${manifest.name}.vsix`)
await mkdir(dirname(archive), { recursive: true })
await execute('python3', [join(pluginDirectory, 'scripts', 'package-editor-preferences.py'), sourcePath, archive])
const result = await execute(join(pluginDirectory, 'runtime', 'current', 'bin', 'openvscode-server'), [
'--extensions-dir', extensionsDirectory,
'--install-extension', archive,
'--force',
])
process.stdout.write(result.stdout)
process.stderr.write(result.stderr)
}
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) await installEditorExtensions()

View file

@ -0,0 +1,8 @@
import { fileURLToPath } from 'node:url'
import { installEditorExtensions } from './install-editor-extensions.mjs'
export async function installEditorPreferences() {
await installEditorExtensions(['editor-preferences'])
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) await installEditorPreferences()

View file

@ -0,0 +1,75 @@
import { execFile } from 'node:child_process'
import { createHash } from 'node:crypto'
import { createReadStream, constants } from 'node:fs'
import { access, mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { promisify } from 'node:util'
import { installEditorExtensions } from './install-editor-extensions.mjs'
const execute = promisify(execFile)
const pluginDirectory = dirname(dirname(fileURLToPath(import.meta.url)))
const release = JSON.parse(await readFile(join(pluginDirectory, 'editor-release.json'), 'utf8'))
const runtimeDirectory = join(pluginDirectory, 'runtime')
const installationDirectory = join(runtimeDirectory, 'current')
async function archiveDigest(path) {
const hash = createHash('sha256')
for await (const chunk of createReadStream(path)) hash.update(chunk)
return hash.digest('hex')
}
async function isAlreadyInstalled() {
let installed
try {
installed = JSON.parse(await readFile(join(installationDirectory, 'dsh-release.json'), 'utf8'))
} catch (error) {
if (error.code === 'ENOENT') return false
throw error
}
if (installed.sha256 !== release.sha256) {
throw new Error('A different editor runtime is installed. Remove runtime/current explicitly before changing versions.')
}
await access(join(installationDirectory, 'bin', 'openvscode-server'), constants.X_OK)
return true
}
async function install() {
const argumentsList = process.argv.slice(2)
if (argumentsList.length !== 0 && (argumentsList.length !== 2 || argumentsList[0] !== '--archive')) {
throw new Error('Usage: node scripts/install-editor.mjs [--archive /path/to/editor.tar.gz]')
}
const providedArchive = argumentsList.length === 2 ? resolve(argumentsList[1]) : null
if (process.platform !== release.platform || process.arch !== release.architecture) {
throw new Error(`This pinned runtime supports ${release.platform}/${release.architecture}, not ${process.platform}/${process.arch}.`)
}
if (await isAlreadyInstalled()) {
console.log(`OpenVSCode Server ${release.version} is already installed. No server started.`)
return
}
await mkdir(runtimeDirectory, { recursive: true })
const stagingDirectory = await mkdtemp(join(runtimeDirectory, '.install-'))
try {
const archive = providedArchive || join(stagingDirectory, 'editor.tar.gz')
if (!providedArchive) {
console.log(`Downloading OpenVSCode Server ${release.version} from the pinned official release…`)
await execute('curl', ['--fail', '--location', '--silent', '--show-error', '--header', 'Accept: application/octet-stream', '--max-time', '180', '--output', archive, release.url], { timeout: 190000 })
}
if (await archiveDigest(archive) !== release.sha256) {
throw new Error('Editor archive SHA-256 mismatch; refusing to extract or execute it.')
}
await execute('tar', ['--extract', '--gzip', '--file', archive, '--directory', stagingDirectory, '--no-same-owner'], { timeout: 60000 })
const extractedDirectory = join(stagingDirectory, release.archiveDirectory)
await access(join(extractedDirectory, 'bin', 'openvscode-server'), constants.X_OK)
await writeFile(join(extractedDirectory, 'dsh-release.json'), `${JSON.stringify(release, null, 2)}\n`)
await rename(extractedDirectory, installationDirectory)
console.log(`Installed and SHA-256 verified OpenVSCode Server ${release.version}. No server started.`)
} finally {
await rm(stagingDirectory, { recursive: true, force: true })
}
}
await install()
await installEditorExtensions()

View file

@ -0,0 +1,56 @@
import json
import sys
import xml.etree.ElementTree as element_tree
import zipfile
from pathlib import Path
source_directory = Path(sys.argv[1])
archive_path = Path(sys.argv[2])
package = json.loads((source_directory / "package.json").read_text())
namespace = "http://schemas.microsoft.com/developer/vsx-schema/2011"
element_tree.register_namespace("", namespace)
def add_element(parent, name, attributes=None, text=None):
child = element_tree.SubElement(parent, f"{{{namespace}}}{name}", attributes or {})
child.text = text
return child
manifest = element_tree.Element(f"{{{namespace}}}PackageManifest", {"Version": "2.0.0"})
metadata = add_element(manifest, "Metadata")
add_element(metadata, "Identity", {
"Language": "en-US",
"Id": package["name"],
"Version": package["version"],
"Publisher": package["publisher"],
})
add_element(metadata, "DisplayName", text=package["displayName"])
add_element(metadata, "Description", text=package["description"])
properties = add_element(metadata, "Properties")
add_element(properties, "Property", {
"Id": "Microsoft.VisualStudio.Code.Engine",
"Value": package["engines"]["vscode"],
})
add_element(properties, "Property", {
"Id": "Microsoft.VisualStudio.Code.ExtensionKind",
"Value": ",".join(package["extensionKind"]),
})
installation = add_element(manifest, "Installation")
add_element(installation, "InstallationTarget", {"Id": "Microsoft.VisualStudio.Code"})
add_element(manifest, "Dependencies")
assets = add_element(manifest, "Assets")
add_element(assets, "Asset", {
"Type": "Microsoft.VisualStudio.Code.Manifest",
"Path": "extension/package.json",
"Addressable": "true",
})
content_types = '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="json" ContentType="application/json"/><Default Extension="js" ContentType="application/javascript"/><Default Extension="vsixmanifest" ContentType="text/xml"/></Types>'
with zipfile.ZipFile(archive_path, "w", zipfile.ZIP_DEFLATED) as archive:
archive.writestr("extension.vsixmanifest", element_tree.tostring(manifest, encoding="utf-8"))
archive.writestr("[Content_Types].xml", content_types)
for filename in ("package.json", "extension.js"):
archive.write(source_directory / filename, f"extension/{filename}")

View file

@ -0,0 +1,769 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
import vm from 'node:vm'
const clientPath = join(dirname(fileURLToPath(import.meta.url)), '..', 'lib', 'client.js')
const clientSource = readFileSync(clientPath, 'utf8')
function createRenderer() {
const state = { component: null, props: null, hooks: [], hookIndex: 0, pending: [], tree: null, rendering: false }
function render() {
state.rendering = true
state.hookIndex = 0
state.pending = []
try {
state.tree = state.component(state.props)
} finally {
state.rendering = false
}
for (const effect of state.pending) {
const hook = state.hooks[effect.index]
if (typeof hook.cleanup === 'function') hook.cleanup()
hook.deps = effect.deps
const result = effect.fn()
hook.cleanup = typeof result === 'function' ? result : undefined
}
}
function schedule() {
if (state.rendering) {
Promise.resolve().then(render)
return
}
render()
}
const react = {
useState(initial) {
const index = state.hookIndex++
if (!(index in state.hooks)) {
state.hooks[index] = { value: typeof initial === 'function' ? initial() : initial }
}
const hook = state.hooks[index]
const setValue = (next) => {
const resolved = typeof next === 'function' ? next(hook.value) : next
if (Object.is(resolved, hook.value)) return
hook.value = resolved
schedule()
}
return [hook.value, setValue]
},
useRef(initial) {
const index = state.hookIndex++
if (!(index in state.hooks)) state.hooks[index] = { current: initial }
return state.hooks[index]
},
useEffect(fn, deps) {
const index = state.hookIndex++
let hook = state.hooks[index]
if (!hook) hook = state.hooks[index] = { initialized: false, deps: undefined, cleanup: undefined }
const changed = !hook.initialized || !deps || !hook.deps ||
deps.length !== hook.deps.length || deps.some((value, i) => !Object.is(value, hook.deps[i]))
if (changed) {
hook.initialized = true
state.pending.push({ index, fn, deps })
}
},
}
const element = (type, props) => ({ type, props: props || {} })
const jsxRuntime = { jsx: element, jsxs: element, Fragment: Symbol('Fragment') }
return {
react,
jsxRuntime,
mount(component, props) {
state.component = component
state.props = props
state.hooks = []
render()
},
update(props) {
state.props = props
render()
},
unmount() {
for (const hook of state.hooks) {
if (hook && typeof hook.cleanup === 'function') {
hook.cleanup()
hook.cleanup = undefined
}
}
},
tree: () => state.tree,
}
}
function makeEditorWindow() {
return {
closed: false,
focusCount: 0,
closeCount: 0,
location: { href: 'about:blank' },
focus() { this.focusCount++ },
close() { this.closeCount++; this.closed = true },
}
}
function makeWindow() {
const win = {
openCalls: [],
openImpl: () => null,
intervals: [],
nextIntervalId: 1,
open(url, name) {
win.openCalls.push({ url, name })
return win.openImpl(url, name)
},
setInterval(callback, ms) {
const id = win.nextIntervalId++
win.intervals.push({ id, callback, ms })
return id
},
clearInterval(id) {
win.intervals = win.intervals.filter(entry => entry.id !== id)
},
triggerIntervals() {
for (const entry of win.intervals) entry.callback()
},
}
return win
}
function makeDocument() {
return {
visibilityState: 'visible',
getElementById: () => null,
createElement: () => ({}),
head: { appendChild() {} },
}
}
function loadClient() {
const renderer = createRenderer()
const win = makeWindow()
const doc = makeDocument()
const captured = {}
win.__ModuleLoader__ = { load: (module) => { captured.module = module } }
const fetchCalls = []
const sandbox = { window: win, document: doc, console, URL, encodeURIComponent, fetch: async (path, options) => {
fetchCalls.push({ path, options })
return { json: async () => ({ ok: true, value: {} }) }
} }
vm.createContext(sandbox)
vm.runInContext(clientSource, sandbox)
const DiffViewer = Symbol('SplitDiffViewer')
const require = (name) => {
if (name === 'react') return renderer.react
if (name === 'react/jsx-runtime') return renderer.jsxRuntime
if (name === '@dsh-external/dsh-diff-viewer') return { DiffViewer }
throw new Error(`Unexpected module request: ${name}`)
}
const api = captured.module.factory(require)
const registrations = []
const slots = {
inject: (name, build) => {
const result = build()
if (typeof result?.[Symbol.iterator] === 'function') return [...result]
return result
},
register: (config, component) => {
const registration = { config, component }
registrations.push(registration)
return registration
},
}
api.apply({ slots })
const workingDirs = registrations.find(entry => entry.config.name === 'conversation.session.header.actions')
const mutation = registrations.find(entry => entry.config.name === 'tool.call.toolview')
return { GitWorkingDirsMenu: workingDirs.component, MutationToolCard: mutation.component, renderer, window: win, document: doc, config: workingDirs.config, registrations, DiffViewer, fetchCalls }
}
async function flush() {
for (let i = 0; i < 5; i++) await new Promise(resolve => setImmediate(resolve))
}
function collectText(node, out = []) {
if (node == null) return out
if (Array.isArray(node)) {
for (const child of node) collectText(child, out)
return out
}
if (typeof node === 'string' || typeof node === 'number') {
out.push(String(node))
return out
}
if (typeof node === 'object') collectText(node.props?.children, out)
return out
}
function findAll(node, predicate, out = []) {
if (node == null) return out
if (Array.isArray(node)) {
for (const child of node) findAll(child, predicate, out)
return out
}
if (typeof node === 'object') {
if (predicate(node)) out.push(node)
findAll(node.props?.children, predicate, out)
}
return out
}
function visibleText(renderer) {
return collectText(renderer.tree()).join(' ')
}
function buttons(renderer) {
return findAll(renderer.tree(), node => node.type === 'button')
}
function trackedEntry(overrides = {}) {
return { ok: true, id: 'e1', repository: '/repo/.git', workingDirectory: '/repo/work', ...overrides }
}
function openGitMenu(renderer) {
buttons(renderer).find(button => button.props.children === 'Git Working Dirs').props.onClick()
}
function gitOpenButton(renderer) {
return buttons(renderer).find(button => button.props.children === 'Open VS Code')
}
test('mounting only lists tracked directories and never opens an editor', async () => {
const { GitWorkingDirsMenu, renderer, window } = loadClient()
let listCalls = 0
let openCalls = 0
const list = async () => { listCalls++; return { ok: true, value: { entries: [trackedEntry()] } } }
const open = async () => { openCalls++; return { ok: true, value: { url: 'http://127.0.0.1:40000' } } }
renderer.mount(GitWorkingDirsMenu, { list, open })
await flush()
assert.equal(listCalls, 1)
assert.equal(openCalls, 0)
assert.equal(window.intervals.length, 1)
})
test('header trigger opens and dismisses the Git Working Dirs menu', async () => {
const { GitWorkingDirsMenu, renderer } = loadClient()
renderer.mount(GitWorkingDirsMenu, {
list: async () => ({ ok: true, value: { entries: [trackedEntry()] } }),
open: async () => ({ ok: true, value: {} }),
})
await flush()
assert.equal(buttons(renderer)[0].props.children, 'Git Working Dirs')
assert.equal(buttons(renderer)[0].props['aria-expanded'], false)
assert.doesNotMatch(visibleText(renderer), /repo\/work/)
openGitMenu(renderer)
assert.equal(buttons(renderer)[0].props['aria-expanded'], true)
assert.match(visibleText(renderer), /repo\/work/)
buttons(renderer).find(button => button.props['aria-label'] === 'Close Git Working Dirs').props.onClick()
assert.equal(buttons(renderer)[0].props['aria-expanded'], false)
})
test('interval polling keeps listing without ever opening an editor', async () => {
const { GitWorkingDirsMenu, renderer, window } = loadClient()
let listCalls = 0
let openCalls = 0
const list = async () => { listCalls++; return { ok: true, value: { entries: [trackedEntry()] } } }
const open = async () => { openCalls++; return { ok: true, value: { url: 'http://127.0.0.1:40000' } } }
renderer.mount(GitWorkingDirsMenu, { list, open })
await flush()
window.triggerIntervals()
await flush()
window.triggerIntervals()
await flush()
assert.equal(listCalls, 3)
assert.equal(openCalls, 0)
})
test('an explicit click opens the editor and navigates the popup to the local URL', async () => {
const { GitWorkingDirsMenu, renderer, window } = loadClient()
const editorWindow = makeEditorWindow()
window.openImpl = () => editorWindow
let openArgument = null
const list = async () => ({ ok: true, value: { entries: [trackedEntry()] } })
const open = async (id) => { openArgument = id; return { ok: true, value: { url: 'http://127.0.0.1:40000' } } }
renderer.mount(GitWorkingDirsMenu, { list, open })
await flush()
openGitMenu(renderer)
gitOpenButton(renderer).props.onClick()
await flush()
assert.equal(openArgument, 'e1')
assert.equal(window.openCalls.length, 1)
assert.equal(editorWindow.location.href, 'http://127.0.0.1:40000/')
assert.match(visibleText(renderer), /VS Code opened/)
})
test('a blocked popup surfaces guidance and never opens an editor', async () => {
const { GitWorkingDirsMenu, renderer, window } = loadClient()
window.openImpl = () => null
let openCalls = 0
const list = async () => ({ ok: true, value: { entries: [trackedEntry()] } })
const open = async () => { openCalls++; return { ok: true, value: { url: 'http://127.0.0.1:40000' } } }
renderer.mount(GitWorkingDirsMenu, { list, open })
await flush()
openGitMenu(renderer)
gitOpenButton(renderer).props.onClick()
await flush()
assert.equal(window.openCalls.length, 1)
assert.equal(openCalls, 0)
assert.match(visibleText(renderer), /Allow popups/)
})
test('a still-open editor window is focused instead of reopened', async () => {
const { GitWorkingDirsMenu, renderer, window } = loadClient()
const editorWindow = makeEditorWindow()
window.openImpl = () => editorWindow
let openCalls = 0
const list = async () => ({ ok: true, value: { entries: [trackedEntry()] } })
const open = async () => { openCalls++; return { ok: true, value: { url: 'http://127.0.0.1:40000' } } }
renderer.mount(GitWorkingDirsMenu, { list, open })
await flush()
openGitMenu(renderer)
gitOpenButton(renderer).props.onClick()
await flush()
gitOpenButton(renderer).props.onClick()
await flush()
assert.equal(window.openCalls.length, 1)
assert.equal(openCalls, 1)
assert.equal(editorWindow.focusCount, 1)
assert.match(visibleText(renderer), /Focused VS Code/)
})
test('a failed list request is reported and never opens an editor', async () => {
const { GitWorkingDirsMenu, renderer } = loadClient()
let openCalls = 0
const list = async () => ({ ok: false, error: { message: 'list request failed' } })
const open = async () => { openCalls++; return { ok: true, value: { url: 'http://127.0.0.1:40000' } } }
renderer.mount(GitWorkingDirsMenu, { list, open })
await flush()
openGitMenu(renderer)
assert.equal(openCalls, 0)
assert.match(visibleText(renderer), /list request failed/)
})
test('a failed open request reports the error and closes the popup', async () => {
const { GitWorkingDirsMenu, renderer, window } = loadClient()
const editorWindow = makeEditorWindow()
window.openImpl = () => editorWindow
const list = async () => ({ ok: true, value: { entries: [trackedEntry()] } })
const open = async () => ({ ok: false, error: { message: 'editor is unavailable' } })
renderer.mount(GitWorkingDirsMenu, { list, open })
await flush()
openGitMenu(renderer)
gitOpenButton(renderer).props.onClick()
await flush()
assert.equal(editorWindow.closeCount, 1)
assert.equal(editorWindow.location.href, 'about:blank')
assert.match(visibleText(renderer), /editor is unavailable/)
})
test('unmounting stops the polling interval', async () => {
const { GitWorkingDirsMenu, renderer, window } = loadClient()
const list = async () => ({ ok: true, value: { entries: [trackedEntry()] } })
const open = async () => ({ ok: true, value: { url: 'http://127.0.0.1:40000' } })
renderer.mount(GitWorkingDirsMenu, { list, open })
await flush()
assert.equal(window.intervals.length, 1)
renderer.unmount()
assert.equal(window.intervals.length, 0)
})
function mutationBlock(overrides = {}) {
return {
kind: 'tool-result',
callId: 'mutation-1',
call: { name: 'write', argsRaw: JSON.stringify({ file_path: '/workspace/file.txt' }) },
isError: false,
content: [{ type: 'text', text: 'File updated.\nWARNING: model-visible note' }],
resultView: { card: 'diff', diffs: [
{ path: '/workspace/file.txt', oldText: 'before', newText: Array.from({ length: 45 }, (_, i) => `after ${i}`).join('\n') },
{ path: '/workspace/file.txt', oldText: 'other context', newText: 'other updated context' },
] },
...overrides,
}
}
function mutationMetadata(overrides = {}) {
return { filePath: '/workspace/file.txt', repository: '/workspace', workspaceDirectory: '/workspace', canOpen: true, warning: null, ...overrides }
}
function mutationProps(overrides = {}) {
return {
callId: 'mutation-1',
toolName: 'write',
block: mutationBlock(),
cwd: '/workspace',
home: '/home/user',
mutation: async () => ({ ok: true, value: mutationMetadata() }),
openMutation: async () => ({ ok: true, value: { delivery: 'new', directory: '/workspace', filePath: '/workspace/file.txt', url: 'http://127.0.0.1:40000/?file=file.txt' } }),
openFile: () => {},
...overrides,
}
}
function openFileButton(renderer) {
return buttons(renderer).find(node => node.props.children === 'Open in VS Code' || node.props.children === 'Opening…')
}
function nativeOpenButton(renderer) {
return buttons(renderer).find(node => node.props.children === 'Open')
}
test('registers the working-directory menu as the final header action and removes the Git tab', async () => {
const { registrations, fetchCalls } = loadClient()
assert.deepEqual(registrations.map(entry => [entry.config.name, entry.config.key]), [
['conversation.session.header.actions', undefined], ['tool.call.toolview', 'write'], ['tool.call.toolview', 'edit'],
])
assert.equal(registrations[0].config.id, 'touched-git-working-dirs')
assert.equal(registrations[0].config.order, 10000)
assert.equal(registrations.some(entry => entry.config.name === 'conversation.view'), false)
assert.match(clientSource, /_headerActions.*:has\(\.dsh-git-working-dirs\)\{flex:1\}/)
assert.match(clientSource, /\.dsh-git-working-dirs\{position:relative;margin-left:auto/)
assert.match(clientSource, /\.dsh-git-working-dirs__menu\{[^}]*right:0/)
assert.equal(registrations[1].component, registrations[2].component)
for (const registration of registrations.slice(1)) {
const api = registration.config.inject('session /1')
assert.equal(fetchCalls.length % 3, 0)
await api.mutation('call /1')
await api.openMutation('call /1', false)
await api.openMutation('call /1', true)
const requests = fetchCalls.slice(-3)
assert.equal(requests[0].path, '/touched-git/mutation?sessionId=session%20%2F1&callId=call%20%2F1')
assert.equal(requests[0].options, undefined)
assert.equal(requests[1].path, '/touched-git/editor/open-file?sessionId=session%20%2F1&callId=call%20%2F1')
assert.equal(requests[2].path, `${requests[1].path}&requireExisting=true`)
assert.equal(requests[2].options.method, 'POST')
assert.equal(requests[2].options.headers['x-dsh-editor-action'], 'open')
}
})
for (const toolName of ['write', 'edit']) {
test(`${toolName} defaults all result diffs to expanded and never launches during metadata preload`, async () => {
const { MutationToolCard, renderer, window, DiffViewer } = loadClient()
let metadataCalls = 0
let openCalls = 0
const props = mutationProps({ toolName,
mutation: async callId => { assert.equal(callId, 'mutation-1'); metadataCalls++; return { ok: true, value: mutationMetadata() } },
openMutation: async () => { openCalls++; return { ok: true, value: {} } },
})
renderer.mount(MutationToolCard, props)
await flush()
const diffs = findAll(renderer.tree(), node => node.type === DiffViewer)
assert.equal(diffs.length, 1)
assert.equal(diffs[0].props.maxLines, Infinity)
assert.equal(diffs[0].props.viewMode, 'split')
assert.equal(diffs[0].props.diffs, props.block.resultView.diffs)
assert.equal(diffs[0].props.diffs.length, 2)
assert.match(diffs[0].props.diffs[0].newText, /after 44$/)
assert.equal(buttons(renderer).find(node => node.props.children === 'Collapse diff').props['aria-expanded'], true)
assert.equal(renderer.tree().props['data-state'], 'ok')
assert.match(visibleText(renderer), /Success/)
assert.match(visibleText(renderer), /WARNING: model-visible note/)
assert.equal(openFileButton(renderer).props.disabled, false)
assert.equal(metadataCalls, 1)
assert.equal(openCalls, 0)
assert.equal(window.openCalls.length, 0)
})
}
test('native Open follows Open in VS Code and forwards the recorded path to the host', async () => {
const { MutationToolCard, renderer, window } = loadClient()
const paths = []
let editorCalls = 0
renderer.mount(MutationToolCard, mutationProps({
openFile: path => paths.push(path),
openMutation: async () => { editorCalls++; return { ok: true, value: {} } },
}))
await flush()
const allButtons = buttons(renderer)
const editorIndex = allButtons.indexOf(openFileButton(renderer))
assert.equal(allButtons[editorIndex + 1], nativeOpenButton(renderer))
assert.equal(nativeOpenButton(renderer).props.disabled, false)
nativeOpenButton(renderer).props.onClick()
assert.deepEqual(paths, ['/workspace/file.txt'])
assert.equal(editorCalls, 0)
assert.equal(window.openCalls.length, 0)
})
test('running mutation shows proposed diff and disabled file-opening buttons without metadata requests', async () => {
const { MutationToolCard, renderer, window, DiffViewer } = loadClient()
let metadataCalls = 0
const intended = [{ path: '/workspace/file.txt', oldText: 'old', newText: 'new' }]
renderer.mount(MutationToolCard, mutationProps({
block: { name: 'edit', argsRaw: '{}', callView: { card: 'diff', diffs: intended } },
mutation: async () => { metadataCalls++; return { ok: true, value: {} } },
}))
await flush()
assert.equal(renderer.tree().props['data-state'], 'running')
assert.equal(findAll(renderer.tree(), node => node.type === DiffViewer)[0].props.diffs, intended)
assert.match(visibleText(renderer), /Proposed changes/)
assert.doesNotMatch(visibleText(renderer), /Applied changes/)
assert.equal(openFileButton(renderer).props.disabled, true)
assert.equal(nativeOpenButton(renderer).props.disabled, true)
openFileButton(renderer).props.onClick()
await flush()
assert.equal(window.openCalls.length, 0)
assert.equal(metadataCalls, 0)
})
test('settled errors retain error text without presenting attempted diffs as applied', async () => {
const { MutationToolCard, renderer, DiffViewer, window } = loadClient()
let opens = 0
renderer.mount(MutationToolCard, mutationProps({
block: mutationBlock({ isError: true, content: [{ type: 'text', text: 'Error: write blocked; unregistered repository' }] }),
openMutation: async () => { opens++; return { ok: true, value: {} } },
}))
await flush()
assert.equal(renderer.tree().props['data-state'], 'error')
assert.match(visibleText(renderer), /Error: write blocked; unregistered repository/)
assert.doesNotMatch(visibleText(renderer), /Applied changes/)
assert.equal(findAll(renderer.tree(), node => node.type === DiffViewer).length, 0)
assert.equal(openFileButton(renderer).props.disabled, true)
assert.equal(nativeOpenButton(renderer).props.disabled, true)
openFileButton(renderer).props.onClick()
await flush()
assert.equal(opens, 0)
assert.equal(window.openCalls.length, 0)
})
test('historical calls without successful mutation records cannot invent editor provenance', async () => {
const { MutationToolCard, renderer, window } = loadClient()
renderer.mount(MutationToolCard, mutationProps({ mutation: async () => ({ ok: true, value: { canOpen: false } }) }))
await flush()
assert.equal(openFileButton(renderer).props.disabled, true)
assert.match(visibleText(renderer), /No successful mutation record.*Historical calls/)
openFileButton(renderer).props.onClick()
await flush()
assert.equal(window.openCalls.length, 0)
})
test('a new delivery navigates only the reserved blank and a later click never reloads it', async () => {
const { MutationToolCard, renderer, window } = loadClient()
const editorWindow = makeEditorWindow()
window.openImpl = () => editorWindow
const requests = []
renderer.mount(MutationToolCard, mutationProps({ openMutation: async (callId, requireExisting) => {
requests.push({ callId, requireExisting, focusCount: editorWindow.focusCount })
return { ok: true, value: requireExisting
? { delivery: 'existing', directory: '/workspace', filePath: '/workspace/file.txt' }
: { delivery: 'new', directory: '/workspace', filePath: '/workspace/file.txt', url: 'http://127.0.0.1:40000/?file=file.txt' } }
} }))
await flush()
openFileButton(renderer).props.onClick()
assert.equal(window.openCalls[0].url, 'about:blank')
await flush()
const firstLocation = editorWindow.location.href
openFileButton(renderer).props.onClick()
assert.equal(editorWindow.focusCount, 1)
await flush()
assert.deepEqual(requests, [
{ callId: 'mutation-1', requireExisting: false, focusCount: 0 },
{ callId: 'mutation-1', requireExisting: true, focusCount: 1 },
])
assert.equal(editorWindow.location.href, firstLocation)
assert.equal(editorWindow.closeCount, 0)
assert.equal(window.openCalls.length, 1)
assert.match(visibleText(renderer), /without reloading/)
})
test('popup failure never sends a mutation open POST', async () => {
const { MutationToolCard, renderer, window } = loadClient()
let opens = 0
renderer.mount(MutationToolCard, mutationProps({ openMutation: async () => { opens++; return { ok: true, value: {} } } }))
await flush()
openFileButton(renderer).props.onClick()
await flush()
assert.equal(window.openCalls.length, 1)
assert.equal(opens, 0)
assert.match(visibleText(renderer), /No editor request was sent/)
})
test('existing delivery after losing browser references closes only the reserved blank', async () => {
const { MutationToolCard, renderer, window } = loadClient()
const blank = makeEditorWindow()
window.openImpl = () => blank
renderer.mount(MutationToolCard, mutationProps({ openMutation: async () => ({ ok: true, value: { delivery: 'existing', directory: '/workspace', filePath: '/workspace/file.txt' } }) }))
await flush()
openFileButton(renderer).props.onClick()
await flush()
assert.equal(blank.closeCount, 1)
assert.equal(blank.location.href, 'about:blank')
assert.match(visibleText(renderer), /existing VS Code workspace.*no longer has a window reference/)
})
test('existing-window request failures leave that editor window open and unchanged', async () => {
const { MutationToolCard, renderer, window } = loadClient()
const editorWindow = makeEditorWindow()
window.openImpl = () => editorWindow
renderer.mount(MutationToolCard, mutationProps({ openMutation: async (callId, requireExisting) => requireExisting
? { ok: false, error: { message: 'Editor socket unavailable' } }
: { ok: true, value: { delivery: 'new', directory: '/workspace', filePath: '/workspace/file.txt', url: 'http://127.0.0.1:40000/' } },
}))
await flush()
openFileButton(renderer).props.onClick()
await flush()
const originalLocation = editorWindow.location.href
openFileButton(renderer).props.onClick()
await flush()
assert.equal(editorWindow.closeCount, 0)
assert.equal(editorWindow.location.href, originalLocation)
assert.equal(window.openCalls.length, 1)
assert.match(visibleText(renderer), /Editor socket unavailable/)
})
test('non-Git recorded edits remain openable and show their warning', async () => {
const { MutationToolCard, renderer, window } = loadClient()
window.openImpl = () => makeEditorWindow()
let openCalls = 0
renderer.mount(MutationToolCard, mutationProps({ toolName: 'edit',
mutation: async () => ({ ok: true, value: mutationMetadata({ repository: null, workspaceDirectory: '/workspace', warning: 'WARNING: outside Git; parent-folder workspace' }) }),
openMutation: async () => { openCalls++; return { ok: true, value: { delivery: 'existing', directory: '/workspace', filePath: '/workspace/file.txt' } } },
}))
await flush()
assert.equal(openFileButton(renderer).props.disabled, false)
assert.match(visibleText(renderer), /outside Git; parent-folder workspace/)
openFileButton(renderer).props.onClick()
await flush()
assert.equal(openCalls, 1)
})
test('non-local new delivery is refused and closes only its blank popup', async () => {
const { MutationToolCard, renderer, window } = loadClient()
const blank = makeEditorWindow()
window.openImpl = () => blank
renderer.mount(MutationToolCard, mutationProps({ openMutation: async () => ({ ok: true, value: { delivery: 'new', directory: '/workspace', url: 'https://untrusted.example/editor' } }) }))
await flush()
openFileButton(renderer).props.onClick()
await flush()
assert.equal(blank.location.href, 'about:blank')
assert.equal(blank.closeCount, 1)
assert.match(visibleText(renderer), /unexpected non-local URL/)
})
test('Git-menu workspace references are reused by mutation file buttons', async () => {
const { GitWorkingDirsMenu, MutationToolCard, renderer, window } = loadClient()
const existing = makeEditorWindow()
window.openImpl = () => existing
renderer.mount(GitWorkingDirsMenu, {
list: async () => ({ ok: true, value: { entries: [trackedEntry({ workingDirectory: '/workspace' })] } }),
open: async () => ({ ok: true, value: { url: 'http://127.0.0.1:40000/' } }),
})
await flush()
openGitMenu(renderer)
gitOpenButton(renderer).props.onClick()
await flush()
renderer.unmount()
const calls = []
renderer.mount(MutationToolCard, mutationProps({ openMutation: async (callId, requireExisting) => {
calls.push(requireExisting)
return { ok: true, value: { delivery: 'existing', directory: '/workspace', filePath: '/workspace/file.txt' } }
} }))
await flush()
openFileButton(renderer).props.onClick()
assert.equal(existing.focusCount, 1)
await flush()
assert.deepEqual(calls, [true])
assert.equal(window.openCalls.length, 1)
assert.equal(existing.location.href, 'http://127.0.0.1:40000/')
})
test('a running call preloads metadata once it settles and uses only authoritative result diffs', async () => {
const { MutationToolCard, renderer, DiffViewer } = loadClient()
let calls = 0
const props = mutationProps({ mutation: async () => { calls++; return { ok: true, value: mutationMetadata() } } })
renderer.mount(MutationToolCard, { ...props, block: { name: 'write', callView: { card: 'diff', diffs: [{ path: '/attempted', oldText: null, newText: 'attempt' }] } } })
await flush()
assert.equal(calls, 0)
renderer.update(props)
await flush()
assert.equal(calls, 1)
assert.equal(findAll(renderer.tree(), node => node.type === DiffViewer)[0].props.diffs, props.block.resultView.diffs)
assert.equal(openFileButton(renderer).props.disabled, false)
})
test('interrupted and malformed settled calls do not render an attempted applied diff', async () => {
const { MutationToolCard, renderer, DiffViewer } = loadClient()
const props = mutationProps({ block: mutationBlock({ error: { name: 'AbortError', code: 'interrupted' }, content: [], isError: true }) })
renderer.mount(MutationToolCard, props)
await flush()
assert.equal(renderer.tree().props['data-state'], 'stopped')
assert.match(visibleText(renderer), /Interrupted.*AbortError: interrupted/)
assert.equal(openFileButton(renderer).props.disabled, true)
assert.equal(findAll(renderer.tree(), node => node.type === DiffViewer).length, 0)
renderer.update({ ...props, block: mutationBlock({ resultView: { card: 'diff', diffs: [{ path: '/file', oldText: 12, newText: 'new' }] } }) })
await flush()
assert.equal(findAll(renderer.tree(), node => node.type === DiffViewer).length, 0)
assert.doesNotMatch(visibleText(renderer), /Applied changes/)
})
test('an unexpected new delivery never navigates or closes a remembered editor', async () => {
const { MutationToolCard, renderer, window } = loadClient()
const existing = makeEditorWindow()
window.openImpl = () => existing
renderer.mount(MutationToolCard, mutationProps())
await flush()
openFileButton(renderer).props.onClick()
await flush()
const location = existing.location.href
openFileButton(renderer).props.onClick()
await flush()
assert.equal(existing.closeCount, 0)
assert.equal(existing.location.href, location)
assert.equal(window.openCalls.length, 1)
assert.match(visibleText(renderer), /window was left unchanged/)
})
test('a browser focus failure is visible without disturbing the existing editor', async () => {
const { MutationToolCard, renderer, window } = loadClient()
const existing = makeEditorWindow()
window.openImpl = () => existing
let calls = 0
const props = mutationProps()
renderer.mount(MutationToolCard, { ...props, openMutation: (...args) => { calls++; return props.openMutation(...args) } })
await flush()
openFileButton(renderer).props.onClick()
await flush()
existing.focus = () => { throw new Error('Browser refused focus') }
openFileButton(renderer).props.onClick()
await flush()
assert.equal(calls, 1)
assert.equal(existing.closeCount, 0)
assert.equal(window.openCalls.length, 1)
assert.match(visibleText(renderer), /could not focus.*left unchanged/)
})
test('metadata errors disable opening and remain visible', async () => {
const { MutationToolCard, renderer, window } = loadClient()
renderer.mount(MutationToolCard, mutationProps({ mutation: async () => ({ ok: false, error: { message: 'No mutation record exists for this call' } }) }))
await flush()
assert.equal(openFileButton(renderer).props.disabled, true)
assert.match(visibleText(renderer), /No mutation record exists/)
assert.equal(window.openCalls.length, 0)
})

View file

@ -0,0 +1,246 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import net from 'node:net'
import { mkdtemp, mkdir, writeFile, rm, stat, rename, symlink } from 'node:fs/promises'
import { join } from 'node:path'
import { openFileInExistingWorkspace } from '../lib/editor-file-bridge.js'
import bridgeProtocol from '../editor-file-bridge/extension.js'
const { startWorkspaceBridge, workspaceSocketPrefix, MAX_MESSAGE_BYTES } = bridgeProtocol
async function fixture(t) {
const directory = await mkdtemp('/tmp/db-')
const workspaceDirectory = join(directory, 'workspace')
const bridgeDirectory = join(directory, 's')
const filePath = join(workspaceDirectory, 'space # + ü.txt')
await mkdir(workspaceDirectory)
await mkdir(bridgeDirectory, { mode: 0o700 })
await writeFile(filePath, 'disk content')
t.after(() => rm(directory, { recursive: true, force: true }))
const shown = []
const dirtyDocument = { isDirty: true, content: 'unsaved content' }
const vscode = {
Uri: { file: filePath => ({ fsPath: filePath }) },
workspace: {
workspaceFolders: [{ uri: { fsPath: workspaceDirectory } }],
openTextDocument: async uri => {
dirtyDocument.uri = uri
return dirtyDocument
},
},
window: { showTextDocument: async (document, options) => { shown.push({ document, options }) } },
}
return { directory, workspaceDirectory, bridgeDirectory, filePath, shown, dirtyDocument, vscode }
}
async function startBridge(t, setup) {
const bridge = await startWorkspaceBridge(setup)
t.after(() => bridge.dispose())
return bridge
}
async function rawServer(t, setup, suffix, onRequest) {
const socketPath = join(setup.bridgeDirectory, `${workspaceSocketPrefix(setup.workspaceDirectory)}${suffix}.sock`)
const connections = new Set()
const server = net.createServer(socket => {
connections.add(socket)
socket.once('close', () => connections.delete(socket))
socket.on('error', () => {})
let buffer = ''
socket.on('data', chunk => {
buffer += chunk
if (buffer.includes('\n')) onRequest(socket, JSON.parse(buffer.split('\n')[0]))
})
})
await new Promise((resolve, reject) => {
server.once('error', reject)
server.listen(socketPath, resolve)
})
const close = () => new Promise(resolve => {
server.close(resolve)
for (const socket of connections) socket.end()
})
t.after(close)
return { socketPath, close }
}
function rawRequest(socketPath, message) {
return new Promise((resolve, reject) => {
const socket = net.createConnection(socketPath)
let buffer = ''
socket.once('error', reject)
socket.once('connect', () => socket.write(message))
socket.on('data', chunk => { buffer += chunk })
socket.once('end', () => resolve(JSON.parse(buffer)))
})
}
test('opens an existing dirty document without changing its content or workspace', async t => {
const setup = await fixture(t)
const bridge = await startBridge(t, setup)
assert.equal((await stat(bridge.socketPath)).mode & 0o777, 0o600)
assert.deepEqual(await openFileInExistingWorkspace(setup), { opened: true })
assert.deepEqual(await openFileInExistingWorkspace(setup), { opened: true })
assert.equal(setup.shown.length, 2)
assert.equal(setup.shown[0].document, setup.dirtyDocument)
assert.deepEqual(setup.shown[0].options, { preview: false, preserveFocus: false })
assert.equal(setup.dirtyDocument.isDirty, true)
assert.equal(setup.dirtyDocument.content, 'unsaved content')
assert.equal(setup.dirtyDocument.uri.fsPath, setup.filePath)
})
test('permits a parent-authorized file outside the opened workspace subfolder', async t => {
const setup = await fixture(t)
setup.filePath = join(setup.directory, 'outside.txt')
await writeFile(setup.filePath, 'outside')
await startBridge(t, setup)
assert.deepEqual(await openFileInExistingWorkspace(setup), { opened: true })
})
test('returns unopened for absent bridge directory or unrelated workspaces', async t => {
const setup = await fixture(t)
assert.deepEqual(await openFileInExistingWorkspace(setup), { opened: false })
assert.deepEqual(await openFileInExistingWorkspace({ ...setup, bridgeDirectory: join(setup.directory, 'missing') }), { opened: false })
await startBridge(t, setup)
const otherWorkspace = join(setup.directory, 'other')
await mkdir(otherWorkspace)
assert.deepEqual(await openFileInExistingWorkspace({ ...setup, workspaceDirectory: otherWorkspace }), { opened: false })
})
test('canonicalizes workspace aliases and removes its socket on repeated disposal', async t => {
const setup = await fixture(t)
const bridge = await startBridge(t, setup)
const alias = join(setup.directory, 'alias')
await symlink(setup.workspaceDirectory, alias)
assert.deepEqual(await openFileInExistingWorkspace({ ...setup, workspaceDirectory: alias }), { opened: true })
await bridge.dispose()
await bridge.dispose()
await assert.rejects(stat(bridge.socketPath), { code: 'ENOENT' })
assert.deepEqual(await openFileInExistingWorkspace(setup), { opened: false })
})
test('skips stale sockets and selects a deterministic live instance', async t => {
const setup = await fixture(t)
const stale = await rawServer(t, setup, '000', () => {})
const stalePath = join(setup.bridgeDirectory, `${workspaceSocketPrefix(setup.workspaceDirectory)}001.sock`)
await rename(stale.socketPath, stalePath)
await stale.close()
const requests = []
await rawServer(t, setup, '100', (socket, request) => {
requests.push('first')
socket.end(`${JSON.stringify({ requestId: request.requestId, ok: true })}\n`)
})
await rawServer(t, setup, '200', (socket, request) => {
requests.push('second')
socket.end(`${JSON.stringify({ requestId: request.requestId, ok: true })}\n`)
})
assert.deepEqual(await openFileInExistingWorkspace(setup), { opened: true })
assert.deepEqual(requests, ['first'])
})
test('returns unopened when only stale sockets remain', async t => {
const setup = await fixture(t)
const stale = await rawServer(t, setup, '000', () => {})
await rename(stale.socketPath, join(setup.bridgeDirectory, `${workspaceSocketPrefix(setup.workspaceDirectory)}001.sock`))
await stale.close()
assert.deepEqual(await openFileInExistingWorkspace(setup), { opened: false })
})
test('live document failures throw and never fall through to another instance', async t => {
const setup = await fixture(t)
let secondCalled = false
await rawServer(t, setup, '100', (socket, request) => socket.end(`${JSON.stringify({ requestId: request.requestId, ok: false, error: 'Cannot open document' })}\n`))
await rawServer(t, setup, '200', () => { secondCalled = true })
await assert.rejects(openFileInExistingWorkspace(setup), /Cannot open document/)
assert.equal(secondCalled, false)
})
test('rejects invalid acknowledgements and disconnected live editors', async t => {
for (const response of ['invalid\n', '{"requestId":"wrong","ok":true}\n', '']) {
await t.test(JSON.stringify(response), async t => {
const setup = await fixture(t)
await rawServer(t, setup, '100', socket => socket.end(response))
await assert.rejects(openFileInExistingWorkspace(setup), /acknowledg|disconnected/)
})
}
})
test('cancellation after a live connection throws rather than allowing fallback', async t => {
const setup = await fixture(t)
const controller = new AbortController()
await rawServer(t, setup, '100', () => controller.abort(new Error('Cancelled by test')))
await assert.rejects(openFileInExistingWorkspace({ ...setup, signal: controller.signal }), /Cancelled by test/)
})
test('rejects workspace mismatch, changed workspace, relative paths and non-files', async t => {
const setup = await fixture(t)
const bridge = await startBridge(t, setup)
const request = { kind: 'open-file', workspaceDirectory: setup.workspaceDirectory, filePath: setup.filePath, requestId: 'test' }
for (const changes of [{ workspaceDirectory: setup.directory }, { filePath: 'relative.txt' }, { filePath: setup.workspaceDirectory }, { filePath: join(setup.directory, 'missing') }]) {
const response = await rawRequest(bridge.socketPath, `${JSON.stringify({ ...request, ...changes })}\n`)
assert.equal(response.ok, false)
}
setup.vscode.workspace.workspaceFolders = []
await assert.rejects(openFileInExistingWorkspace(setup), /workspace has changed/)
assert.equal(setup.shown.length, 0)
})
test('bounds incoming messages and rejects malformed JSON', async t => {
const setup = await fixture(t)
const bridge = await startBridge(t, setup)
assert.equal((await rawRequest(bridge.socketPath, 'not-json\n')).ok, false)
assert.equal((await rawRequest(bridge.socketPath, `${'x'.repeat(MAX_MESSAGE_BYTES + 1)}\n`)).ok, false)
assert.equal(setup.shown.length, 0)
})
test('rejects insecure bridge directory and overly long socket paths', async t => {
const setup = await fixture(t)
const insecureDirectory = join(setup.directory, 'insecure')
await mkdir(insecureDirectory, { mode: 0o755 })
await assert.rejects(startWorkspaceBridge({ ...setup, bridgeDirectory: insecureDirectory }), /mode 0700/)
const longDirectory = join(setup.directory, 'x'.repeat(80))
await mkdir(longDirectory, { mode: 0o700 })
await assert.rejects(startWorkspaceBridge({ ...setup, bridgeDirectory: longDirectory }), /too long/)
})
test('propagates public VS Code document opening failures', async t => {
const setup = await fixture(t)
setup.vscode.workspace.openTextDocument = async () => { throw new Error('Document provider rejected file') }
await startBridge(t, setup)
await assert.rejects(openFileInExistingWorkspace(setup), /Document provider rejected file/)
assert.equal(setup.shown.length, 0)
})
test('bounds acknowledgements from live clients', async t => {
const setup = await fixture(t)
await rawServer(t, setup, '100', socket => socket.end('x'.repeat(MAX_MESSAGE_BYTES + 1)))
await assert.rejects(openFileInExistingWorkspace(setup), /acknowledgement is too large/)
})
test('accepts fragmented UTF-8 messages and processes only one request per connection', async t => {
const setup = await fixture(t)
const bridge = await startBridge(t, setup)
const request = Buffer.from(`${JSON.stringify({ kind: 'open-file', workspaceDirectory: setup.workspaceDirectory, filePath: setup.filePath, requestId: 'fragmented' })}\n`)
const response = await new Promise((resolve, reject) => {
const socket = net.createConnection(bridge.socketPath)
let buffered = ''
socket.once('error', reject)
socket.once('connect', () => {
const unicodeOffset = request.indexOf(Buffer.from('ü'))
socket.write(request.subarray(0, unicodeOffset + 1), () => {
socket.write(Buffer.concat([request.subarray(unicodeOffset + 1), request]))
})
})
socket.on('data', chunk => { buffered += chunk })
socket.once('end', () => resolve(JSON.parse(buffered)))
})
assert.deepEqual(response, { requestId: 'fragmented', ok: true })
assert.equal(setup.shown.length, 1)
})
test('does not create a bridge for an empty workspace or missing configuration', async t => {
const setup = await fixture(t)
assert.equal(await startWorkspaceBridge({ ...setup, bridgeDirectory: undefined }), null)
setup.vscode.workspace.workspaceFolders = []
assert.equal(await startWorkspaceBridge(setup), null)
})

View file

@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import test from 'node:test'
import vm from 'node:vm'
const source = await readFile(new URL('../editor-preferences/extension.js', import.meta.url), 'utf8')
function preferencesHarness({ initialized = false, defaultsVersion = initialized ? 2 : 0, settings = {} } = {}) {
const updates = []
const commands = []
const stored = new Map([['defaultsVersion', defaultsVersion]])
const configuration = {
inspect: key => ({ globalValue: settings[key] }),
update: async (key, value, target) => { updates.push({ key, value, target }); settings[key] = value },
}
const vscode = {
ConfigurationTarget: { Global: 1 },
workspace: { getConfiguration: () => configuration },
commands: { executeCommand: async command => { commands.push(command) } },
}
const sandbox = { exports: {}, require: name => { assert.equal(name, 'vscode'); return vscode } }
vm.runInNewContext(source, sandbox)
const context = {
globalState: {
get: key => stored.get(key),
update: async (key, value) => { stored.set(key, value) },
},
}
return { activate: () => sandbox.exports.activate(context), updates, commands, settings, stored }
}
test('first activation applies requested trust, theme, and sidebar preferences globally', async () => {
const harness = preferencesHarness({ settings: { 'editor.fontSize': 16 } })
await harness.activate()
assert.deepEqual(harness.settings, {
'editor.fontSize': 16,
'security.workspace.trust.enabled': false,
'workbench.colorTheme': 'Default Dark Modern',
'workbench.secondarySideBar.defaultVisibility': 'hidden',
'editor.fontFamily': "'JetBrains Mono', monospace",
})
assert.equal(harness.updates.every(update => update.target === 1), true)
assert.deepEqual(harness.commands, ['workbench.action.closeAuxiliaryBar'])
assert.equal(harness.stored.get('defaultsVersion'), 2)
})
test('later activation preserves customized preferences while closing the sidebar', async () => {
const harness = preferencesHarness({ initialized: true, settings: {
'security.workspace.trust.enabled': true,
'workbench.colorTheme': 'Other Theme',
'workbench.secondarySideBar.defaultVisibility': 'visible',
'editor.fontFamily': 'Other Font',
} })
await harness.activate()
assert.deepEqual(harness.updates, [])
assert.deepEqual(harness.commands, ['workbench.action.closeAuxiliaryBar'])
})
test('upgrading existing preferences changes only the newly requested font', async () => {
const harness = preferencesHarness({ defaultsVersion: 1, settings: {
'security.workspace.trust.enabled': true,
'workbench.colorTheme': 'Other Theme',
'workbench.secondarySideBar.defaultVisibility': 'visible',
'editor.fontFamily': 'Old Font',
} })
await harness.activate()
assert.deepEqual(harness.updates, [{ key: 'editor.fontFamily', value: "'JetBrains Mono', monospace", target: 1 }])
assert.equal(harness.settings['workbench.colorTheme'], 'Other Theme')
assert.equal(harness.stored.get('defaultsVersion'), 2)
await harness.activate()
assert.equal(harness.updates.length, 1)
})
test('a new browser origin receives missing defaults even when global state already exists', async () => {
const harness = preferencesHarness({ initialized: true })
await harness.activate()
assert.equal(harness.updates.length, 4)
assert.equal(harness.settings['security.workspace.trust.enabled'], false)
assert.equal(harness.settings['workbench.colorTheme'], 'Default Dark Modern')
})

View file

@ -0,0 +1,193 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { createEditorOpenHandler } from '../lib/editor-route.js'
function trackedEntry(overrides = {}) {
return {
ok: true,
id: 'entry-1',
path: '/repo',
repository: '/repo',
workingDirectory: '/repo/work',
...overrides,
}
}
function makeRequest(overrides = {}) {
return {
method: 'POST',
url: '/touched-git/editor/open?sessionId=s1&path=%2Frepo%2Fwork',
headers: { host: '127.0.0.1:3080', 'x-dsh-editor-action': 'open' },
...overrides,
}
}
function makeResponse() {
return {
statusCode: 0,
headers: {},
body: null,
setHeader(name, value) { this.headers[name] = value },
}
}
function writeJson(response, status, body) {
response.statusCode = status
response.body = body
return body
}
function buildHandler(overrides = {}) {
const calls = { resolved: [], opened: [] }
const deps = {
store: { list: async () => [trackedEntry()] },
resolveDirectoryRepository: async (directory) => {
calls.resolved.push(directory)
return { repository: '/repo', workingDirectory: '/repo/work' }
},
isLoopbackRequest: () => true,
editor: {
open: async (directory) => {
calls.opened.push(directory)
return { url: 'http://127.0.0.1:40000/?tkn=x&folder=/repo/work', directory }
},
},
writeJson,
...overrides,
}
return { handler: createEditorOpenHandler(deps), calls }
}
test('a non-POST request is rejected and never opens an editor', async () => {
const { handler, calls } = buildHandler()
const response = makeResponse()
const body = await handler(makeRequest({ method: 'GET' }), response)
assert.equal(response.statusCode, 405)
assert.equal(body.error.code, 'method-not-allowed')
assert.equal(calls.opened.length, 0)
})
test('a cross-site request without a loopback origin is forbidden', async () => {
const { handler, calls } = buildHandler({ isLoopbackRequest: () => false })
const response = makeResponse()
const body = await handler(makeRequest(), response)
assert.equal(response.statusCode, 403)
assert.equal(body.error.code, 'forbidden')
assert.equal(calls.opened.length, 0)
})
test('a request missing the custom editor-action header is forbidden', async () => {
const { handler, calls } = buildHandler()
const response = makeResponse()
const request = makeRequest({ headers: { host: '127.0.0.1:3080' } })
const body = await handler(request, response)
assert.equal(response.statusCode, 403)
assert.equal(body.error.code, 'forbidden')
assert.equal(calls.opened.length, 0)
})
test('a request missing the session or directory is a bad request', async () => {
const { handler, calls } = buildHandler()
const response = makeResponse()
const request = makeRequest({ url: '/touched-git/editor/open?sessionId=s1' })
const body = await handler(request, response)
assert.equal(response.statusCode, 400)
assert.equal(body.error.code, 'invalid-request')
assert.equal(calls.opened.length, 0)
})
test('a directory the session does not track is refused', async () => {
const { handler, calls } = buildHandler({ store: { list: async () => [] } })
const response = makeResponse()
const body = await handler(makeRequest(), response)
assert.equal(response.statusCode, 403)
assert.equal(body.error.code, 'not-tracked')
assert.equal(calls.opened.length, 0)
})
test('an invalid tracked entry is never matched or opened', async () => {
const invalid = trackedEntry({ ok: false, error: { message: 'Directory is unavailable.' } })
const { handler, calls } = buildHandler({ store: { list: async () => [invalid] } })
const response = makeResponse()
const body = await handler(makeRequest(), response)
assert.equal(response.statusCode, 403)
assert.equal(body.error.code, 'not-tracked')
assert.equal(calls.opened.length, 0)
})
test('a relocated repository is refused before opening', async () => {
const { handler, calls } = buildHandler({
resolveDirectoryRepository: async () => ({ repository: '/moved', workingDirectory: '/moved/work' }),
})
const response = makeResponse()
const body = await handler(makeRequest(), response)
assert.equal(response.statusCode, 409)
assert.equal(body.error.code, 'directory-changed')
assert.equal(calls.opened.length, 0)
})
test('a directory deleted since tracking fails resolution and never opens', async () => {
const { handler, calls } = buildHandler({
resolveDirectoryRepository: async () => { throw new Error('ENOENT: no such file or directory') },
})
const response = makeResponse()
const body = await handler(makeRequest(), response)
assert.equal(response.statusCode, 503)
assert.equal(body.error.code, 'editor-unavailable')
assert.equal(body.error.message, 'ENOENT: no such file or directory')
assert.equal(calls.opened.length, 0)
})
test('a tracked directory opens its resolved working directory only', async () => {
const { handler, calls } = buildHandler()
const response = makeResponse()
const body = await handler(makeRequest(), response)
assert.equal(response.statusCode, 200)
assert.equal(body.ok, true)
assert.equal(body.value.directory, '/repo/work')
assert.deepEqual(calls.resolved, ['/repo/work'])
assert.deepEqual(calls.opened, ['/repo/work'])
assert.equal(response.headers['cache-control'], 'no-store')
})
test('an entry matched by its id still opens the resolved working directory', async () => {
const { handler, calls } = buildHandler()
const response = makeResponse()
const request = makeRequest({ url: '/touched-git/editor/open?sessionId=s1&path=entry-1' })
const body = await handler(request, response)
assert.equal(response.statusCode, 200)
assert.deepEqual(calls.opened, ['/repo/work'])
})
test('an editor failure is reported as an unavailable editor', async () => {
const { handler } = buildHandler({
editor: { open: async () => { throw new Error('runtime is not installed') } },
})
const response = makeResponse()
const body = await handler(makeRequest(), response)
assert.equal(response.statusCode, 503)
assert.equal(body.error.code, 'editor-unavailable')
assert.equal(body.error.message, 'runtime is not installed')
})

View file

@ -0,0 +1,178 @@
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import test from 'node:test'
import { EditorWorkspaces, waitForEditorReady } from '../lib/editor-runtime.js'
function deferred() {
let resolve
let reject
const promise = new Promise((accept, fail) => { resolve = accept; reject = fail })
return { promise, resolve, reject }
}
function fakeRuntime() {
const closure = deferred()
return {
url: 'http://127.0.0.1:40000',
token: 'test-token',
closed: closure.promise,
stop: async () => { closure.resolve() },
finish: closure.resolve,
}
}
test('constructing and disposing an unused launcher never starts a runtime', async () => {
let starts = 0
const editor = new EditorWorkspaces(async () => { starts++; return fakeRuntime() })
assert.equal(starts, 0)
await editor.dispose()
assert.equal(starts, 0)
await assert.rejects(editor.open('/repo'), /stopped/)
})
test('concurrent workspace opens share one startup and preserve exact folder paths', async () => {
let starts = 0
const startup = deferred()
const editor = new EditorWorkspaces(() => { starts++; return startup.promise })
const first = editor.open('/repo with spaces/#feature?&')
const second = editor.open('/another/repo')
assert.equal(starts, 1)
startup.resolve(fakeRuntime())
const results = await Promise.all([first, second])
assert.equal(new URL(results[0].url).searchParams.get('folder'), '/repo with spaces/#feature?&')
assert.equal(new URL(results[1].url).searchParams.get('folder'), '/another/repo')
assert.equal(new URL(results[0].url).searchParams.get('tkn'), 'test-token')
await editor.open('/third')
assert.equal(starts, 1)
await editor.dispose()
})
test('failed startup does not retry until another explicit open', async () => {
let starts = 0
const editor = new EditorWorkspaces(async () => {
starts++
if (starts === 1) throw new Error('startup failed')
return fakeRuntime()
})
await assert.rejects(editor.open('/repo'), /startup failed/)
assert.equal(starts, 1)
await editor.open('/repo')
assert.equal(starts, 2)
await editor.dispose()
})
test('server exit clears its cached handle but does not restart automatically', async () => {
let starts = 0
const runtimes = []
const editor = new EditorWorkspaces(async () => {
starts++
const runtime = fakeRuntime()
runtimes.push(runtime)
return runtime
})
await editor.open('/repo')
runtimes[0].finish()
await runtimes[0].closed
assert.equal(starts, 1)
await editor.open('/repo')
assert.equal(starts, 2)
await editor.dispose()
})
test('disposal during startup shuts down without returning an editor URL', async () => {
const startup = deferred()
const runtime = fakeRuntime()
const editor = new EditorWorkspaces(() => startup.promise)
const opening = editor.open('/repo')
const disposing = editor.dispose()
startup.resolve(runtime)
await assert.rejects(opening, /stopped/)
await disposing
await runtime.closed
})
test('first file open carries a correctly encoded remote URI only in the new-tab URL', async () => {
const editor = new EditorWorkspaces(async () => fakeRuntime(), async () => ({ opened: false }))
const filePath = '/repo/file #?%雪.txt'
const result = await editor.openFile('/repo', filePath)
const url = new URL(result.url)
const payload = JSON.parse(url.searchParams.get('payload'))
assert.equal(result.delivery, 'new')
assert.equal(url.searchParams.get('folder'), '/repo')
assert.equal(payload[0][0], 'openFile')
const remoteFile = new URL(payload[0][1])
assert.equal(remoteFile.protocol, 'vscode-remote:')
assert.equal(remoteFile.host, url.host)
assert.equal(decodeURIComponent(remoteFile.pathname), filePath)
await editor.dispose()
})
test('file delivery to an existing workspace never returns a navigation URL', async () => {
let starts = 0
const received = []
const editor = new EditorWorkspaces(async () => { starts++; return fakeRuntime() }, async request => { received.push(request); return { opened: true } })
await editor.open('/repo')
const result = await editor.openFile('/repo', '/repo/file.txt', { requireExisting: true })
assert.deepEqual(result, { delivery: 'existing', directory: '/repo' })
assert.equal(starts, 1)
assert.equal(received[0].filePath, '/repo/file.txt')
await editor.dispose()
})
test('an unavailable remembered workspace never falls back to a replacement tab', async () => {
let starts = 0
const editor = new EditorWorkspaces(async () => { starts++; return fakeRuntime() }, async () => ({ opened: false }))
await assert.rejects(editor.openFile('/repo', '/repo/file.txt', { requireExisting: true }), /not connected/)
assert.equal(starts, 0)
await editor.open('/repo')
await assert.rejects(editor.openFile('/repo', '/repo/file.txt', { requireExisting: true }), /not ready/)
assert.equal(starts, 1)
await editor.dispose()
})
test('live bridge rejection is surfaced without new-tab fallback', async () => {
const editor = new EditorWorkspaces(async () => fakeRuntime(), async () => { throw new Error('document unavailable') })
await assert.rejects(editor.openFile('/repo', '/repo/file.txt'), /document unavailable/)
await editor.dispose()
})
function fakeChild() {
const child = new EventEmitter()
child.stdout = new EventEmitter()
child.stderr = new EventEmitter()
return child
}
test('readiness requires complete loopback Web UI announcement and strips token', async () => {
const child = fakeChild()
const ready = waitForEditorReady(child)
child.stdout.emit('data', 'Extension host agent listening on 40000\nWeb UI avail')
child.stdout.emit('data', 'able at http://127.0.0.1:40')
child.stdout.emit('data', '000/?tkn=secret\n')
assert.equal(await ready, 'http://127.0.0.1:40000')
assert.equal(child.listenerCount('exit'), 0)
assert.equal(child.stdout.listenerCount('data'), 0)
})
test('installed runtime localhost readiness format is normalized to IPv4 loopback', async () => {
const child = fakeChild()
const ready = waitForEditorReady(child)
child.stdout.emit('data', 'Web UI available at http://localhost:40000?tkn=secret\n')
assert.equal(await ready, 'http://127.0.0.1:40000')
})
test('readiness rejects an early exit and removes listeners', async () => {
const child = fakeChild()
const ready = waitForEditorReady(child)
child.emit('exit', 1)
await assert.rejects(ready, /exit 1/)
assert.equal(child.stdout.listenerCount('data'), 0)
})
test('readiness rejects a spawn error without including process output', async () => {
const child = fakeChild()
const ready = waitForEditorReady(child)
child.stderr.emit('data', 'sensitive startup output')
child.emit('error', new Error('internal detail'))
await assert.rejects(ready, { message: 'Unable to execute the installed VS Code runtime.' })
})

View file

@ -0,0 +1,415 @@
import assert from 'node:assert/strict'
import { execFile } from 'node:child_process'
import { mkdtemp, mkdir, realpath, rm, symlink } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { promisify } from 'node:util'
import test from 'node:test'
import { registerGitWriteGuard } from '../lib/git-write-guard.js'
const execute = promisify(execFile)
async function workspace(t) {
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-git-write-guard-')))
t.after(() => rm(root, { recursive: true, force: true }))
const repository = join(root, 'repository')
await mkdir(repository)
await execute('git', ['init', '--quiet', repository])
return { root, repository }
}
function createStore() {
const entries = new Map()
return {
register(sessionId, repository) {
entries.set(`${sessionId}:${repository}`, { ok: true, repository })
},
unregister(sessionId, repository) {
entries.delete(`${sessionId}:${repository}`)
},
async list(sessionId) {
return [...entries].filter(([key]) => key.startsWith(`${sessionId}:`)).map(([, value]) => value)
},
isRegistered(sessionId, repository) {
return entries.has(`${sessionId}:${repository}`)
},
}
}
function createHarness({ store = createStore(), onSuccessfulMutation } = {}) {
const hooks = new Map()
const guards = []
const resolved = []
const context = {
fs: {
async resolve(filePath, options) {
resolved.push({ filePath, options })
return { path: resolve(options.cwd, filePath) }
},
async processPath(target) {
return target.path
},
},
on(name, callback, prepend = false) {
const listeners = hooks.get(name) ?? []
hooks.set(name, listeners)
if (prepend === true || prepend.prepend) listeners.unshift(callback)
else listeners.push(callback)
return () => {
const index = listeners.indexOf(callback)
if (index >= 0) listeners.splice(index, 1)
}
},
tools: {
guard(callback) {
guards.push(callback)
return () => {
const index = guards.indexOf(callback)
if (index >= 0) guards.splice(index, 1)
}
},
},
}
const dispose = registerGitWriteGuard(context, { store, onSuccessfulMutation })
function waterfall(name, args, final) {
const callbacks = [...hooks.get(name) ?? []]
const next = () => (callbacks.shift() ?? final)(...args, next)
return next()
}
function denial(execution) {
return guards.map(guard => guard(execution)).find(reason => reason !== undefined)
}
function result(execution, outcome) {
for (const callback of hooks.get('tools/result') ?? []) callback(execution, outcome)
}
return {
context, store, hooks, guards, resolved, dispose, denial, result,
pre: (execution, decision = { kind: 'allow' }) => waterfall('tools/pre-execute', [execution], () => decision),
intent: (name, target, execution, final = () => undefined) => waterfall(name, [target, execution], final),
post: (execution, outcome, decision = { kind: 'accept' }) => waterfall('tools/post-execute', [execution, outcome], () => decision),
async dispatch(execution, body) {
try {
const decision = await this.pre(execution)
const reason = decision.kind === 'deny' ? decision.reason : denial(execution)
if (reason !== undefined) return { isError: true, error: { message: reason }, content: [{ type: 'text', text: reason }] }
return await body()
} catch (error) {
return { isError: true, error: { message: error.message }, content: [{ type: 'text', text: error.message }] }
}
},
}
}
function execution(cwd, name = 'write', filePath = 'new.txt') {
return {
token: Symbol(),
callId: 'call-1',
name,
arguments: { file_path: filePath },
signal: new AbortController().signal,
agent: { session: { header: { id: 'session-1', cwd } } },
}
}
function success() {
return { isError: false, value: { path: 'new.txt', before: null, after: 'content' }, content: [{ type: 'text', text: 'Saved' }] }
}
test('parent traversal uses the physical session cwd like the filesystem tools', async t => {
const { root, repository } = await workspace(t)
const inside = join(repository, 'inside')
const aliases = join(root, 'aliases')
await mkdir(inside)
await mkdir(aliases)
const alias = join(aliases, 'cwd')
await symlink(inside, alias)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
const call = execution(alias, 'write', '../new.txt')
await harness.pre(call)
assert.equal(harness.denial(call), undefined)
assert.equal(harness.resolved[0].options.cwd, inside)
})
for (const name of ['write', 'edit']) {
test(`${name} blocks an unregistered repository before dispatch`, async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
let calls = 0
const outcome = await harness.dispatch(execution(repository, name), () => { calls++; return success() })
assert.equal(calls, 0)
assert.equal(outcome.isError, true)
assert.match(outcome.error.message, /not registered/)
})
test(`${name} allows a registered current-session repository`, async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
let calls = 0
const outcome = await harness.dispatch(execution(repository, name), () => { calls++; return success() })
assert.equal(calls, 1)
assert.equal(outcome.isError, false)
})
test(`${name} outside Git appends a warning and awaits successful reporting`, async t => {
const { root } = await workspace(t)
let release
const persisted = new Promise(resolve => { release = resolve })
let mutation
let reportStarted
const started = new Promise(resolve => { reportStarted = resolve })
const harness = createHarness({ onSuccessfulMutation: async value => { mutation = value; reportStarted(); await persisted } })
t.after(harness.dispose)
const call = execution(root, name, ' file with spaces ')
const outcome = await harness.dispatch(call, success)
const promise = harness.post(call, outcome)
await started
assert.deepEqual(harness.resolved[0], { filePath: ' file with spaces ', options: { cwd: root, signal: call.signal } })
assert.equal(mutation.filePath, join(root, ' file with spaces '))
assert.equal(mutation.repository, null)
assert.equal(mutation.sessionId, 'session-1')
assert.equal(mutation.toolName, name)
release()
const decision = await promise
assert.equal(decision.kind, 'accept')
assert.equal(Object.hasOwn(decision, 'value'), false)
assert.equal(decision.content[0], outcome.content[0])
assert.match(decision.content.at(-1).text, /WARNING:.*outside a Git repository/)
})
test(`${name} intent checks the actual target and preserves downstream intent`, async t => {
const { root, repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
const call = execution(repository, name)
await harness.pre(call)
const expected = { version: 'observed-version' }
let observationCalls = 0
const intentName = `fs/${name}-intent`
harness.context.on(intentName, () => { observationCalls++; return expected })
assert.equal(await harness.intent(intentName, { path: join(repository, 'new.txt') }, call), expected)
assert.equal(observationCalls, 1)
const nested = join(repository, 'nested')
await mkdir(nested)
await execute('git', ['init', '--quiet', nested])
await assert.rejects(harness.intent(intentName, { path: join(nested, 'new.txt') }, call), /not registered/)
assert.equal(observationCalls, 1)
assert.notEqual(root, repository)
})
}
test('missing pre-execution certificate blocks even if another hook grants permission', async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
harness.context.on('tools/pre-execute', () => ({ kind: 'allow' }), true)
let calls = 0
const outcome = await harness.dispatch(execution(repository), () => { calls++; return success() })
assert.equal(calls, 0)
assert.match(outcome.error.message, /did not complete/)
})
test('other tools pass through without filesystem resolution or reporting', async t => {
let reports = 0
const harness = createHarness({ onSuccessfulMutation: () => { reports++ } })
t.after(harness.dispose)
const call = execution('/unused', 'bash')
const decision = { kind: 'ask', reason: 'Existing policy' }
assert.equal(await harness.pre(call, decision), decision)
assert.equal(harness.denial(call), undefined)
const intent = { version: 'untouched' }
assert.equal(await harness.intent('fs/write-intent', {}, call, () => intent), intent)
assert.deepEqual(await harness.post(call, success()), { kind: 'accept' })
assert.equal(harness.resolved.length, 0)
assert.equal(reports, 0)
})
test('pre-execution preserves downstream deny and ask decisions', async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
const call = execution(repository)
const denied = { kind: 'deny', reason: 'Other policy refused' }
assert.equal(await harness.pre(call, denied), denied)
assert.equal(harness.resolved.length, 0)
const ask = { kind: 'ask', reason: 'Approval required' }
assert.equal(await harness.pre(call, ask), ask)
assert.equal(harness.denial(call), undefined)
})
test('last-moment unregistration and session identity changes invalidate certificates', async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
const call = execution(repository)
await harness.pre(call)
harness.store.unregister('session-1', repository)
assert.match(harness.denial(call), /not registered/)
harness.store.register('session-1', repository)
call.agent.session.header.id = 'session-2'
assert.match(harness.denial(call), /session or working directory changed/)
call.agent.session.header.id = 'session-1'
call.agent.session.header.cwd = '/other'
assert.match(harness.denial(call), /session or working directory changed/)
})
test('registration in another session never authorizes a mutation', async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('parent-session', repository)
const call = execution(repository)
await harness.pre(call)
assert.match(harness.denial(call), /not registered/)
})
test('nested repository and physical symlink target require their own registration', async t => {
const { root, repository } = await workspace(t)
const nested = join(repository, 'nested')
await mkdir(nested)
await execute('git', ['init', '--quiet', nested])
const alias = join(root, 'alias')
await symlink(nested, alias)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
const call = execution(root, 'write', join(alias, 'missing', 'new.txt'))
await harness.pre(call)
assert.match(harness.denial(call), /not registered/)
harness.store.register('session-1', nested)
await harness.pre(call)
assert.equal(harness.denial(call), undefined)
})
test('cancellation prevents dispatch and reaches filesystem resolution unchanged', async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
const controller = new AbortController()
const call = execution(repository)
call.signal = controller.signal
controller.abort(new Error('Cancelled by caller'))
let calls = 0
const outcome = await harness.dispatch(call, () => { calls++; return success() })
assert.equal(outcome.isError, true)
assert.match(outcome.error.message, /Cancelled by caller/)
assert.equal(calls, 0)
assert.equal(harness.resolved.length, 0)
})
test('cancellation during classification prevents a certificate from allowing dispatch', async t => {
const { root } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
const controller = new AbortController()
const call = execution(root)
call.signal = controller.signal
harness.context.fs.processPath = async target => { controller.abort(new Error('Cancelled during resolution')); return target.path }
await assert.rejects(harness.pre(call), /Cancelled during resolution/)
assert.notEqual(harness.denial(call), undefined)
})
test('intent rechecks registration after downstream observation settles', async t => {
const { repository } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
harness.store.register('session-1', repository)
const call = execution(repository)
await harness.pre(call)
await assert.rejects(harness.intent('fs/write-intent', { path: join(repository, 'new.txt') }, call, () => {
harness.store.unregister('session-1', repository)
return { createIfAbsent: true }
}), /not registered/)
})
test('normal tool failures retain failure content and never report success', async t => {
const { root } = await workspace(t)
let reports = 0
const harness = createHarness({ onSuccessfulMutation: () => { reports++ } })
t.after(harness.dispose)
const call = execution(root)
await harness.pre(call)
const failure = { isError: true, error: { message: 'Literal not found' }, content: [{ type: 'text', text: 'Literal not found' }] }
const decision = await harness.post(call, failure)
assert.equal(decision.content[0], failure.content[0])
assert.equal(Object.hasOwn(decision, 'value'), false)
assert.equal(failure.isError, true)
assert.equal(reports, 0)
})
test('persistence failure warns without rejecting a completed mutation', async t => {
const { repository } = await workspace(t)
const harness = createHarness({ onSuccessfulMutation: async () => { throw new Error('disk unavailable') } })
t.after(harness.dispose)
harness.store.register('session-1', repository)
const call = execution(repository)
await harness.pre(call)
const decision = await harness.post(call, success())
assert.equal(decision.kind, 'accept')
assert.match(decision.content.at(-1).text, /operation completed.*could not be saved: disk unavailable/)
})
test('outside warning preserves downstream blocks and replacement values', async t => {
const { root } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
const call = execution(root)
await harness.pre(call)
const block = { kind: 'block', feedback: [{ type: 'text', text: 'Other post policy' }], additionalContexts: [] }
const blocked = await harness.post(call, success(), block)
assert.equal(blocked.kind, 'block')
assert.equal(blocked.feedback, block.feedback)
assert.equal(blocked.additionalContexts[0].role, 'user')
assert.match(blocked.additionalContexts[0].content[0].text, /WARNING/)
const replacement = { kind: 'accept', value: { path: 'replacement' } }
const replaced = await harness.post(call, success(), replacement)
assert.equal(replaced.value, replacement.value)
assert.equal(Object.hasOwn(replaced, 'content'), false)
assert.equal(replaced.additionalContexts.length, 1)
})
test('post content replacement and Code Mode warnings preserve existing contexts', async t => {
const { root } = await workspace(t)
const harness = createHarness()
t.after(harness.dispose)
const call = execution(root)
call.parent = Symbol('code-call')
await harness.pre(call)
const existingContext = { id: 'existing', role: 'user', source: { kind: 'plugin', plugin: 'test' }, content: [] }
const decision = await harness.post(call, success(), { kind: 'accept', content: [{ type: 'text', text: 'Replacement' }], additionalContexts: [existingContext] })
assert.equal(decision.content[0].text, 'Replacement')
assert.equal(decision.additionalContexts[0], existingContext)
assert.equal(decision.additionalContexts[1].source.plugin, 'dsh-touched-git')
})
test('tools/result and disposal clear certificates and unregister every hook', async t => {
const { repository } = await workspace(t)
const harness = createHarness()
harness.store.register('session-1', repository)
const call = execution(repository)
await harness.pre(call)
assert.equal(harness.denial(call), undefined)
harness.result(call, success())
assert.match(harness.denial(call), /did not complete/)
await harness.pre(call)
const guard = harness.guards[0]
harness.dispose()
harness.dispose()
assert.match(guard(call), /did not complete/)
assert.equal(harness.guards.length, 0)
assert.ok([...harness.hooks.values()].every(callbacks => callbacks.length === 0))
})

View file

@ -0,0 +1,250 @@
import assert from 'node:assert/strict'
import { execFile } from 'node:child_process'
import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { promisify } from 'node:util'
import test from 'node:test'
import { TouchedGitStore } from '../lib/index.js'
import { registerGitWriteGuard } from '../lib/git-write-guard.js'
const installedRuntime = createRequire(process.env.DSH_RUNTIME_PACKAGE_JSON || join(homedir(), '.local/lib/node_modules/@deepseek-ai/dsh/package.json'))
const { Context } = await import(installedRuntime.resolve('@deepseek-ai/cordis'))
const { SystemPrompt } = await import(installedRuntime.resolve('@deepseek-ai/dsh-system-prompt'))
const { ToolRuntime, defineTool } = await import(installedRuntime.resolve('@deepseek-ai/dsh-tools'))
const { LocalFileSystem } = await import(installedRuntime.resolve('@deepseek-ai/dsh-fs-local'))
const { apply: applyObservationPolicy } = await import(installedRuntime.resolve('@deepseek-ai/dsh-fs-observation-policy'))
const execute = promisify(execFile)
const mutationOutput = {
type: 'object',
additionalProperties: false,
properties: {
path: { type: 'string', required: true },
before: { required: true, oneOf: [{ type: 'string' }, { type: 'null' }] },
after: { type: 'string', required: true },
},
}
function registerFixtureMutation(context, name, counters) {
const parameters = name === 'write'
? { content: { type: 'string', required: true } }
: { old_string: { type: 'string', required: true }, new_string: { type: 'string', required: true } }
context.tools.register(defineTool({
name,
description: `Integration fixture ${name} using the real filesystem provider.`,
parameters: { file_path: { type: 'string', required: true }, ...parameters },
output: {
schema: mutationOutput,
render: (_args, value) => [{ type: 'text', text: `${name} completed: ${value.path}` }],
},
async execute(args, execution) {
counters.bodies[name]++
const target = await context.fs.resolve(args.file_path, { cwd: execution.agent.session.header.cwd, signal: execution.signal })
const intent = await context.waterfall(`fs/${name}-intent`, target, execution, () => undefined)
const outcome = name === 'write'
? await context.fs.writeText(target, args.content, intent, execution.signal)
: await context.fs.editText(target, { oldString: args.old_string, newString: args.new_string, replaceAll: false }, intent, execution.signal)
counters.mutations[name]++
context.emit('fs/observed', target, { kind: 'present', version: outcome.version }, execution)
return { path: target.displayPath, before: outcome.before, after: outcome.after }
},
}))
}
function registerFixtureRead(context) {
context.tools.register(defineTool({
name: 'read',
description: 'Integration fixture read that records the provider observation.',
parameters: { file_path: { type: 'string', required: true } },
output: {
schema: {
type: 'object', additionalProperties: false,
properties: { text: { type: 'string', required: true } },
},
render: (_args, value) => [{ type: 'text', text: value.text }],
},
async execute(args, execution) {
const target = await context.fs.resolve(args.file_path, { cwd: execution.agent.session.header.cwd, signal: execution.signal })
const text = await context.fs.readText(target, execution.signal)
const info = await context.fs.stat(target, execution.signal)
context.emit('fs/observed', target, { kind: 'present', version: info.version }, execution)
return { text }
},
}))
}
async function fixture(t, options = {}) {
const root = await realpath(await mkdtemp(join(tmpdir(), 'dsh-git-runtime-')))
const context = new Context()
let disposeGuard
t.after(async () => {
disposeGuard?.()
await context.fiber.dispose()
await rm(root, { recursive: true, force: true })
})
const repository = join(root, 'repository')
await mkdir(repository)
await execute('git', ['init', '--quiet', repository])
const store = new TouchedGitStore(join(root, 'store.json'))
await store.ready
new SystemPrompt(context, { includeHarnessIdentity: false })
new ToolRuntime(context)
new LocalFileSystem(context, { cwd: root, diffBasisMaxBytes: 1024 * 1024 })
applyObservationPolicy(context)
const counters = { bodies: { write: 0, edit: 0 }, mutations: { write: 0, edit: 0 } }
registerFixtureMutation(context, 'write', counters)
registerFixtureMutation(context, 'edit', counters)
registerFixtureRead(context)
const recorded = []
disposeGuard = registerGitWriteGuard(context, {
store,
onSuccessfulMutation: options.onSuccessfulMutation ?? (async mutation => { recorded.push(mutation) }),
})
const agent = { session: { header: { id: 'integration-session', cwd: repository } } }
let callNumber = 0
return {
root, repository, context, store, counters, recorded, agent,
async register() {
await store.upsert(agent.session.header.id, { id: repository, path: repository, workingDirectory: repository, repository, ok: true })
},
run(name, args, extra = {}) {
return context.tools.execute({ name, arguments: args, callId: `integration-call-${++callNumber}`, agent, signal: new AbortController().signal, ...extra })
},
}
}
function mutationArgs(name, filePath) {
return name === 'write'
? { file_path: filePath, content: 'updated content' }
: { file_path: filePath, old_string: 'original', new_string: 'updated' }
}
function resultText(result) {
return result.content.filter(block => block.type === 'text').map(block => block.text).join('\n')
}
for (const name of ['write', 'edit']) {
test(`actual registry denies unregistered ${name} before entering its body or changing the file`, async t => {
const world = await fixture(t)
const target = join(world.repository, 'existing.txt')
await writeFile(target, 'original content')
const result = await world.run(name, mutationArgs(name, target))
assert.equal(result.isError, true)
assert.match(result.error.message, /not registered/)
assert.equal(Object.hasOwn(result, 'value'), false)
assert.equal(world.counters.bodies[name], 0)
assert.equal(world.counters.mutations[name], 0)
assert.equal(await readFile(target, 'utf8'), 'original content')
assert.equal(world.recorded.length, 0)
assert.ok(Object.isFrozen(result))
})
test(`actual registry accepts registered observed ${name} and publishes a canonical successful result`, async t => {
const world = await fixture(t)
await world.register()
const target = join(world.repository, 'existing.txt')
await writeFile(target, 'original content')
const observed = await world.run('read', { file_path: target })
assert.equal(observed.isError, false)
const result = await world.run(name, mutationArgs(name, target))
assert.equal(result.isError, false)
assert.equal(await readFile(target, 'utf8'), 'updated content')
assert.equal(world.counters.bodies[name], 1)
assert.equal(world.counters.mutations[name], 1)
assert.deepEqual(result.value, { path: target, before: 'original content', after: 'updated content' })
assert.ok(Object.isFrozen(result.value))
assert.equal(world.recorded.length, 1)
assert.equal(world.recorded[0].repository, world.repository)
assert.equal(world.recorded[0].toolName, name)
assert.equal(world.recorded[0].sessionId, world.agent.session.header.id)
})
test(`actual registry preserves outside-Git ${name} success and appends a warning without altering output schema`, async t => {
const world = await fixture(t)
const target = join(world.root, 'outside.txt')
await writeFile(target, 'original content')
await world.run('read', { file_path: target })
const result = await world.run(name, mutationArgs(name, target))
assert.equal(result.isError, false)
assert.equal(await readFile(target, 'utf8'), 'updated content')
assert.equal(world.counters.mutations[name], 1)
assert.match(resultText(result), /WARNING:.*outside a Git repository/)
assert.deepEqual(Object.keys(result.value).sort(), ['after', 'before', 'path'])
assert.equal(world.recorded[0].repository, null)
assert.match(world.recorded[0].warning, /outside a Git repository/)
})
test(`actual monotonic guard blocks ${name} when an outer pre listener skips classification`, async t => {
const world = await fixture(t)
await world.register()
const target = join(world.repository, 'existing.txt')
await writeFile(target, 'original content')
await world.run('read', { file_path: target })
world.context.on('tools/pre-execute', async (execution, next) => execution.name === name ? { kind: 'allow' } : next(), true)
const result = await world.run(name, mutationArgs(name, target))
assert.equal(result.isError, true)
assert.match(result.error.message, /pre-execution check did not complete/)
assert.equal(world.counters.bodies[name], 0)
assert.equal(world.counters.mutations[name], 0)
assert.equal(await readFile(target, 'utf8'), 'original content')
})
test(`real observation policy still rejects unread ${name} through the prepended Git intent hook`, async t => {
const world = await fixture(t)
await world.register()
const target = join(world.repository, 'existing.txt')
await writeFile(target, 'original content')
const unread = await world.run(name, mutationArgs(name, target))
assert.equal(unread.isError, true)
assert.equal(unread.error.info.code, 'FS_NOT_OBSERVED')
assert.equal(world.counters.bodies[name], 1)
assert.equal(world.counters.mutations[name], 0)
assert.equal(world.recorded.length, 0)
assert.equal(await readFile(target, 'utf8'), 'original content')
await world.run('read', { file_path: target })
const observed = await world.run(name, mutationArgs(name, target))
assert.equal(observed.isError, false)
assert.equal(world.counters.mutations[name], 1)
assert.equal(await readFile(target, 'utf8'), 'updated content')
})
}
test('actual registry creates a new registered file without requiring a prior read', async t => {
const world = await fixture(t)
await world.register()
const target = join(world.repository, 'new-directory', 'new.txt')
const result = await world.run('write', mutationArgs('write', target))
assert.equal(result.isError, false)
assert.equal(result.value.before, null)
assert.equal(await readFile(target, 'utf8'), 'updated content')
})
test('actual registry materializes warning contexts when downstream post policy replaces the canonical value', async t => {
const world = await fixture(t)
const target = join(world.root, 'outside.txt')
world.context.on('tools/post-execute', async (execution, result, next) => execution.name === 'write' && !result.isError
? { kind: 'accept', value: { ...result.value, after: 'policy-projected value' } }
: next())
const result = await world.run('write', mutationArgs('write', target))
assert.equal(result.isError, false)
assert.equal(result.value.after, 'policy-projected value')
assert.equal(await readFile(target, 'utf8'), 'updated content')
assert.equal(result.additionalContexts.length, 1)
assert.equal(result.additionalContexts[0].role, 'user')
assert.equal(result.additionalContexts[0].source.plugin, 'dsh-touched-git')
assert.match(result.additionalContexts[0].content[0].text, /outside a Git repository/)
assert.ok(Object.isFrozen(result.additionalContexts[0]))
})
test('actual successful mutation is not falsely rejected when persistence callback fails', async t => {
const world = await fixture(t, { onSuccessfulMutation: async () => { throw new Error('fixture persistence failure') } })
await world.register()
const target = join(world.repository, 'new.txt')
const result = await world.run('write', mutationArgs('write', target))
assert.equal(result.isError, false)
assert.equal(await readFile(target, 'utf8'), 'updated content')
assert.match(resultText(result), /operation completed.*could not be saved: fixture persistence failure/)
})

View file

@ -0,0 +1,128 @@
import assert from 'node:assert/strict'
import { execFile } from 'node:child_process'
import { lstat, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import test from 'node:test'
import { promisify } from 'node:util'
import { canonicalWriteTarget, checkGitWriteScope } from '../lib/git-write-scope.js'
const execute = promisify(execFile)
async function git(directory, ...args) {
return execute('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'commit.gpgsign=false', '-c', 'user.name=Scope Test', '-c', 'user.email=scope@example.invalid', '-C', directory, ...args])
}
async function fixture(context) {
const directory = await mkdtemp(join(tmpdir(), 'dsh-git-scope-'))
context.after(() => rm(directory, { recursive: true, force: true }))
return directory
}
async function repository(directory) {
await mkdir(directory, { recursive: true })
await git(directory, 'init', '--initial-branch=main')
return directory
}
function storeFor(...repositories) {
return { list: async sessionId => sessionId === 'registered-session' ? repositories.map(repository => ({ ok: true, repository })) : [] }
}
function check(filePath, store, overrides = {}) {
return checkGitWriteScope({ filePath, store, sessionId: 'registered-session', ...overrides })
}
test('outside-Git new nested files warn without creating anything', async context => {
const root = await fixture(context)
const target = join(root, 'new', 'deep', 'file.txt')
const result = await check(target, storeFor())
assert.equal(result.repository, null)
assert.match(result.warning, /outside a Git repository/)
await assert.rejects(lstat(join(root, 'new')), { code: 'ENOENT' })
})
test('registered repository permits new files while another session is blocked', async context => {
const root = await repository(join(await fixture(context), 'repo'))
const target = join(root, 'new', 'file.txt')
const store = storeFor(root)
assert.equal((await check(target, store)).warning, null)
await assert.rejects(check(target, store, { sessionId: 'other-session' }), { code: 'unregistered-git-write' })
})
test('unregistered and invalid tracked entries fail closed', async context => {
const root = await repository(join(await fixture(context), 'repo'))
await assert.rejects(check(join(root, 'file.txt'), storeFor()), /add_touched_git_directory/)
const store = { list: async () => [{ repository: root, ok: false }] }
await assert.rejects(check(join(root, 'file.txt'), store), { code: 'unregistered-git-write' })
})
test('nested repositories are not authorized by their parent registration', async context => {
const outer = await repository(join(await fixture(context), 'outer'))
const nested = await repository(join(outer, 'nested'))
await assert.rejects(check(join(nested, 'file.txt'), storeFor(outer)), { code: 'unregistered-git-write' })
assert.equal((await check(join(nested, 'file.txt'), storeFor(nested))).repository, nested)
})
test('linked worktrees require their own registration', async context => {
const root = await fixture(context)
const main = await repository(join(root, 'main'))
const worktree = join(root, 'linked')
await git(main, 'commit', '--allow-empty', '-m', 'Fixture')
await git(main, 'worktree', 'add', '-b', 'linked', worktree)
await assert.rejects(check(join(worktree, 'file.txt'), storeFor(main)), { code: 'unregistered-git-write' })
assert.equal((await check(join(worktree, 'file.txt'), storeFor(worktree))).repository, worktree)
})
test('directory symlinks are checked against the physical repository', async context => {
const root = await fixture(context)
const registered = await repository(join(root, 'registered'))
const external = await repository(join(root, 'external'))
await symlink(external, join(registered, 'linked'))
await assert.rejects(check(join(registered, 'linked', 'file.txt'), storeFor(registered)), { code: 'unregistered-git-write' })
const result = await check(join(registered, 'linked', 'file.txt'), storeFor(external))
assert.equal(result.filePath, join(external, 'file.txt'))
})
test('dangling file symlinks and parent traversal cannot disguise another repository', async context => {
const root = await fixture(context)
const registered = await repository(join(root, 'registered'))
const external = await repository(join(root, 'external'))
await mkdir(join(external, 'inside'))
await symlink(join(external, 'inside'), join(registered, 'trampoline'))
await symlink('trampoline/../new.txt', join(registered, 'file.txt'))
assert.equal(await canonicalWriteTarget(join(registered, 'file.txt')), join(external, 'new.txt'))
await assert.rejects(check(join(registered, 'file.txt'), storeFor(registered)), { code: 'unregistered-git-write' })
})
test('symlink loops and broken Git metadata are errors rather than non-Git warnings', async context => {
const root = await fixture(context)
await symlink('loop', join(root, 'loop'))
await assert.rejects(check(join(root, 'loop'), storeFor()), { code: 'symlink-loop' })
await writeFile(join(root, '.git'), 'gitdir: /this-git-directory-does-not-exist\n')
await assert.rejects(check(join(root, 'file.txt'), storeFor()), { code: 'git-scope-unavailable' })
})
test('empty Git metadata is not mistaken for an ordinary non-Git directory', async context => {
const root = await fixture(context)
await mkdir(join(root, '.git'))
await assert.rejects(check(join(root, 'file.txt'), storeFor()), { code: 'git-scope-unavailable' })
})
test('repository names containing spaces preserve exact scope identity', async context => {
const root = await repository(join(await fixture(context), 'repo with spaces '))
assert.equal((await check(join(root, 'new file.txt'), storeFor(root))).repository, root)
})
test('missing session and cancelled operations do not receive permission', async context => {
const root = await fixture(context)
await assert.rejects(check(join(root, 'file.txt'), storeFor(), { sessionId: '' }), { code: 'missing-write-session' })
const controller = new AbortController()
controller.abort(new Error('cancelled by caller'))
await assert.rejects(check(join(root, 'file.txt'), storeFor(), { signal: controller.signal }), /cancelled by caller/)
})
test('registration-store failures never allow Git writes', async context => {
const root = await repository(join(await fixture(context), 'repo'))
await assert.rejects(check(join(root, 'file.txt'), { list: async () => { throw new Error('store failed') } }), /store failed/)
})

View file

@ -0,0 +1,160 @@
import assert from 'node:assert/strict'
import { execFile } from 'node:child_process'
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import test from 'node:test'
import { promisify } from 'node:util'
import { MutationStore } from '../lib/mutation-store.js'
import { createMutationHandlers } from '../lib/mutation-routes.js'
const execute = promisify(execFile)
async function fixture(context, { inGit = true } = {}) {
const root = await mkdtemp(join(tmpdir(), 'dsh-mutation-test-'))
context.after(() => rm(root, { recursive: true, force: true }))
const workspace = join(root, 'workspace')
await mkdir(workspace)
if (inGit) await execute('git', ['-C', workspace, 'init', '--initial-branch=main'])
const filePath = join(workspace, 'file #雪.txt')
await writeFile(filePath, 'original content')
const mutations = new MutationStore(join(root, 'records.json'))
await mutations.record({ sessionId: 's1', callId: 'c1', toolName: 'write', filePath, repository: inGit ? workspace : null, warning: inGit ? null : 'WARNING: outside Git' })
const entries = inGit ? [{ ok: true, repository: workspace, workingDirectory: workspace }] : []
const opened = []
const store = { list: async () => entries }
const handlers = createMutationHandlers({
mutations,
store,
editor: { openFile: async (...argumentsList) => { opened.push(argumentsList); return { delivery: 'existing', directory: argumentsList[0] } } },
resolveDirectoryRepository: async directory => ({ repository: workspace, workingDirectory: directory }),
isLoopbackRequest: request => request.headers.origin !== 'https://outside.invalid',
writeJson: (response, status, body) => { response.status = status; response.body = body },
})
return { root, workspace, filePath, mutations, entries, opened, ...handlers }
}
function request(method = 'POST', query = '') {
return {
method,
url: `/touched-git/editor/open-file?sessionId=s1&callId=c1${query}`,
headers: { host: '127.0.0.1:3080', 'x-dsh-editor-action': 'open' },
}
}
function response() {
return { headers: {}, setHeader(name, value) { this.headers[name] = value } }
}
test('describing a recorded mutation never launches an editor', async context => {
const fixtureData = await fixture(context)
const result = response()
await fixtureData.describeHandler(request('GET'), result)
assert.equal(result.status, 200)
assert.equal(result.body.value.canOpen, true)
assert.equal(result.body.value.filePath, fixtureData.filePath)
assert.equal(result.headers['cache-control'], 'no-store')
assert.deepEqual(fixtureData.opened, [])
})
test('explicit open uses the recorded physical file and forwards existing-workspace safety', async context => {
const fixtureData = await fixture(context)
const result = response()
await fixtureData.openFileHandler(request('POST', '&requireExisting=true'), result)
assert.equal(result.status, 200)
assert.deepEqual(fixtureData.opened, [[fixtureData.workspace, fixtureData.filePath, { requireExisting: true }]])
assert.equal(result.body.value.delivery, 'existing')
assert.equal(await readFile(fixtureData.filePath, 'utf8'), 'original content')
})
test('GET, cross-origin, and missing action header cannot open editors', async context => {
const fixtureData = await fixture(context)
const cases = [
[request('GET'), 405],
[{ ...request(), headers: { ...request().headers, origin: 'https://outside.invalid' } }, 403],
[{ ...request(), headers: { host: '127.0.0.1:3080' } }, 403],
]
for (const [input, status] of cases) {
const result = response()
await fixtureData.openFileHandler(input, result)
assert.equal(result.status, status)
}
assert.deepEqual(fixtureData.opened, [])
})
test('unknown and other-session calls cannot supply arbitrary target paths', async context => {
const fixtureData = await fixture(context)
for (const url of ['/route?sessionId=s1&callId=unknown&path=/etc/passwd', '/route?sessionId=s2&callId=c1']) {
const result = response()
await fixtureData.openFileHandler({ ...request(), url }, result)
assert.equal(result.status, 403)
}
const description = response()
await fixtureData.describeHandler({ ...request('GET'), url: '/route?sessionId=s1&callId=old' }, description)
assert.equal(description.body.value.canOpen, false)
assert.deepEqual(fixtureData.opened, [])
})
test('unregistering a repository disables its recorded file action', async context => {
const fixtureData = await fixture(context)
fixtureData.entries.length = 0
const result = response()
await fixtureData.openFileHandler(request(), result)
assert.equal(result.status, 403)
assert.deepEqual(fixtureData.opened, [])
})
test('outside-Git files open only on click in their parent folder and retain warning', async context => {
const fixtureData = await fixture(context, { inGit: false })
const description = response()
await fixtureData.describeHandler(request('GET'), description)
assert.equal(description.body.value.canOpen, true)
assert.match(description.body.value.warning, /outside Git/)
assert.deepEqual(fixtureData.opened, [])
const result = response()
await fixtureData.openFileHandler(request(), result)
assert.equal(result.status, 200)
assert.equal(fixtureData.opened[0][0], dirname(fixtureData.filePath))
assert.match(result.body.value.warning, /outside Git/)
assert.deepEqual(fixtureData.entries, [])
})
test('changed symlink targets cannot redirect recorded file actions', async context => {
const fixtureData = await fixture(context)
const other = join(fixtureData.root, 'other.txt')
await writeFile(other, 'unrelated')
await rm(fixtureData.filePath)
await symlink(other, fixtureData.filePath)
const result = response()
await fixtureData.openFileHandler(request(), result)
assert.equal(result.status, 503)
assert.match(result.body.error.message, /symlink/)
assert.deepEqual(fixtureData.opened, [])
})
test('a deleted recorded file produces an error rather than creating an editor', async context => {
const fixtureData = await fixture(context)
await rm(fixtureData.filePath)
const result = response()
await fixtureData.openFileHandler(request(), result)
assert.equal(result.status, 503)
assert.deepEqual(fixtureData.opened, [])
})
test('mutation records survive reload and remain isolated by session and call', async context => {
const fixtureData = await fixture(context)
const reloaded = new MutationStore(join(fixtureData.root, 'records.json'))
assert.equal((await reloaded.get('s1', 'c1')).filePath, fixtureData.filePath)
assert.equal(await reloaded.get('s2', 'c1'), null)
assert.equal(await reloaded.get('s1', 'c2'), null)
})
test('corrupt mutation records are surfaced and not silently overwritten', async context => {
const fixtureData = await fixture(context)
const path = join(fixtureData.root, 'corrupt.json')
await writeFile(path, 'broken JSON')
const store = new MutationStore(path)
await assert.rejects(store.get('s1', 'c1'))
await assert.rejects(store.record({ sessionId: 's1', callId: 'c1', filePath: fixtureData.filePath }))
assert.equal(await readFile(path, 'utf8'), 'broken JSON')
})

Some files were not shown because too many files have changed in this diff Show more