diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6f2e7de8..92d12a26 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,7 +86,7 @@ jobs: build-macos: name: build-macos - runs-on: macos-latest + runs-on: macos-26 needs: lint-and-test # The Vite production build OOMs at Node's default heap on the macOS runner. env: @@ -123,4 +123,10 @@ jobs: curl --fail --location --retry 5 --retry-delay 10 --retry-all-errors \ --output "$ELECTRON_CACHE/$ELECTRON_ZIP" \ "https://github.com/electron/electron/releases/download/v${ELECTRON_VERSION}/${ELECTRON_ZIP}" - - run: pnpm exec electron-builder --publish never + - name: Package canonical Apple Silicon artifact + timeout-minutes: 30 + env: + DAEMON_MAC_ADHOC: '1' + DAEMON_REQUIRE_MAC_NOTARIZATION: '0' + CSC_IDENTITY_AUTO_DISCOVERY: 'false' + run: pnpm run package:lite:mac diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1a7cba3e..a8b49448 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,9 +8,6 @@ on: permissions: contents: read -# The Vite/tsc production build peaks past Node's ~2GB default heap on the hosted -# runners (the macOS arm64 job OOM'd with exit 134 on v4.6.2). Give every job a -# 4GB heap so the build can't run out of memory on any platform. env: NODE_OPTIONS: --max-old-space-size=4096 @@ -31,13 +28,14 @@ jobs: node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile + - name: Verify tag matches package version + run: test "${GITHUB_REF_NAME}" = "v$(node -p "require('./package.json').version")" - run: pnpm run typecheck - run: pnpm run test - release-windows: + package-windows: runs-on: windows-latest needs: validate - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') steps: - uses: actions/checkout@v6 with: @@ -49,50 +47,53 @@ jobs: with: node-version: 22 cache: pnpm - - name: Cache electron + - name: Cache Electron uses: actions/cache@v5 with: path: | - ~/.cache/electron - ~/.cache/electron-builder ~\AppData\Local\electron\cache ~\AppData\Local\electron-builder\cache key: electron-cache-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - electron-cache-${{ runner.os }}- + restore-keys: electron-cache-${{ runner.os }}- - run: pnpm install --frozen-lockfile - - run: pnpm run build - - name: Package + - name: Build canonical DAEMON installer + shell: pwsh + env: + CSC_LINK: ${{ secrets.CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }} + run: pnpm run package:lite + - name: Packaged smoke shell: pwsh run: | - if ("${{ secrets.CSC_LINK }}") { - $env:CSC_LINK = "${{ secrets.CSC_LINK }}" - } - if ("${{ secrets.CSC_KEY_PASSWORD }}") { - $env:CSC_KEY_PASSWORD = "${{ secrets.CSC_KEY_PASSWORD }}" - } - pnpm exec electron-builder --publish never - - name: Generate checksums + node scripts/smoke/lite-app-smoke.mjs + node scripts/smoke/lite-workbench-smoke.mjs + - name: Generate checksum shell: pwsh run: | - Get-ChildItem -Path release -Recurse -File -Include DAEMON-setup.exe | ForEach-Object { - $hash = (Get-FileHash $_.FullName -Algorithm SHA256).Hash.ToLower() - "$hash $($_.Name)" | Out-File -Append -FilePath release/checksums-windows.txt - } + $installer = Get-ChildItem -Path release-lite -Recurse -File -Filter DAEMON-setup.exe | Select-Object -First 1 + if (-not $installer) { throw 'DAEMON-setup.exe was not produced' } + $hash = (Get-FileHash $installer.FullName -Algorithm SHA256).Hash.ToLower() + "$hash $($installer.Name)" | Out-File -FilePath release-lite/checksums-windows.txt - uses: actions/upload-artifact@v7 with: - name: windows-release + name: daemon-windows-release path: | - release/*/DAEMON-setup.exe - release/*/latest.yml - release/*/DAEMON-setup.exe.blockmap - release/checksums-windows.txt + release-lite/*/DAEMON-setup.exe + release-lite/*/DAEMON-setup.exe.blockmap + release-lite/*/latest.yml + release-lite/checksums-windows.txt retention-days: 1 - release-macos: - runs-on: macos-latest + package-macos: + runs-on: macos-26 needs: validate - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + env: + CSC_LINK: ${{ secrets.MAC_CSC_LINK }} + CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + DAEMON_ALLOW_UNSIGNED_MAC_VERSION: '4.7.11' steps: - uses: actions/checkout@v6 with: @@ -104,113 +105,166 @@ jobs: with: node-version: 22 cache: pnpm - - name: Cache electron + - name: Select signed or version-scoped unsigned mode + shell: bash + run: | + present=0 + for name in CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID; do + if [ -n "${!name:-}" ]; then present=$((present + 1)); fi + done + version="$(node -p "require('./package.json').version")" + if [ "$present" -eq 5 ]; then + mode=signed + echo 'DAEMON_REQUIRE_MAC_NOTARIZATION=1' >> "$GITHUB_ENV" + echo 'DAEMON_MAC_ADHOC=0' >> "$GITHUB_ENV" + elif [ "$present" -ne 0 ]; then + echo 'Partial Apple credentials are not allowed' + exit 1 + elif [ "$version" = "$DAEMON_ALLOW_UNSIGNED_MAC_VERSION" ]; then + mode=unsigned + echo 'DAEMON_REQUIRE_MAC_NOTARIZATION=0' >> "$GITHUB_ENV" + echo 'DAEMON_MAC_ADHOC=1' >> "$GITHUB_ENV" + echo 'CSC_IDENTITY_AUTO_DISCOVERY=false' >> "$GITHUB_ENV" + else + echo "Apple credentials are required for macOS release $version" + exit 1 + fi + echo "DAEMON_MAC_RELEASE_MODE=$mode" >> "$GITHUB_ENV" + echo "macOS release mode: $mode" + - name: Cache Electron uses: actions/cache@v5 with: path: | ~/.cache/electron ~/.cache/electron-builder - key: electron-cache-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - electron-cache-${{ runner.os }}- + key: electron-cache-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: electron-cache-${{ runner.os }}-${{ runner.arch }}- - run: pnpm install --frozen-lockfile - - run: pnpm run build - - name: Pre-cache Electron binaries + - name: Build Apple Silicon release shell: bash run: | - ELECTRON_VERSION="$(node -p "require('./package.json').devDependencies.electron.replace(/^[^0-9]*/, '')")" - ELECTRON_CACHE="$HOME/.cache/electron" - mkdir -p "$ELECTRON_CACHE" - for arch in arm64 x64; do - ELECTRON_ZIP="electron-v${ELECTRON_VERSION}-darwin-${arch}.zip" - curl --fail --location --retry 5 --retry-delay 10 --retry-all-errors \ - --output "$ELECTRON_CACHE/$ELECTRON_ZIP" \ - "https://github.com/electron/electron/releases/download/v${ELECTRON_VERSION}/${ELECTRON_ZIP}" - done - - name: Package - run: | - if [ -n "${{ secrets.CSC_LINK }}" ]; then - export CSC_LINK="${{ secrets.CSC_LINK }}" - fi - if [ -n "${{ secrets.CSC_KEY_PASSWORD }}" ]; then - export CSC_KEY_PASSWORD="${{ secrets.CSC_KEY_PASSWORD }}" + if [ "$DAEMON_MAC_RELEASE_MODE" = 'unsigned' ]; then + unset CSC_LINK CSC_KEY_PASSWORD APPLE_ID APPLE_APP_SPECIFIC_PASSWORD APPLE_TEAM_ID fi - if [ -n "${{ secrets.APPLE_ID }}" ]; then - export APPLE_ID="${{ secrets.APPLE_ID }}" - fi - if [ -n "${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" ]; then - export APPLE_APP_SPECIFIC_PASSWORD="${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}" - fi - if [ -n "${{ secrets.APPLE_TEAM_ID }}" ]; then - export APPLE_TEAM_ID="${{ secrets.APPLE_TEAM_ID }}" - fi - if [ -n "$APPLE_ID" ] && [ -n "$APPLE_APP_SPECIFIC_PASSWORD" ] && [ -n "$APPLE_TEAM_ID" ]; then - echo "Notarization credentials found" - fi - pnpm exec electron-builder --mac dmg zip --arm64 --x64 --publish never - - name: Generate checksums + pnpm run package:lite:mac + - name: Verify signature mode, architecture, and updater metadata + shell: bash run: | - cd release - find . -name "*.dmg" -o -name "*.zip" | while read f; do - shasum -a 256 "$f" >> checksums-macos.txt - done - - uses: actions/upload-artifact@v7 - with: - name: macos-release - path: | - release/*/DAEMON-*.dmg - release/*/DAEMON-*.zip - release/*/latest-mac.yml - release/*/DAEMON-*.blockmap - release/checksums-macos.txt - retention-days: 1 + VERSION="$(node -p "require('./package.json').version")" + RELEASE_DIR="release-lite/$VERSION" + APP_PATH="$RELEASE_DIR/mac-arm64/DAEMON.app" + test "$(uname -m)" = arm64 + case "$DAEMON_MAC_RELEASE_MODE" in + signed) artifact_stem='DAEMON-arm64'; verifier_args=() ;; + unsigned) artifact_stem='DAEMON-unsigned-arm64'; verifier_args=(--unsigned) ;; + *) echo "Invalid macOS release mode: $DAEMON_MAC_RELEASE_MODE"; exit 1 ;; + esac - release-linux: - runs-on: ubuntu-latest - needs: validate - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - uses: pnpm/action-setup@v6 - with: - version: 10.15.0 - - uses: actions/setup-node@v6 - with: - node-version: 22 - cache: pnpm - - name: Cache electron - uses: actions/cache@v5 - with: - path: | - ~/.cache/electron - ~/.cache/electron-builder - key: electron-cache-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} - restore-keys: | - electron-cache-${{ runner.os }}- - - run: pnpm install --frozen-lockfile - - run: pnpm run build - - run: pnpm exec electron-builder --publish never - - name: Generate checksums + verify_bundle() { + local app_path="$1" + local executable="$app_path/Contents/MacOS/DAEMON" + test "$(lipo -archs "$executable")" = arm64 + codesign --verify --deep --strict --verbose=2 "$app_path" + local app_details app_team + app_details="$(codesign -dv --verbose=4 "$app_path" 2>&1)" + case "$DAEMON_MAC_RELEASE_MODE" in + signed) + grep -q '^Authority=Developer ID Application:' <<< "$app_details" + app_team="$(sed -n 's/^TeamIdentifier=//p' <<< "$app_details")" + test -n "$app_team" + xcrun stapler validate "$app_path" + spctl --assess --type execute -vv "$app_path" + ;; + unsigned) + grep -q '^Signature=adhoc$' <<< "$app_details" + ! grep -q '^Authority=' <<< "$app_details" + ! grep -q 'flags=.*runtime' <<< "$app_details" + if spctl --assess --type execute -vv "$app_path"; then + echo 'Unsigned mode unexpectedly passed Gatekeeper assessment' + exit 1 + fi + ;; + esac + + local native_dir="$app_path/Contents/Resources/app.asar.unpacked/node_modules" + local addons=("$native_dir/better-sqlite3/build/Release/better_sqlite3.node") + for candidate in \ + "$native_dir/node-pty/bin/darwin-arm64-145/node-pty.node" \ + "$native_dir/node-pty/build/Release/pty.node" \ + "$native_dir/node-pty/prebuilds/darwin-arm64/pty.node"; do + if [ -f "$candidate" ]; then addons+=("$candidate"); fi + done + test "${#addons[@]}" -ge 2 + for addon in "${addons[@]}"; do + test "$(lipo -archs "$addon")" = arm64 + codesign --verify --strict --verbose=2 "$addon" + local addon_details + addon_details="$(codesign -dv --verbose=4 "$addon" 2>&1)" + if [ "$DAEMON_MAC_RELEASE_MODE" = signed ]; then + grep -q '^Authority=Developer ID Application:' <<< "$addon_details" + test "$(sed -n 's/^TeamIdentifier=//p' <<< "$addon_details")" = "$app_team" + else + grep -q '^Signature=adhoc$' <<< "$addon_details" + ! grep -q '^Authority=' <<< "$addon_details" + fi + done + } + + verify_bundle "$APP_PATH" + node scripts/release-tools/verify-macos-artifacts.mjs "$RELEASE_DIR" "$VERSION" "${verifier_args[@]}" + + dmg_path="$RELEASE_DIR/$artifact_stem.dmg" + zip_path="$RELEASE_DIR/$artifact_stem.zip" + hdiutil verify "$dmg_path" + unzip -t "$zip_path" + mount_dir="$(mktemp -d)" + zip_dir="$(mktemp -d)" + cleanup() { + hdiutil detach "$mount_dir" -force >/dev/null 2>&1 || true + rm -rf "$mount_dir" "$zip_dir" + } + trap cleanup EXIT + hdiutil attach "$dmg_path" -nobrowse -readonly -mountpoint "$mount_dir" + verify_bundle "$mount_dir/DAEMON.app" + ditto -x -k "$zip_path" "$zip_dir" + verify_bundle "$zip_dir/DAEMON.app" + DAEMON_PACKAGED_EXE="$zip_dir/DAEMON.app/Contents/MacOS/DAEMON" \ + node scripts/smoke/lite-app-smoke.mjs + cleanup + trap - EXIT + - name: Packaged smoke run: | - cd release - find . -name "*.AppImage" | while read f; do - sha256sum "$f" >> checksums-linux.txt - done + node scripts/smoke/lite-app-smoke.mjs + node scripts/smoke/lite-workbench-smoke.mjs + - name: Generate checksum + shell: bash + run: | + VERSION="$(node -p "require('./package.json').version")" + RELEASE_DIR="release-lite/$VERSION" + case "$DAEMON_MAC_RELEASE_MODE" in + signed) artifact_stem='DAEMON-arm64' ;; + unsigned) artifact_stem='DAEMON-unsigned-arm64' ;; + *) echo "Invalid macOS release mode: $DAEMON_MAC_RELEASE_MODE"; exit 1 ;; + esac + shasum -a 256 "$RELEASE_DIR/$artifact_stem.dmg" "$RELEASE_DIR/$artifact_stem.zip" \ + | sed "s#$RELEASE_DIR/##" > release-lite/checksums-macos.txt + printf '%s\n' "$DAEMON_MAC_RELEASE_MODE" > release-lite/mac-release-mode.txt - uses: actions/upload-artifact@v7 with: - name: linux-release + name: daemon-macos-arm64-release path: | - release/*/DAEMON.AppImage - release/*/latest-linux.yml - release/checksums-linux.txt + release-lite/*/DAEMON-*arm64.dmg + release-lite/*/DAEMON-*arm64.dmg.blockmap + release-lite/*/DAEMON-*arm64.zip + release-lite/*/DAEMON-*arm64.zip.blockmap + release-lite/*/latest-mac.yml + release-lite/checksums-macos.txt + release-lite/mac-release-mode.txt retention-days: 1 publish: runs-on: ubuntu-latest - needs: [release-windows, release-macos, release-linux] - if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + needs: [package-windows, package-macos] permissions: actions: read contents: write @@ -220,47 +274,68 @@ jobs: fetch-depth: 0 - uses: actions/download-artifact@v8 with: - name: windows-release + name: daemon-windows-release path: artifacts/windows - uses: actions/download-artifact@v8 with: - name: macos-release + name: daemon-macos-arm64-release path: artifacts/macos - - uses: actions/download-artifact@v8 - with: - name: linux-release - path: artifacts/linux - name: Generate changelog - id: changelog run: | PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || git rev-list --max-parents=0 HEAD) - echo "## Changes" > changelog.md - echo "" >> changelog.md - git log --pretty=format:"- %s (%h)" "$PREV_TAG"..HEAD >> changelog.md - echo "" >> changelog.md - echo "" >> changelog.md - echo "## Checksums" >> changelog.md + MAC_RELEASE_MODE="$(cat artifacts/macos/mac-release-mode.txt)" + case "$MAC_RELEASE_MODE" in + unsigned) + echo '## macOS Apple Silicon warning' > changelog.md + echo '' >> changelog.md + echo 'This Apple Silicon build is ad-hoc signed and is not Apple-notarized. Gatekeeper will block the first launch. Verify the SHA-256 checksum below, then follow the [macOS install steps](https://www.daemonide.tech/docs/install-help). Automatic macOS updates are disabled; install future releases manually.' >> changelog.md + echo '' >> changelog.md + echo "RELEASE_TITLE=DAEMON ${GITHUB_REF_NAME} (macOS unsigned)" >> "$GITHUB_ENV" + ;; + signed) + : > changelog.md + echo "RELEASE_TITLE=DAEMON ${GITHUB_REF_NAME}" >> "$GITHUB_ENV" + ;; + *) echo "Invalid macOS release mode: $MAC_RELEASE_MODE"; exit 1 ;; + esac + echo "MAC_RELEASE_MODE=$MAC_RELEASE_MODE" >> "$GITHUB_ENV" + echo '## DAEMON focused workbench' >> changelog.md + echo '' >> changelog.md + echo '- Conversation-first Solana development shell' >> changelog.md + echo '- Project explorer, offline Monaco editor, and scoped terminal' >> changelog.md + echo '- Meme Tech repo intelligence and read-only market evidence' >> changelog.md + echo '- Guarded localnet/devnet workflows with no silent mainnet authority' >> changelog.md + echo '' >> changelog.md + echo '## Changes' >> changelog.md + git log --pretty=format:'- %s (%h)' "$PREV_TAG"..HEAD >> changelog.md + echo '' >> changelog.md + echo '' >> changelog.md + echo '## SHA-256' >> changelog.md echo '```' >> changelog.md - cat artifacts/windows/checksums-windows.txt 2>/dev/null >> changelog.md || true - cat artifacts/macos/checksums-macos.txt 2>/dev/null >> changelog.md || true - cat artifacts/linux/checksums-linux.txt 2>/dev/null >> changelog.md || true + cat artifacts/windows/checksums-windows.txt >> changelog.md + cat artifacts/macos/checksums-macos.txt >> changelog.md echo '```' >> changelog.md - - name: Create GitHub Release + - name: Stage release assets + shell: bash + run: | + mkdir -p artifacts/release + find artifacts/windows -type f \( -name 'DAEMON-setup.exe' -o -name 'DAEMON-setup.exe.blockmap' -o -name 'latest.yml' \) \ + -exec cp {} artifacts/release/ \; + cp artifacts/windows/checksums-windows.txt artifacts/release/ + find artifacts/macos -type f \( -name 'DAEMON-*arm64.dmg' -o -name 'DAEMON-*arm64.dmg.blockmap' -o -name 'DAEMON-*arm64.zip' -o -name 'DAEMON-*arm64.zip.blockmap' \) \ + -exec cp {} artifacts/release/ \; + cp artifacts/macos/checksums-macos.txt artifacts/release/ + case "$MAC_RELEASE_MODE" in + signed) find artifacts/macos -type f -name 'latest-mac.yml' -exec cp {} artifacts/release/ \; ;; + unsigned) ;; + *) echo "Invalid macOS release mode: $MAC_RELEASE_MODE"; exit 1 ;; + esac + - name: Create GitHub release uses: softprops/action-gh-release@v3 with: + name: ${{ env.RELEASE_TITLE }} body_path: changelog.md draft: false prerelease: ${{ contains(github.ref, '-beta') || contains(github.ref, '-alpha') || contains(github.ref, '-rc') }} files: | - artifacts/windows/*/DAEMON-setup.exe - artifacts/windows/*/latest.yml - artifacts/windows/*/DAEMON-setup.exe.blockmap - artifacts/macos/*/DAEMON-*.dmg - artifacts/macos/*/DAEMON-*.zip - artifacts/macos/*/latest-mac.yml - artifacts/macos/*/DAEMON-*.blockmap - artifacts/linux/*/DAEMON.AppImage - artifacts/linux/*/latest-linux.yml - artifacts/windows/checksums-windows.txt - artifacts/macos/checksums-macos.txt - artifacts/linux/checksums-linux.txt + artifacts/release/* diff --git a/.gitignore b/.gitignore index 0798d510..f5a3abe9 100644 --- a/.gitignore +++ b/.gitignore @@ -10,11 +10,14 @@ node_modules dist dist-ssr dist-electron +dist-electron-lite +dist-lite dist-cloud dist-bridge packages/bridge-shim/daemon-bridge-shim.mjs packages/bridge-shim/*.tgz release +release-lite *.tsbuildinfo *.local @@ -73,6 +76,7 @@ features/ # Internal dev artifacts .design/ /screenshots/ +/recordings/ NVIDIA Corporation/ .agents/ .codex-run/ diff --git a/BRAND.md b/BRAND.md index 6396d64e..5b9aea8d 100644 --- a/BRAND.md +++ b/BRAND.md @@ -8,28 +8,29 @@ Official brand identity reference for the DAEMON agent workbench. Follow these r ### Core Backgrounds (Elevation Scale) -Dark-first. Each step lifts a surface closer to the user. - -| Token | Hex | Usage | -|--------|-----------|-------------------------------------| -| `--bg` | `#0a0a0a` | Workspace pit — deepest layer | -| `--s1` | `#141414` | Sidebars, titlebar, cards | -| `--s2` | `#1a1a1a` | Inputs, secondary surfaces | -| `--s3` | `#222222` | Hover states | -| `--s4` | `#2a2a2a` | Active / pressed states | -| `--s5` | `#333333` | Borders | -| `--s6` | `#3a3a3a` | Strong borders / dividers | +Dark-first, green-tinted near-black (shared with the Daemon website design +system). Never brown, never pure black or pure gray. Each step lifts a surface +closer to the user. + +| Token | Hex | Usage | +|--------|-----------|----------------------------------------| +| `--void` | `#0a0c0b` | Workspace pit — deepest layer | +| `--bg` | `#0c0e0d` | App chrome, editor/terminal ground | +| `--s1` | `#101211` | Panels, sidebars, titlebar | +| `--s2` | `#171a18` | Cards, inputs, secondary surfaces | +| `--s3` | `#1d211e` | Hover states, raised surfaces | +| `--s4` | `#252a27` | Active / pressed states | ### Text Scale -All values are WCAG AA compliant against `--bg` (#0a0a0a). +Cream `#ECEEE9` ladder (brand steps 100/65/45/30%) against `--s1` (#101211). -| Token | Hex | Contrast | Usage | -|--------|-----------|----------|--------------------------| -| `--t1` | `#f0f0f0` | 15.5:1 | Primary text | -| `--t2` | `#a0a0a0` | 7.5:1 | Secondary text | -| `--t3` | `#888888` | 6.1:1 | Tertiary / muted labels | -| `--t4` | `#666666` | 4.6:1 | Disabled / placeholder | +| Token | Hex | Usage | +|--------|-----------|--------------------------| +| `--t1` | `#eceee9` | Primary text | +| `--t2` | `#9fa19d` | Secondary text | +| `--t3` | `#737571` | Tertiary / muted labels | +| `--t4` | `#525451` | Disabled / placeholder | ### Accent Colors @@ -67,8 +68,8 @@ Each accent has three states: **base**, **dim** (hover/pressed), and **glow** (a | Purpose | Font | Fallback Stack | |---------------|---------------------|-------------------------------------------------------| -| **UI** | Plus Jakarta Sans | -apple-system, BlinkMacSystemFont, sans-serif | -| **Code** | JetBrains Mono | Fira Code, Cascadia Code, monospace | +| **UI** | Geist | Plus Jakarta Sans, -apple-system, sans-serif | +| **Code** | JetBrains Mono | Geist Mono, Cascadia Code, monospace | Both fonts are self-hosted as `.woff2` in `/public/fonts/`. No external font requests — DAEMON runs fully offline. diff --git a/DESIGN_SYSTEM.md b/DESIGN_SYSTEM.md index c20380e4..dd9dd33c 100644 --- a/DESIGN_SYSTEM.md +++ b/DESIGN_SYSTEM.md @@ -8,6 +8,13 @@ should feel like it was built by the same hand on the same day. > flow, visual cohesion, and professional information density — on top of DAEMON's > dark + green Solana identity. +> **Brand alignment (July 2026):** token values follow the shared Daemon brand +> system (same as the website): green-tinted near-black surfaces (panel `#101211`, +> card `#171A18`, raised `#1D211E`, chrome `#0C0E0D` — never brown, never pure +> black/gray), cream text ladder from `#ECEEE9`, cream hairline borders, one green +> accent `#3ECF8E`. UI face is **Geist**; mono is **JetBrains Mono** (Geist Mono +> fallback). Values live in `styles/tokens.css`; panels keep using semantic tokens. + --- ## Principles diff --git a/README.md b/README.md index ad18c3c4..49920b06 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@

DAEMON

-

An AI-native Solana development environment for agents, wallets, launches, deployments, and hosted DAEMON AI.

+

AI agents that work inside your Solana stack, under your authority.

+

Build, inspect, and operate from one local-first workbench. Writes, Git pushes, and fund movement stay behind explicit review.

@@ -8,7 +9,6 @@ Release Downloads License - Tests

@@ -25,20 +25,20 @@ ---

- $DAEMON CA: 4vpf4qNtNVkvz2dm5qL2mT6jBXH9gDY8qH2QsHN5pump + DAEMON 4.7 project templates with the ARIA console and guarded first-mission actions

---- +**[Frontier demo runbook](FRONTIER_SUBMISSION.md#2-minute-demo-runbook)** — 2-minute submission flow from project open to devnet settlement. -

- DAEMON agent workbench with editor, terminal, and sidebar -

+DAEMON is a focused Windows workbench for Solana builders who want one persistent AI conversation beside real project files, a scoped terminal, guarded local workflows, and live meme-tech evidence. It is local-first and does not hand agents silent authority over code, keys, Git, or funds. -**[Frontier demo runbook](FRONTIER_SUBMISSION.md#2-minute-demo-runbook)** — 2-minute submission flow from project open to devnet settlement. +## How authority works -DAEMON is a standalone Electron development environment for Solana builders who use AI agents to ship. It combines an offline editor, real PTY terminals, DAEMON AI, Claude/Codex agent spawning, MCP management, wallet/RPC readiness, token launches, deployments, integrations, and an Anchor-backed registry for publishing verifiable agent work receipts. Not a VS Code fork. +1. **Inspect:** enabled read tools gather project, Git, wallet, and runtime context. +2. **Review:** writes pause for approval. Guarded sensitive flows use typed checks, and direct Autopilot arming ends in an OS-native review of every bound mainnet term. +3. **Verify:** diffs, test output, signatures, and explorer links keep results inspectable after execution. -DAEMON Light stays free and useful for local work and bring-your-own-key AI. DAEMON Pro and holder access unlock hosted DAEMON AI, Pro Skills, Arena, MCP sync, priority workflows, and higher model lanes as they go live. +The free tier stays free for local work and bring-your-own-key AI. Advanced trading, launch, and hosted AI surfaces are optional capability packs, not prerequisites for the core build loop. ## Install @@ -46,64 +46,55 @@ DAEMON Light stays free and useful for local work and bring-your-own-key AI. DAE -**Mac:** Build from source (signed builds configurable via Apple credentials): +**Mac and Linux:** The canonical packaged release is currently Windows-only. Build the repository from source for development: ```bash git clone https://github.com/nullxnothing/daemon.git cd daemon pnpm install pnpm run build -pnpm run package ``` -The `.dmg` will be in `release//`. Signed/notarized builds require Apple Developer credentials in the packaging environment. Without them, the app will still package, but Gatekeeper may require right-click > Open on first launch. - - - -**Linux:** Build from source (AppImage builds coming soon): +**Build the canonical Windows installer from source:** ```bash git clone https://github.com/nullxnothing/daemon.git cd daemon pnpm install -pnpm run build -pnpm run package +pnpm run package:lite ``` -The AppImage will be in `release//`. Make it executable with `chmod +x` and run directly. +Requires **Node.js 22+** and **pnpm 9+**. -**Build from source (any platform):** +The installer is written to `release-lite//DAEMON-setup.exe`. -```bash -git clone https://github.com/nullxnothing/daemon.git -cd daemon -pnpm install -pnpm run package -``` +## Focused workbench -Requires **Node.js 22+** and **pnpm 9+**. +- **Conversation:** project-scoped sessions, bring-your-own-key models, and encrypted local credentials. +- **Code:** bounded project import, recursive explorer, offline Monaco, dirty-file protection, and explicit save states. +- **Terminal:** real project-scoped PTYs with visible exit and error states. +- **Meme Tech:** repo topology, Birdeye and DEX market context, provider divergence, and read-only token-risk evidence. +- **Wallet, Trade, Scanner:** optional tools behind the same guarded shell. Wallet addresses remain watch-only by default. ## Features -

- Editor with multiple tabs, breadcrumbs, and file tree -

- **Editor** — Monaco running fully offline via a custom protocol handler. Multi-tab, breadcrumbs, syntax highlighting, Ctrl+S save. No CDN dependency. **Terminal** — Real PTY sessions powered by node-pty and xterm.js. Multiple tabs, split panes, command history search (Ctrl+R), tab-completion hints, and dedicated agent session management. -

- Agent launcher with model selection and MCP config -

- **Agent Launcher** — Spawn Claude Code agents with custom system prompts, model selection, and per-project MCP configurations. Agents run as real CLI sessions in dedicated terminal tabs. +**ARIA Game Studio Beta:** Create a playable local Phaser starter, install dependencies, verify a +production build, and open the preview inside DAEMON. With approval, ARIA can send one focused +`build_game` lane into a separate Git worktree. The starter has typed seams for future Solana +integration, but the beta uses local stubs. It does not connect a live wallet, write onchain, mint, +publish, or deploy. `deploy_app` opens the Deploy panel for a manual handoff. + **VS Code-style shell + capability packs** — Explorer, editor, a bottom-panel terminal, and the DAEMON Console on the right rail. Domain features ship as toggleable capability packs (Solana, Wallet, Launch, Agents, Memory, Sites, Markets, Create); disabling a pack quiesces its tools, integrations, sidebar icon, console commands, and background work — IPC handlers included. The Capability Manager shows how many packs are active and how much backend work is idle. **DAEMON Console (ARIA operator)** — The right-rail AI operator drives the whole IDE from natural language, chat-first with `>` and `/` command accelerators. Per-project chat sessions (new / switch / rename / archive / delete) with memory that survives restarts and compounds: the console proposes durable facts after real work (Keep/Dismiss), cites which taught facts a turn drew on, and strengthens proven facts over time. It runs DAEMON itself — agent wallets, token preflight/launch, Flywheel config, git — through a registry of typed tools with typed confirmation for sensitive on-chain actions (and a `[MAINNET]` guard). It never pushes to git autonomously. -**ARIA Autopilot** — Standing, structured trading mandates parsed from natural language and executed unattended on mainnet on a fixed cadence, with exit rules (take-profit / stop-loss / liquidity floor), a hard exposure cap, arm/disarm/kill-switch, and a "The Desk" panel showing live unrealized P&L and the action tape. Every tick claims its ledger row before it swaps, so a crash mid-tick is held for review, never replayed into a double-buy; a cluster switch auto-holds armed mandates; unattended slippage and price impact are capped tighter than a human-confirmed trade. +**ARIA Autopilot (experimental):** Bounded mainnet mandates run on a fixed cadence after a typed review of the wallet, mint, clip, exposure cap, slippage, and exits. The Desk shows estimated P&L and an action tape. Every tick claims its ledger row before it swaps, so a crash mid-tick is held for review, never replayed into a double-buy; a cluster switch auto-holds armed mandates; unattended slippage and price impact are capped tighter than a human-confirmed trade. Disarming stops future ticks, but a submitted swap may still settle. **Hyperliquid (via HypurrClaw)** — ARIA reads Hyperliquid markets and trades perps/spot by driving the agent-first `hyperliquid` CLI through a single execFile gate (no raw shell). Network defaults to testnet, DAEMON never holds a Hyperliquid key (the CLI's encrypted wallet signs), and every signing action stops for typed confirmation with an `[HL-MAINNET]`/`[HL-TESTNET]` marker. @@ -123,10 +114,6 @@ Requires **Node.js 22+** and **pnpm 9+**. **Wallet** — Live Solana portfolio tracking via Helius. SOL balance and SPL token holdings with USD values from Jupiter. -

- Wallet panel showing token balances -

- **Settings** — API keys encrypted via the OS keychain. MCP integrations, agent defaults, and display preferences. **Tools Browser** — Create, import, and run scripts (TypeScript, Python, Shell) with per-language execution. @@ -154,6 +141,8 @@ DAEMON AI is the hosted agent layer for project-aware chat, patch workflows, Sol Holder access starts with a simple rule: hold 1,000,000 $DAEMON to claim DAEMON Pro with included monthly AI usage. Higher holder tiers can unlock higher limits, discounts, badges, and early access later. Holder access does not mean unlimited AI usage. +**$DAEMON contract address:** `4vpf4qNtNVkvz2dm5qL2mT6jBXH9gDY8qH2QsHN5pump` + DAEMON also includes a Zauth integration surface for x402 database and Provider Hub management. Payment and entitlement enforcement should remain server-side through DAEMON AI Cloud and the relevant provider backends. ## Architecture diff --git a/Whatsnew.md b/Whatsnew.md index 746e4a2c..e7463776 100644 --- a/Whatsnew.md +++ b/Whatsnew.md @@ -1,9 +1,19 @@ -# DAEMON v4.6 +# DAEMON v4.7 -DAEMON v4.6 turns the operator into a full trading and execution surface: unattended mandates, a second venue, a transparent fee line, and a bridge that lets external agents drive DAEMON's gated tools, all on top of the VS Code-style shell and capability packs introduced in v4.3. +DAEMON v4.7 adds ARIA Game Studio Beta: a local-first path from a playable Phaser starter to an +agent-assisted build and in-app preview. The starter runs locally. Its typed wallet, score, and +trophy interfaces use local stubs, so the beta makes no onchain calls. ## Highlights +- **ARIA Game Studio Beta:** Choose the game starter, install its dependencies, verify a production + build, and open the local preview in DAEMON. The starter includes typed seams for future Solana + integration backed by local stubs. +- **Focused agent build:** The approved `build_game` action runs one lane in a separate Git worktree + with Game Studio constraints supplied by DAEMON. Lane output still goes through project, branch, + and clean-worktree checks before merge. +- **Manual deploy handoff:** `deploy_app` opens the Deploy panel. The beta does not perform a live + wallet connection, onchain write, mint, publish, or deployment. - **DAEMON Console + capability packs** — a VS Code-style shell (explorer, editor, bottom terminal, right-rail console) with toggleable packs. Turn a pack off and its tools, integrations, and background work go quiet. - **ARIA Autopilot** — standing trading mandates parsed from natural language and run unattended on mainnet with exit rules, a hard exposure cap, and arm/disarm/kill switches. "The Desk" shows live unrealized P&L and the action tape. - **Hyperliquid via HypurrClaw** — ARIA reads Hyperliquid markets and trades perps/spot through the agent-first CLI. Testnet by default; DAEMON never holds a Hyperliquid key. @@ -13,8 +23,20 @@ DAEMON v4.6 turns the operator into a full trading and execution surface: unatte - **Agent economy control tower** — track agent-routed execution, fees, and paid-resource activity in one panel. - **Venum** — a first-class Solana execution provider in the Markets pack (live/batch prices, ranked swap quotes). +## DAEMON Lite + +- **A separate, small download that is just the agent.** DAEMON Lite ships the ARIA chatbox on its own — no editor, terminal, or project system. Paste one key (Anthropic or GLM/Z.AI) and chat. Keys are encrypted with the OS keychain and stay on the device. +- **Coexists with the full app.** Its own installer, appId, and userData, at roughly half the size (~106MB). Install both side by side. +- **DAEMON-focused tools, same gate.** A collapsible Tools section adds Wallet (read-only watch), Trade (token search, watchlist, and typed-confirm swaps through ARIA with a hard cap), and Scanner (one-shot rug check on mint/freeze authority, snipers, bundles, and cabal links). Every write and swap runs through the same approval gate as the full app. +- **Pop-out browser.** A real browser pane for previews and dashboards, restricted to https and loopback URLs, owned by the main process with no preload on the guest page. +- **Beginner-first onboarding.** One screen, bring-your-own-key, with an "Open in DAEMON IDE" handoff when you outgrow the chatbox. + ## Hardening +- Game starter files and prompts redact RPC and credential-bearing URLs. Project names are strict, + and scaffolding requires a target folder that does not already exist. +- The generated lockfile is committed only after install, production build verification, and a + real local preview listener. Git push blocking applies only to the build lane's worktree. - Autopilot ticks claim their ledger row before swapping, so a crash mid-tick is held for review rather than replayed into a double-buy; a cluster switch auto-holds armed mandates; unattended slippage and price impact are capped tighter than a human-confirmed trade. - Swap price impact is normalized to a single unit end to end, so ordinary low-impact swaps are never spuriously blocked. - ARIA streamed events are tagged per session so approval cards can never attach to the wrong conversation; the approval-resolution channels reject untrusted senders. @@ -39,3 +61,5 @@ DAEMON v4.6 turns the operator into a full trading and execution surface: unatte - `pnpm run typecheck && pnpm run test && pnpm run build` - `pnpm run lint:styles` +- `pnpm run test:ci` +- Packaged Windows executable exercised through the Game Studio desktop/mobile smoke flow. diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist index 259b9903..f5fb28cf 100644 --- a/build/entitlements.mac.plist +++ b/build/entitlements.mac.plist @@ -6,8 +6,6 @@ com.apple.security.cs.allow-unsigned-executable-memory - com.apple.security.cs.allow-dyld-environment-variables - com.apple.security.network.client com.apple.security.files.user-selected.read-write diff --git a/build/lite-runtime/.npmrc b/build/lite-runtime/.npmrc new file mode 100644 index 00000000..d67f3748 --- /dev/null +++ b/build/lite-runtime/.npmrc @@ -0,0 +1 @@ +node-linker=hoisted diff --git a/build/lite-runtime/package.json b/build/lite-runtime/package.json new file mode 100644 index 00000000..eee7de9a --- /dev/null +++ b/build/lite-runtime/package.json @@ -0,0 +1,20 @@ +{ + "name": "@daemon/lite-runtime", + "version": "0.0.0", + "private": true, + "pnpm": { + "patchedDependencies": { + "node-pty@1.1.0": "../../patches/node-pty@1.1.0.patch" + } + }, + "dependencies": { + "@anthropic-ai/sdk": "0.98.0", + "@solana/spl-token": "0.4.14", + "@solana/web3.js": "1.98.4", + "better-sqlite3": "12.10.0", + "bs58": "6.0.0", + "electron-updater": "6.8.3", + "node-pty": "1.1.0", + "tweetnacl": "1.0.3" + } +} diff --git a/build/lite-runtime/pnpm-lock.yaml b/build/lite-runtime/pnpm-lock.yaml new file mode 100644 index 00000000..a4cafa92 --- /dev/null +++ b/build/lite-runtime/pnpm-lock.yaml @@ -0,0 +1,1324 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +patchedDependencies: + node-pty@1.1.0: + hash: f41f3f1b27203d2dfc08a004c1c51bc4a56ca84fce763607028e80840c5bcc3e + path: ../../patches/node-pty@1.1.0.patch + +importers: + + .: + dependencies: + '@anthropic-ai/sdk': + specifier: 0.98.0 + version: 0.98.0 + '@solana/spl-token': + specifier: 0.4.14 + version: 0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@7.0.2)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2)(utf-8-validate@6.0.6) + '@solana/web3.js': + specifier: 1.98.4 + version: 1.98.4(bufferutil@4.1.0)(typescript@7.0.2)(utf-8-validate@6.0.6) + better-sqlite3: + specifier: 12.10.0 + version: 12.10.0 + bs58: + specifier: 6.0.0 + version: 6.0.0 + electron-updater: + specifier: 6.8.3 + version: 6.8.3 + node-pty: + specifier: 1.1.0 + version: 1.1.0(patch_hash=f41f3f1b27203d2dfc08a004c1c51bc4a56ca84fce763607028e80840c5bcc3e) + tweetnacl: + specifier: 1.0.3 + version: 1.0.3 + +packages: + + '@anthropic-ai/sdk@0.98.0': + resolution: {integrity: sha512-N7aXtCvC5g6T1Y4V29lJjceu/zTkVkIZF0jdBvagr0TRFHuKeImffalGWEfqZKrvjH+IQbzJWw6TmSmUzrlMgg==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@noble/curves@1.9.7': + resolution: {integrity: sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==} + engines: {node: ^14.21.3 || >=16} + + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + + '@solana/buffer-layout-utils@0.2.0': + resolution: {integrity: sha512-szG4sxgJGktbuZYDg2FfNmkMi0DYQoVjN2h7ta1W1hPrwzarcFLBq9UpX1UjNXsNpT9dn+chgprtWGioUAr4/g==} + engines: {node: '>= 10'} + + '@solana/buffer-layout@4.0.1': + resolution: {integrity: sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==} + engines: {node: '>=5.10'} + + '@solana/codecs-core@2.0.0-rc.1': + resolution: {integrity: sha512-bauxqMfSs8EHD0JKESaNmNuNvkvHSuN3bbWAF5RjOfDu2PugxHrvRebmYauvSumZ3cTfQ4HJJX6PG5rN852qyQ==} + peerDependencies: + typescript: '>=5' + + '@solana/codecs-core@2.3.0': + resolution: {integrity: sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.3.3' + + '@solana/codecs-data-structures@2.0.0-rc.1': + resolution: {integrity: sha512-rinCv0RrAVJ9rE/rmaibWJQxMwC5lSaORSZuwjopSUE6T0nb/MVg6Z1siNCXhh/HFTOg0l8bNvZHgBcN/yvXog==} + peerDependencies: + typescript: '>=5' + + '@solana/codecs-numbers@2.0.0-rc.1': + resolution: {integrity: sha512-J5i5mOkvukXn8E3Z7sGIPxsThRCgSdgTWJDQeZvucQ9PT6Y3HiVXJ0pcWiOWAoQ3RX8e/f4I3IC+wE6pZiJzDQ==} + peerDependencies: + typescript: '>=5' + + '@solana/codecs-numbers@2.3.0': + resolution: {integrity: sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==} + engines: {node: '>=20.18.0'} + peerDependencies: + typescript: '>=5.3.3' + + '@solana/codecs-strings@2.0.0-rc.1': + resolution: {integrity: sha512-9/wPhw8TbGRTt6mHC4Zz1RqOnuPTqq1Nb4EyuvpZ39GW6O2t2Q7Q0XxiB3+BdoEjwA2XgPw6e2iRfvYgqty44g==} + peerDependencies: + fastestsmallesttextencoderdecoder: ^1.0.22 + typescript: '>=5' + + '@solana/codecs@2.0.0-rc.1': + resolution: {integrity: sha512-qxoR7VybNJixV51L0G1RD2boZTcxmwUWnKCaJJExQ5qNKwbpSyDdWfFJfM5JhGyKe9DnPVOZB+JHWXnpbZBqrQ==} + peerDependencies: + typescript: '>=5' + + '@solana/errors@2.0.0-rc.1': + resolution: {integrity: sha512-ejNvQ2oJ7+bcFAYWj225lyRkHnixuAeb7RQCixm+5mH4n1IA4Qya/9Bmfy5RAAHQzxK43clu3kZmL5eF9VGtYQ==} + hasBin: true + peerDependencies: + typescript: '>=5' + + '@solana/errors@2.3.0': + resolution: {integrity: sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==} + engines: {node: '>=20.18.0'} + hasBin: true + peerDependencies: + typescript: '>=5.3.3' + + '@solana/options@2.0.0-rc.1': + resolution: {integrity: sha512-mLUcR9mZ3qfHlmMnREdIFPf9dpMc/Bl66tLSOOWxw4ml5xMT2ohFn7WGqoKcu/UHkT9CrC6+amEdqCNvUqI7AA==} + peerDependencies: + typescript: '>=5' + + '@solana/spl-token-group@0.0.7': + resolution: {integrity: sha512-V1N/iX7Cr7H0uazWUT2uk27TMqlqedpXHRqqAbVO2gvmJyT0E0ummMEAVQeXZ05ZhQ/xF39DLSdBp90XebWEug==} + engines: {node: '>=16'} + peerDependencies: + '@solana/web3.js': ^1.95.3 + + '@solana/spl-token-metadata@0.1.6': + resolution: {integrity: sha512-7sMt1rsm/zQOQcUWllQX9mD2O6KhSAtY1hFR2hfFwgqfFWzSY9E9GDvFVNYUI1F0iQKcm6HmePU9QbKRXTEBiA==} + engines: {node: '>=16'} + peerDependencies: + '@solana/web3.js': ^1.95.3 + + '@solana/spl-token@0.4.14': + resolution: {integrity: sha512-u09zr96UBpX4U685MnvQsNzlvw9TiY005hk1vJmJr7gMJldoPG1eYU5/wNEyOA5lkMLiR/gOi9SFD4MefOYEsA==} + engines: {node: '>=16'} + peerDependencies: + '@solana/web3.js': ^1.95.5 + + '@solana/web3.js@1.98.4': + resolution: {integrity: sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==} + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + + '@swc/helpers@0.5.23': + resolution: {integrity: sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/node@12.20.55': + resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} + + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + + '@types/uuid@10.0.0': + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + + '@types/ws@7.4.7': + resolution: {integrity: sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + agentkeepalive@4.6.0: + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==} + engines: {node: '>= 8.0.0'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + base-x@3.0.11: + resolution: {integrity: sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==} + + base-x@5.0.1: + resolution: {integrity: sha512-M7uio8Zt++eg3jPj+rHMfCC+IuygQHHCOU+IYsVtik6FWjuYpVt/+MRKcgsAMHh8mMFAwnB+Bs+mTrFiXjMzKg==} + + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + + better-sqlite3@12.10.0: + resolution: {integrity: sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==} + engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x || 26.x} + + bigint-buffer@1.1.5: + resolution: {integrity: sha512-trfYco6AoZ+rKhKnxA0hgX0HAbVP/s808/EuDSe2JDzUnCp/xAsli35Orvk67UrTEcwuxZqYZDmfA2RXJgxVvA==} + engines: {node: '>= 10.0.0'} + + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + + bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + + bn.js@5.2.5: + resolution: {integrity: sha512-Vq886eXykuP5E6HcKSSStP3bJgrE6In5WKxVUvJ8XGpWWYs2xZHWqUwzCtGgEtBcxyd57KBFDPFoUfNzdaHCNg==} + + borsh@0.7.0: + resolution: {integrity: sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==} + + bs58@4.0.1: + resolution: {integrity: sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==} + + bs58@6.0.0: + resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} + + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + + bufferutil@4.1.0: + resolution: {integrity: sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==} + engines: {node: '>=6.14.2'} + + builder-util-runtime@9.5.1: + resolution: {integrity: sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==} + engines: {node: '>=12.0.0'} + + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + + commander@12.1.0: + resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==} + engines: {node: '>=18'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + + delay@5.0.0: + resolution: {integrity: sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==} + engines: {node: '>=10'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-updater@6.8.3: + resolution: {integrity: sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==} + + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + + es6-promise@4.2.8: + resolution: {integrity: sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==} + + es6-promisify@5.0.0: + resolution: {integrity: sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==} + + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + eyes@0.1.8: + resolution: {integrity: sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==} + engines: {node: '> 0.1.90'} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fast-stable-stringify@1.0.0: + resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} + + fastestsmallesttextencoderdecoder@1.0.22: + resolution: {integrity: sha512-Pb8d48e+oIuY4MaM64Cd7OW1gt4nxCHs7/ddPPZ/Ic3sg8yVGM7O9wDvZ7us6ScaUupzM+pfBolwtYhN1IxBIw==} + + file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + + fs-extra@10.1.0: + resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} + engines: {node: '>=12'} + + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + humanize-ms@1.2.1: + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==} + + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + isomorphic-ws@4.0.1: + resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} + peerDependencies: + ws: '*' + + jayson@4.3.0: + resolution: {integrity: sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==} + engines: {node: '>=8'} + hasBin: true + + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + hasBin: true + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json-stringify-safe@5.0.1: + resolution: {integrity: sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==} + + jsonfile@6.2.1: + resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + + lazy-val@1.0.5: + resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} + + lodash.escaperegexp@4.1.2: + resolution: {integrity: sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==} + + lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead. + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + + node-abi@3.94.0: + resolution: {integrity: sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==} + engines: {node: '>=10'} + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + + node-gyp-build@4.8.4: + resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==} + hasBin: true + + node-pty@1.1.0: + resolution: {integrity: sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg==} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. + hasBin: true + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + + rpc-websockets@9.3.9: + resolution: {integrity: sha512-2iQDaTB4g5fDB2ihrTFSJSibCEuxaRi1q7qTW7ZO9/M5/TC+ToHA4D9/ffNLEbAoHNNrcdeP05oATNk44SKZXA==} + + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + + sax@1.6.0: + resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==} + engines: {node: '>=11.0.0'} + + semver@7.7.4: + resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==} + engines: {node: '>=10'} + hasBin: true + + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + + stream-chain@2.2.5: + resolution: {integrity: sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==} + + stream-json@1.9.1: + resolution: {integrity: sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==} + + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + + superstruct@2.0.2: + resolution: {integrity: sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==} + engines: {node: '>=14.0.0'} + + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + + text-encoding-utf-8@1.0.2: + resolution: {integrity: sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==} + + tiny-typed-emitter@2.1.0: + resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} + + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + + tweetnacl@1.0.3: + resolution: {integrity: sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + utf-8-validate@6.0.6: + resolution: {integrity: sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==} + engines: {node: '>=6.14.2'} + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@14.0.1: + resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==} + hasBin: true + + uuid@8.3.2: + resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + ws@8.21.1: + resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@anthropic-ai/sdk@0.98.0': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + + '@babel/runtime@7.29.7': {} + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@solana/buffer-layout-utils@0.2.0(bufferutil@4.1.0)(typescript@7.0.2)(utf-8-validate@6.0.6)': + dependencies: + '@solana/buffer-layout': 4.0.1 + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@7.0.2)(utf-8-validate@6.0.6) + bigint-buffer: 1.1.5 + bignumber.js: 9.3.1 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + + '@solana/buffer-layout@4.0.1': + dependencies: + buffer: 6.0.3 + + '@solana/codecs-core@2.0.0-rc.1(typescript@7.0.2)': + dependencies: + '@solana/errors': 2.0.0-rc.1(typescript@7.0.2) + typescript: 7.0.2 + + '@solana/codecs-core@2.3.0(typescript@7.0.2)': + dependencies: + '@solana/errors': 2.3.0(typescript@7.0.2) + typescript: 7.0.2 + + '@solana/codecs-data-structures@2.0.0-rc.1(typescript@7.0.2)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@7.0.2) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@7.0.2) + '@solana/errors': 2.0.0-rc.1(typescript@7.0.2) + typescript: 7.0.2 + + '@solana/codecs-numbers@2.0.0-rc.1(typescript@7.0.2)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@7.0.2) + '@solana/errors': 2.0.0-rc.1(typescript@7.0.2) + typescript: 7.0.2 + + '@solana/codecs-numbers@2.3.0(typescript@7.0.2)': + dependencies: + '@solana/codecs-core': 2.3.0(typescript@7.0.2) + '@solana/errors': 2.3.0(typescript@7.0.2) + typescript: 7.0.2 + + '@solana/codecs-strings@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@7.0.2) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@7.0.2) + '@solana/errors': 2.0.0-rc.1(typescript@7.0.2) + fastestsmallesttextencoderdecoder: 1.0.22 + typescript: 7.0.2 + + '@solana/codecs@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@7.0.2) + '@solana/codecs-data-structures': 2.0.0-rc.1(typescript@7.0.2) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@7.0.2) + '@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2) + '@solana/options': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2) + typescript: 7.0.2 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/errors@2.0.0-rc.1(typescript@7.0.2)': + dependencies: + chalk: 5.6.2 + commander: 12.1.0 + typescript: 7.0.2 + + '@solana/errors@2.3.0(typescript@7.0.2)': + dependencies: + chalk: 5.6.2 + commander: 14.0.3 + typescript: 7.0.2 + + '@solana/options@2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2)': + dependencies: + '@solana/codecs-core': 2.0.0-rc.1(typescript@7.0.2) + '@solana/codecs-data-structures': 2.0.0-rc.1(typescript@7.0.2) + '@solana/codecs-numbers': 2.0.0-rc.1(typescript@7.0.2) + '@solana/codecs-strings': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2) + '@solana/errors': 2.0.0-rc.1(typescript@7.0.2) + typescript: 7.0.2 + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + + '@solana/spl-token-group@0.0.7(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@7.0.2)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2)': + dependencies: + '@solana/codecs': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@7.0.2)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - typescript + + '@solana/spl-token-metadata@0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@7.0.2)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2)': + dependencies: + '@solana/codecs': 2.0.0-rc.1(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@7.0.2)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - fastestsmallesttextencoderdecoder + - typescript + + '@solana/spl-token@0.4.14(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@7.0.2)(utf-8-validate@6.0.6))(bufferutil@4.1.0)(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2)(utf-8-validate@6.0.6)': + dependencies: + '@solana/buffer-layout': 4.0.1 + '@solana/buffer-layout-utils': 0.2.0(bufferutil@4.1.0)(typescript@7.0.2)(utf-8-validate@6.0.6) + '@solana/spl-token-group': 0.0.7(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@7.0.2)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2) + '@solana/spl-token-metadata': 0.1.6(@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@7.0.2)(utf-8-validate@6.0.6))(fastestsmallesttextencoderdecoder@1.0.22)(typescript@7.0.2) + '@solana/web3.js': 1.98.4(bufferutil@4.1.0)(typescript@7.0.2)(utf-8-validate@6.0.6) + buffer: 6.0.3 + transitivePeerDependencies: + - bufferutil + - encoding + - fastestsmallesttextencoderdecoder + - typescript + - utf-8-validate + + '@solana/web3.js@1.98.4(bufferutil@4.1.0)(typescript@7.0.2)(utf-8-validate@6.0.6)': + dependencies: + '@babel/runtime': 7.29.7 + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + '@solana/buffer-layout': 4.0.1 + '@solana/codecs-numbers': 2.3.0(typescript@7.0.2) + agentkeepalive: 4.6.0 + bn.js: 5.2.5 + borsh: 0.7.0 + bs58: 4.0.1 + buffer: 6.0.3 + fast-stable-stringify: 1.0.0 + jayson: 4.3.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + node-fetch: 2.7.0 + rpc-websockets: 9.3.9 + superstruct: 2.0.2 + transitivePeerDependencies: + - bufferutil + - encoding + - typescript + - utf-8-validate + + '@stablelib/base64@1.0.1': {} + + '@swc/helpers@0.5.23': + dependencies: + tslib: 2.8.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 26.1.1 + + '@types/node@12.20.55': {} + + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + + '@types/uuid@10.0.0': {} + + '@types/ws@7.4.7': + dependencies: + '@types/node': 26.1.1 + + '@types/ws@8.18.1': + dependencies: + '@types/node': 26.1.1 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + agentkeepalive@4.6.0: + dependencies: + humanize-ms: 1.2.1 + + argparse@2.0.1: {} + + base-x@3.0.11: + dependencies: + safe-buffer: 5.2.1 + + base-x@5.0.1: {} + + base64-js@1.5.1: {} + + better-sqlite3@12.10.0: + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + + bigint-buffer@1.1.5: + dependencies: + bindings: 1.5.0 + + bignumber.js@9.3.1: {} + + bindings@1.5.0: + dependencies: + file-uri-to-path: 1.0.0 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + + bn.js@5.2.5: {} + + borsh@0.7.0: + dependencies: + bn.js: 5.2.5 + bs58: 4.0.1 + text-encoding-utf-8: 1.0.2 + + bs58@4.0.1: + dependencies: + base-x: 3.0.11 + + bs58@6.0.0: + dependencies: + base-x: 5.0.1 + + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + bufferutil@4.1.0: + dependencies: + node-gyp-build: 4.8.4 + optional: true + + builder-util-runtime@9.5.1: + dependencies: + debug: 4.4.3 + sax: 1.6.0 + transitivePeerDependencies: + - supports-color + + chalk@5.6.2: {} + + chownr@1.1.4: {} + + commander@12.1.0: {} + + commander@14.0.3: {} + + commander@2.20.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + + delay@5.0.0: {} + + detect-libc@2.1.2: {} + + electron-updater@6.8.3: + dependencies: + builder-util-runtime: 9.5.1 + fs-extra: 10.1.0 + js-yaml: 4.3.0 + lazy-val: 1.0.5 + lodash.escaperegexp: 4.1.2 + lodash.isequal: 4.5.0 + semver: 7.7.4 + tiny-typed-emitter: 2.1.0 + transitivePeerDependencies: + - supports-color + + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + + es6-promise@4.2.8: {} + + es6-promisify@5.0.0: + dependencies: + es6-promise: 4.2.8 + + eventemitter3@5.0.4: {} + + expand-template@2.0.3: {} + + eyes@0.1.8: {} + + fast-sha256@1.3.0: {} + + fast-stable-stringify@1.0.0: {} + + fastestsmallesttextencoderdecoder@1.0.22: {} + + file-uri-to-path@1.0.0: {} + + fs-constants@1.0.0: {} + + fs-extra@10.1.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.2.1 + universalify: 2.0.1 + + github-from-package@0.0.0: {} + + graceful-fs@4.2.11: {} + + humanize-ms@1.2.1: + dependencies: + ms: 2.1.3 + + ieee754@1.2.1: {} + + inherits@2.0.4: {} + + ini@1.3.8: {} + + isomorphic-ws@4.0.1(ws@7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6)): + dependencies: + ws: 7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6) + + jayson@4.3.0(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + '@types/connect': 3.4.38 + '@types/node': 12.20.55 + '@types/ws': 7.4.7 + commander: 2.20.3 + delay: 5.0.0 + es6-promisify: 5.0.0 + eyes: 0.1.8 + isomorphic-ws: 4.0.1(ws@7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + json-stringify-safe: 5.0.1 + stream-json: 1.9.1 + uuid: 8.3.2 + ws: 7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + + json-stringify-safe@5.0.1: {} + + jsonfile@6.2.1: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + lazy-val@1.0.5: {} + + lodash.escaperegexp@4.1.2: {} + + lodash.isequal@4.5.0: {} + + mimic-response@3.1.0: {} + + minimist@1.2.8: {} + + mkdirp-classic@0.5.3: {} + + ms@2.1.3: {} + + napi-build-utils@2.0.0: {} + + node-abi@3.94.0: + dependencies: + semver: 7.8.5 + + node-addon-api@7.1.1: {} + + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + + node-gyp-build@4.8.4: + optional: true + + node-pty@1.1.0(patch_hash=f41f3f1b27203d2dfc08a004c1c51bc4a56ca84fce763607028e80840c5bcc3e): + dependencies: + node-addon-api: 7.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + prebuild-install@7.1.3: + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.94.0 + pump: 3.0.4 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.5 + tunnel-agent: 0.6.0 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + + rpc-websockets@9.3.9: + dependencies: + '@swc/helpers': 0.5.23 + '@types/uuid': 10.0.0 + '@types/ws': 8.18.1 + buffer: 6.0.3 + eventemitter3: 5.0.4 + uuid: 14.0.1 + ws: 8.21.1(bufferutil@4.1.0)(utf-8-validate@6.0.6) + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + + safe-buffer@5.2.1: {} + + sax@1.6.0: {} + + semver@7.7.4: {} + + semver@7.8.5: {} + + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + stream-chain@2.2.5: {} + + stream-json@1.9.1: + dependencies: + stream-chain: 2.2.5 + + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + + strip-json-comments@2.0.1: {} + + superstruct@2.0.2: {} + + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + + text-encoding-utf-8@1.0.2: {} + + tiny-typed-emitter@2.1.0: {} + + tr46@0.0.3: {} + + ts-algebra@2.0.0: {} + + tslib@2.8.1: {} + + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 + + tweetnacl@1.0.3: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.3.0: {} + + universalify@2.0.1: {} + + utf-8-validate@6.0.6: + dependencies: + node-gyp-build: 4.8.4 + optional: true + + util-deprecate@1.0.2: {} + + uuid@14.0.1: {} + + uuid@8.3.2: {} + + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + wrappy@1.0.2: {} + + ws@7.5.13(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 + + ws@8.21.1(bufferutil@4.1.0)(utf-8-validate@6.0.6): + optionalDependencies: + bufferutil: 4.1.0 + utf-8-validate: 6.0.6 diff --git a/build/macPackaging.cjs b/build/macPackaging.cjs new file mode 100644 index 00000000..b2cad2d9 --- /dev/null +++ b/build/macPackaging.cjs @@ -0,0 +1,14 @@ +const SIGNED_ENTITLEMENTS = 'build/entitlements.mac.plist' + +function macSigningConfig(env = process.env) { + const isAdHoc = env.DAEMON_MAC_ADHOC === '1' + return { + isAdHoc, + identity: isAdHoc ? '-' : undefined, + hardenedRuntime: !isAdHoc, + entitlements: isAdHoc ? undefined : SIGNED_ENTITLEMENTS, + artifactName: isAdHoc ? 'DAEMON-unsigned-${arch}.${ext}' : 'DAEMON-${arch}.${ext}', + } +} + +module.exports = { macSigningConfig } diff --git a/build/macos-adhoc-release b/build/macos-adhoc-release new file mode 100644 index 00000000..978c509c --- /dev/null +++ b/build/macos-adhoc-release @@ -0,0 +1 @@ +This macOS build is ad-hoc signed and not Apple-notarized. diff --git a/build/notarize.mjs b/build/notarize.mjs index 82f51718..5bb7ab7d 100644 --- a/build/notarize.mjs +++ b/build/notarize.mjs @@ -1,5 +1,7 @@ import { notarize } from '@electron/notarize' +import { spawnSync } from 'node:child_process' +const REQUIRED_SIGNING_ENV_VARS = ['CSC_LINK', 'CSC_KEY_PASSWORD'] const REQUIRED_ENV_VARS = [ 'APPLE_ID', 'APPLE_APP_SPECIFIC_PASSWORD', @@ -10,13 +12,24 @@ function hasNotarizeEnv() { return REQUIRED_ENV_VARS.every((key) => Boolean(process.env[key])) } -export default async function afterSign(context) { - if (process.platform !== 'darwin') { - return +function isNotarizationRequired() { + return process.env.DAEMON_REQUIRE_MAC_NOTARIZATION === '1' +} + +function missingEnvVars(names) { + return names.filter((key) => !process.env[key]) +} + +function assertDeveloperIdSignature(appPath) { + const result = spawnSync('codesign', ['-dv', '--verbose=4', appPath], { encoding: 'utf8' }) + const details = `${result.stdout ?? ''}\n${result.stderr ?? ''}` + if (result.status !== 0 || !details.includes('Authority=Developer ID Application:')) { + throw new Error('macOS release is not signed with a Developer ID Application certificate') } +} - if (!hasNotarizeEnv()) { - console.log('[notarize] Skipping notarization; missing Apple credentials in environment') +export default async function afterSign(context) { + if (process.platform !== 'darwin') { return } @@ -24,15 +37,31 @@ export default async function afterSign(context) { if (electronPlatformName !== 'darwin') { return } - const appName = packager.appInfo.productFilename const appBundleId = packager.appInfo.id + const appPath = `${appOutDir}/${appName}.app` + + if (!hasNotarizeEnv()) { + if (isNotarizationRequired()) { + throw new Error(`Missing required macOS notarization credentials: ${REQUIRED_ENV_VARS.join(', ')}`) + } + console.log('[notarize] Skipping notarization; missing Apple credentials in environment') + return + } + + if (isNotarizationRequired()) { + const missingSigningVars = missingEnvVars(REQUIRED_SIGNING_ENV_VARS) + if (missingSigningVars.length > 0) { + throw new Error(`Missing required macOS signing credentials: ${missingSigningVars.join(', ')}`) + } + assertDeveloperIdSignature(appPath) + } console.log(`[notarize] Submitting ${appName}.app for notarization`) await notarize({ appBundleId, - appPath: `${appOutDir}/${appName}.app`, + appPath, appleId: process.env.APPLE_ID, appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD, teamId: process.env.APPLE_TEAM_ID, diff --git a/docs/screenshots/editor.webp b/docs/screenshots/editor.webp index 19a64643..e758ec92 100644 Binary files a/docs/screenshots/editor.webp and b/docs/screenshots/editor.webp differ diff --git a/docs/screenshots/ui-overview.webp b/docs/screenshots/ui-overview.webp index 3cfcc050..e758ec92 100644 Binary files a/docs/screenshots/ui-overview.webp and b/docs/screenshots/ui-overview.webp differ diff --git a/docs/screenshots/wallet.webp b/docs/screenshots/wallet.webp index 8852ac07..e758ec92 100644 Binary files a/docs/screenshots/wallet.webp and b/docs/screenshots/wallet.webp differ diff --git a/electron-builder.json b/electron-builder.json index 6614b1ee..2d73bbc8 100644 --- a/electron-builder.json +++ b/electron-builder.json @@ -35,7 +35,10 @@ "category": "public.app-category.developer-tools", "artifactName": "${productName}-${arch}.${ext}", "hardenedRuntime": true, - "gatekeeperAssess": false + "gatekeeperAssess": false, + "notarize": false, + "entitlements": "build/entitlements.mac.plist", + "entitlementsInherit": "build/entitlements.mac.plist" }, "afterSign": "build/notarize.mjs", "win": { diff --git a/electron-builder.lite.cjs b/electron-builder.lite.cjs new file mode 100644 index 00000000..5138a99e --- /dev/null +++ b/electron-builder.lite.cjs @@ -0,0 +1,81 @@ +/** + * Canonical DAEMON packaging for the focused agent workbench. + * Runtime dependencies are installed into an isolated, hoisted staging app + * before packaging so pnpm's version-specific dependency graph stays intact. + */ +const path = require('node:path') +const { macSigningConfig } = require('./build/macPackaging.cjs') +const macSigning = macSigningConfig() + +module.exports = { + appId: 'com.daemon.app', + productName: 'DAEMON', + asar: true, + beforeBuild: async () => false, + compression: macSigning.isAdHoc ? 'normal' : 'maximum', + directories: { + app: 'release-lite/.stage', + output: 'release-lite/${version}', + }, + extraMetadata: { + name: 'daemon', + main: 'dist-electron-lite/main/lite.js', + }, + extraResources: macSigning.isAdHoc + ? [{ from: path.join(__dirname, 'build/macos-adhoc-release'), to: 'macos-adhoc-release' }] + : undefined, + publish: [{ provider: 'github', owner: 'nullxnothing', repo: 'daemon' }], + files: [ + 'dist-electron-lite/**', + 'dist-lite/**', + '!**/*.map', + { + from: path.join(__dirname, 'release-lite/.stage/node_modules'), + to: 'node_modules', + filter: [ + '**/*', + '!@types{,/**/*}', + '!**/.bin{,/**/*}', + '!**/*.map', + '!better-sqlite3/{deps,src}/**', + '!better-sqlite3/build/Release/{obj,sqlite3.a,test_extension.node}', + ], + }, + ], + electronLanguages: ['en-US'], + asarUnpack: [ + 'node_modules/better-sqlite3/**', + 'node_modules/node-pty/**', + ], + mac: { + icon: 'build/icon.icns', + target: ['dmg', 'zip'], + category: 'public.app-category.developer-tools', + artifactName: macSigning.artifactName, + identity: macSigning.identity, + hardenedRuntime: macSigning.hardenedRuntime, + gatekeeperAssess: false, + notarize: false, + entitlements: macSigning.entitlements, + entitlementsInherit: macSigning.entitlements, + }, + afterSign: 'build/notarize.mjs', + win: { + icon: 'resources/icon.ico', + target: [ + { + target: 'nsis', + arch: ['x64'], + }, + ], + artifactName: 'DAEMON-setup.${ext}', + }, + nsis: { + oneClick: false, + perMachine: false, + allowToChangeInstallationDirectory: true, + deleteAppDataOnUninstall: false, + createDesktopShortcut: true, + createStartMenuShortcut: true, + }, +} diff --git a/electron/ipc/claude.ts b/electron/ipc/claude.ts index 75e9d3b3..f4d5c406 100644 --- a/electron/ipc/claude.ts +++ b/electron/ipc/claude.ts @@ -15,6 +15,7 @@ import { broadcast } from '../services/EventBus' import { getDb } from '../db/db' import { isPathSafe } from '../shared/pathValidation' import { ipcHandler, withValidation } from '../services/IpcHandlerFactory' +import { registerSecureKeyHandlers } from './secureKeys' import { restartProviderInPty, restartAllProviderSessions } from '../shared/providerRestart' import type { McpAddInput } from '../shared/types' @@ -162,19 +163,9 @@ ${content}`, return tidied.replace(/^```(?:markdown|md)?\s*\n?/, '').replace(/\n?```\s*$/, '') })) - // --- Secure Keys --- + // --- Secure Keys (extracted; Lite registers them without this module) --- - ipcMain.handle('claude:store-key', ipcHandler(async (_event, name: string, value: string) => { - SecureKey.storeKey(name, value) - })) - - ipcMain.handle('claude:list-keys', ipcHandler(async () => { - return SecureKey.listKeys() - })) - - ipcMain.handle('claude:delete-key', ipcHandler(async (_event, name: string) => { - SecureKey.deleteKey(name) - })) + registerSecureKeyHandlers() // --- CLAUDE.md --- diff --git a/electron/ipc/filesystem.lite.ts b/electron/ipc/filesystem.lite.ts new file mode 100644 index 00000000..10c87a0c --- /dev/null +++ b/electron/ipc/filesystem.lite.ts @@ -0,0 +1,182 @@ +import { ipcMain } from 'electron' +import fs from 'node:fs/promises' +import fsSync from 'node:fs' +import path from 'node:path' +import { getDb } from '../db/db' +import { ipcHandler } from '../services/IpcHandlerFactory' +import type { FileEntry } from '../shared/types' + +const IGNORED_NAMES = new Set([ + 'node_modules', '.git', 'dist', 'dist-electron', '.next', 'target', + 'coverage', '.anchor', '.cache', '.turbo', '.vite', '.pnpm-store', +]) +const MAX_READ_DIR_DEPTH = 4 +const MAX_READ_DIR_ENTRIES = 1_000 +const MAX_TEXT_BYTES = 2 * 1024 * 1024 +const WATCH_DEBOUNCE_MS = 200 +const TEXT_SAMPLE_BYTES = 8 * 1024 + +interface ActiveWatcher { + rootPath: string + senderId: number + watcher: fsSync.FSWatcher + debounceTimer: NodeJS.Timeout | null +} + +let activeWatcher: ActiveWatcher | null = null + +function normalizedPath(value: string): string { + const resolved = path.resolve(value) + return process.platform === 'win32' ? resolved.toLowerCase() : resolved +} + +function isWithinRoot(target: string, root: string): boolean { + const normalizedTarget = normalizedPath(target) + const normalizedRoot = normalizedPath(root) + return normalizedTarget === normalizedRoot || normalizedTarget.startsWith(`${normalizedRoot}${path.sep}`) +} + +async function registeredRootFor(targetPath: string): Promise<{ target: string; root: string }> { + if (typeof targetPath !== 'string' || !targetPath.trim()) throw new Error('File path is required') + const target = await fs.realpath(path.resolve(targetPath)).catch(() => null) + if (!target) throw new Error('Path does not exist') + + const rows = getDb().prepare('SELECT path FROM projects').all() as Array<{ path: string }> + for (const row of rows) { + const root = await fs.realpath(path.resolve(row.path)).catch(() => null) + if (root && isWithinRoot(target, root)) return { target, root } + } + throw new Error('Path outside registered project boundaries') +} + +function isSecretWritePath(filePath: string): boolean { + const name = path.basename(filePath).toLowerCase() + if (name === '.env' || (name.startsWith('.env.') && !['.env.example', '.env.sample', '.env.template'].includes(name))) return true + if (/keypair.*\.json$/i.test(name)) return true + if (/\.(?:pem|key|p12|pfx)$/i.test(name)) return true + return /^(?:secret|secrets|seed|mnemonic)(?:\.[^.]+)?$/i.test(name) +} + +function isProbablyBinary(buffer: Buffer): boolean { + const sample = buffer.subarray(0, Math.min(buffer.length, TEXT_SAMPLE_BYTES)) + if (sample.includes(0)) return true + if (sample.length === 0) return false + + let controlBytes = 0 + for (const byte of sample) { + if (byte < 32 && byte !== 9 && byte !== 10 && byte !== 13) controlBytes += 1 + } + return controlBytes / sample.length > 0.1 +} + +function decodeText(buffer: Buffer): string { + if (isProbablyBinary(buffer)) throw new Error('Binary files cannot be opened in Lite Workbench') + try { + return new TextDecoder('utf-8', { fatal: true }).decode(buffer) + } catch { + throw new Error('File is not valid UTF-8 text') + } +} + +function validateWriteContent(content: unknown): Buffer { + if (typeof content !== 'string') throw new Error('File content must be text') + if (content.includes('\0')) throw new Error('Binary content cannot be written in Lite Workbench') + const buffer = Buffer.from(content, 'utf8') + if (buffer.length > MAX_TEXT_BYTES) throw new Error('File too large (>2MB)') + return buffer +} + +function stopWatcher(): void { + if (!activeWatcher) return + if (activeWatcher.debounceTimer) clearTimeout(activeWatcher.debounceTimer) + try { activeWatcher.watcher.close() } catch { /* already closed */ } + activeWatcher = null +} + +function isIgnoredChange(relativePath: string | null): boolean { + if (!relativePath) return false + return relativePath.split(/[\\/]/).some((segment) => IGNORED_NAMES.has(segment)) +} + +function startWatcher(event: Electron.IpcMainInvokeEvent, rootPath: string, canonicalRoot: string): void { + stopWatcher() + const watcher = fsSync.watch(canonicalRoot, { recursive: true }, (_eventType, filename) => { + const relativePath = filename == null ? null : filename.toString() + if (isIgnoredChange(relativePath) || activeWatcher?.watcher !== watcher) return + if (activeWatcher.debounceTimer) clearTimeout(activeWatcher.debounceTimer) + activeWatcher.debounceTimer = setTimeout(() => { + if (activeWatcher?.watcher !== watcher || activeWatcher.senderId !== event.sender.id || event.sender.isDestroyed()) return + event.sender.send('fs:changed', { rootPath }) + }, WATCH_DEBOUNCE_MS) + }) + watcher.on('error', () => { + if (activeWatcher?.watcher === watcher) stopWatcher() + }) + activeWatcher = { rootPath, senderId: event.sender.id, watcher, debounceTimer: null } +} + +async function readDirectory(dirPath: string, depth: number, remaining: { value: number }): Promise { + if (depth <= 0 || remaining.value <= 0) return [] + const items = await fs.readdir(dirPath, { withFileTypes: true }) + items.sort((left, right) => { + if (left.isDirectory() !== right.isDirectory()) return left.isDirectory() ? -1 : 1 + return left.name.localeCompare(right.name) + }) + + const entries: FileEntry[] = [] + for (const item of items) { + if (IGNORED_NAMES.has(item.name) || remaining.value <= 0) continue + const entryPath = path.join(dirPath, item.name) + const entry: FileEntry = { name: item.name, path: entryPath, isDirectory: item.isDirectory() } + remaining.value -= 1 + if (item.isDirectory() && !item.isSymbolicLink() && depth > 1) { + entry.children = await readDirectory(entryPath, depth - 1, remaining) + } + entries.push(entry) + } + return entries +} + +export function registerLiteFilesystemHandlers(): void { + ipcMain.handle('fs:readDir', ipcHandler(async (_event, dirPath: string, depth = 1) => { + const { target } = await registeredRootFor(dirPath) + const stats = await fs.stat(target) + if (!stats.isDirectory()) throw new Error('Path is not a directory') + const safeDepth = Math.max(1, Math.min(Number.isInteger(depth) ? depth : 1, MAX_READ_DIR_DEPTH)) + return readDirectory(target, safeDepth, { value: MAX_READ_DIR_ENTRIES }) + })) + + ipcMain.handle('fs:readFile', ipcHandler(async (_event, filePath: string) => { + const { target } = await registeredRootFor(filePath) + const stats = await fs.stat(target) + if (!stats.isFile()) throw new Error('Path is not a file') + if (stats.size > MAX_TEXT_BYTES) throw new Error('File too large (>2MB)') + return { content: decodeText(await fs.readFile(target)), path: target } + })) + + ipcMain.handle('fs:writeFile', ipcHandler(async (_event, filePath: string, content: string) => { + const { target } = await registeredRootFor(filePath) + const stats = await fs.stat(target) + if (!stats.isFile()) throw new Error('Path is not a file') + if (isSecretWritePath(target)) throw new Error('Lite Workbench refuses to write secret or keypair files') + const buffer = validateWriteContent(content) + await fs.writeFile(target, buffer) + })) + + ipcMain.handle('fs:watch', ipcHandler(async (event, rootPath: string) => { + const { target, root } = await registeredRootFor(rootPath) + if (normalizedPath(target) !== normalizedPath(root)) throw new Error('Only a registered project root can be watched') + startWatcher(event, rootPath, root) + })) + + ipcMain.handle('fs:unwatch', ipcHandler(async (event) => { + if (activeWatcher && activeWatcher.senderId !== event.sender.id) { + throw new Error('Project watcher belongs to another window') + } + stopWatcher() + })) +} + +export function stopLiteFilesystemWatcher(): void { + stopWatcher() +} diff --git a/electron/ipc/forensics.lite.ts b/electron/ipc/forensics.lite.ts new file mode 100644 index 00000000..d54b0a44 --- /dev/null +++ b/electron/ipc/forensics.lite.ts @@ -0,0 +1,34 @@ +/** + * DAEMON Lite forensics IPC — swapped in for forensics.ts by vite.lite.config.ts. + * Registers scan/expand/blacklist/poll only. Drops the RicoMaps embed handlers + * (RicoMapsEmbedService spawns a Node dev-server child process — out of scope + * for Lite, and it keeps that import off the lite main graph). + */ +import { clipboard, ipcMain } from 'electron' +import { ipcHandler } from '../services/IpcHandlerFactory' +import * as RicoMapsService from '../services/RicoMapsService' +import type { ForensicsExpandInput, ForensicsScanInput } from '../shared/types' + +export function registerForensicsHandlers() { + ipcMain.handle('forensics:scan', ipcHandler(async (_event, input: ForensicsScanInput) => { + return RicoMapsService.scan(input) + })) + + ipcMain.handle('forensics:expand', ipcHandler(async (_event, input: ForensicsExpandInput) => { + return RicoMapsService.expandNode(input) + })) + + ipcMain.handle('forensics:blacklist', ipcHandler(async () => { + return RicoMapsService.listBlacklist() + })) + + ipcMain.handle('forensics:export-blacklist', ipcHandler(async () => { + const csv = RicoMapsService.exportBlacklistCsv() + clipboard.writeText(csv) + return { csv, copied: true } + })) + + ipcMain.handle('forensics:poll-holders', ipcHandler(async (_event, mint: string) => { + return RicoMapsService.pollHolders(mint) + })) +} diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index ddeab240..0977ecd8 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -122,6 +122,24 @@ export function registerGitHandlers() { await git.commit(message) })) + // Deterministic init + stage-all + initial commit for a freshly scaffolded project. + // Ordered and error-checked in one place (the shell-chained equivalent in a startup + // command was fragile: a `.git` created by init but a failed commit left an unborn + // HEAD, which a swarm can't branch from). Idempotent: skips if a commit already exists. + ipcMain.handle('git:init-commit', ipcHandler(async (_event, cwd: string, message: string) => { + validateCwd(cwd) + const git = simpleGit(cwd) + await ensureGitRepository(cwd) + // Already has a commit? Nothing to do. + try { + await git.revparse(['HEAD']) + return { committed: false, reason: 'already has commits' } + } catch { /* unborn HEAD — proceed to first commit */ } + await git.add(['-A']) + await git.commit(message.trim() || 'chore: initial scaffold') + return { committed: true } + })) + ipcMain.handle('git:push', ipcHandler(async (_event, cwd: string) => { validateCwd(cwd) const ensured = await ensureGitRepository(cwd) diff --git a/electron/ipc/lite.ts b/electron/ipc/lite.ts new file mode 100644 index 00000000..cbb016cb --- /dev/null +++ b/electron/ipc/lite.ts @@ -0,0 +1,74 @@ +/** + * DAEMON Lite IPC — flavor info, first-run flag, and the "Open in DAEMON IDE" + * handoff. Registered only by the Lite main entry (electron/main/lite.ts). + */ +import { ipcMain, app } from 'electron' +import { spawn } from 'node:child_process' +import fs from 'node:fs' +import path from 'node:path' +import { createRequire } from 'node:module' +import { ipcHandler } from '../services/IpcHandlerFactory' +import { getBooleanSetting, setBooleanSetting } from '../services/SettingsService' +import { openSafeExternalUrl } from '../security/externalNavigation' + +const IDE_DOWNLOAD_URL = 'https://daemon-landing.vercel.app' +const LITE_ONBOARDING_KEY = 'lite_onboarding_complete' +const LITE_SHOW_TOOLS_KEY = 'lite_show_tools' + +/** Packaged: the installer's version. Dev: app.getVersion() is Electron's own + * version, so read the repo package.json instead. */ +function appVersion(): string { + if (app.isPackaged) return app.getVersion() + try { + const require = createRequire(import.meta.url) + return (require(path.join(process.env.APP_ROOT ?? '', 'package.json')) as { version: string }).version + } catch { + return app.getVersion() + } +} + +/** Full-DAEMON NSIS per-user install location; null when not installed. */ +function fullIdeExePath(): string | null { + const base = process.env.LOCALAPPDATA + if (!base) return null + const exe = path.join(base, 'Programs', 'DAEMON', 'DAEMON.exe') + return fs.existsSync(exe) ? exe : null +} + +export function registerLiteHandlers() { + ipcMain.handle('lite:get-flavor-info', ipcHandler(async () => ({ + flavor: 'lite' as const, + version: appVersion(), + ideInstalled: fullIdeExePath() !== null, + }))) + + ipcMain.handle('lite:is-onboarding-complete', ipcHandler(async () => { + return getBooleanSetting(LITE_ONBOARDING_KEY, false) + })) + + ipcMain.handle('lite:set-onboarding-complete', ipcHandler(async (_event, complete: boolean) => { + setBooleanSetting(LITE_ONBOARDING_KEY, Boolean(complete)) + })) + + // "Tools" section (wallet / trade / scanner) — off by default so a fresh + // install stays a plain chatbox until the user (or an agent tool) opts in. + ipcMain.handle('lite:get-show-tools', ipcHandler(async () => { + return getBooleanSetting(LITE_SHOW_TOOLS_KEY, false) + })) + + ipcMain.handle('lite:set-show-tools', ipcHandler(async (_event, show: boolean) => { + setBooleanSetting(LITE_SHOW_TOOLS_KEY, Boolean(show)) + })) + + // Launch the full IDE when installed; otherwise open the download page. + ipcMain.handle('lite:open-in-ide', ipcHandler(async () => { + const exe = fullIdeExePath() + if (exe) { + const child = spawn(exe, [], { detached: true, stdio: 'ignore' }) + child.unref() + return { launched: true } + } + await openSafeExternalUrl(IDE_DOWNLOAD_URL) + return { launched: false } + })) +} diff --git a/electron/ipc/memeStudio.ts b/electron/ipc/memeStudio.ts new file mode 100644 index 00000000..b29f338a --- /dev/null +++ b/electron/ipc/memeStudio.ts @@ -0,0 +1,10 @@ +import { ipcMain } from 'electron' +import { ipcHandler } from '../services/IpcHandlerFactory' +import { detectMemeTechArchetype } from '../services/meme-studio/ArchetypeDetector' +import { readMemeMarketContext, readTokenRiskPreflight } from '../services/meme-studio/MarketContextService' + +export function registerMemeStudioHandlers(): void { + ipcMain.handle('meme-studio:detect', ipcHandler(async (_event, projectPath: string) => detectMemeTechArchetype(projectPath))) + ipcMain.handle('meme-studio:market-context', ipcHandler(async (_event, mint: string) => readMemeMarketContext(mint))) + ipcMain.handle('meme-studio:token-preflight', ipcHandler(async (_event, mint: string) => readTokenRiskPreflight(mint))) +} diff --git a/electron/ipc/popout.ts b/electron/ipc/popout.ts new file mode 100644 index 00000000..1f4456f6 --- /dev/null +++ b/electron/ipc/popout.ts @@ -0,0 +1,51 @@ +/** + * DAEMON Lite pop-out browser IPC. lite:popout-open is called from the main + * renderer (trusted). The popout:* nav channels are called from each pop-out's + * own chrome renderer via the minimal popout preload; they carry the window id + * so a chrome window can only drive its own guest. + */ +import { BrowserWindow, ipcMain } from 'electron' +import type { WebContents } from 'electron' +import { ipcHandler } from '../services/IpcHandlerFactory' +import { isTrustedSender } from '../security/ipcSender' +import { + openPopout, popoutNavigate, popoutBack, popoutForward, popoutReload, +} from '../services/PopoutBrowserService' + +function ownerWindowId(sender: WebContents): number | undefined { + return BrowserWindow.fromWebContents(sender)?.id +} + +export function registerPopoutHandlers() { + ipcMain.handle('lite:popout-open', ipcHandler(async (event, url: string) => { + if (!isTrustedSender(event)) return { opened: false } + const result = openPopout(url) + return { opened: result.opened } + })) + + // Nav channels come from the chrome renderer (its own trusted origin); each + // scopes to the sender window's id so it can only steer its own guest. + ipcMain.handle('popout:navigate', ipcHandler(async (event, url: string) => { + if (!isTrustedSender(event)) return false + const windowId = ownerWindowId(event.sender) + return windowId !== undefined ? popoutNavigate(windowId, url) : false + })) + + ipcMain.on('popout:back', (event) => { + if (!isTrustedSender(event)) return + const windowId = ownerWindowId(event.sender) + if (windowId !== undefined) popoutBack(windowId) + }) + + ipcMain.on('popout:forward', (event) => { + if (!isTrustedSender(event)) return + const windowId = ownerWindowId(event.sender) + if (windowId !== undefined) popoutForward(windowId) + }) + + ipcMain.on('popout:reload', (event) => { + if (!isTrustedSender(event)) return + const windowId = ownerWindowId(event.sender) + if (windowId !== undefined) popoutReload(windowId) + }) +} diff --git a/electron/ipc/projects.lite.ts b/electron/ipc/projects.lite.ts new file mode 100644 index 00000000..3f46bf7d --- /dev/null +++ b/electron/ipc/projects.lite.ts @@ -0,0 +1,111 @@ +import { dialog, ipcMain } from 'electron' +import fs from 'node:fs/promises' +import path from 'node:path' +import { getDb } from '../db/db' +import { ipcHandler } from '../services/IpcHandlerFactory' +import { invalidatePathCache } from '../shared/pathValidation' +import type { Project, ProjectCreateInput } from '../shared/types' + +const PICK_CAPABILITY_TTL_MS = 2 * 60 * 1000 +const MAX_PROJECT_NAME_LENGTH = 120 + +interface PickCapability { + path: string + senderId: number + expiresAt: number +} + +let pendingPick: PickCapability | null = null + +function normalizedPath(value: string): string { + const resolved = path.resolve(value) + return process.platform === 'win32' ? resolved.toLowerCase() : resolved +} + +async function canonicalDirectory(value: string): Promise { + const resolved = await fs.realpath(path.resolve(value)) + const stats = await fs.stat(resolved) + if (!stats.isDirectory()) throw new Error('Selected project path is not a directory') + return resolved +} + +function validateProjectName(value: unknown): string { + if (typeof value !== 'string') throw new Error('Project name is required') + const name = value.trim() + if (!name) throw new Error('Project name is required') + if (name.length > MAX_PROJECT_NAME_LENGTH) throw new Error('Project name is too long') + return name +} + +function consumePick(senderId: number): PickCapability { + const capability = pendingPick + pendingPick = null + if (!capability || capability.expiresAt < Date.now()) { + throw new Error('Choose the project folder again before importing it') + } + if (capability.senderId !== senderId) { + throw new Error('Project folder selection belongs to another window') + } + return capability +} + +function insertProject(name: string, projectPath: string): Project { + const db = getDb() + const existing = db.prepare('SELECT * FROM projects WHERE path = ?').get(projectPath) as Project | undefined + if (existing) return existing + + const id = crypto.randomUUID() + const now = Date.now() + db.prepare('INSERT INTO projects (id, name, path, last_active) VALUES (?,?,?,?)') + .run(id, name, projectPath, now) + invalidatePathCache() + return db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as Project +} + +export function registerLiteProjectHandlers(): void { + ipcMain.handle('projects:list', ipcHandler(async () => { + return getDb() + .prepare('SELECT * FROM projects ORDER BY pinned DESC, last_active DESC, created_at DESC') + .all() as Project[] + })) + + ipcMain.handle('projects:openDialog', ipcHandler(async (event) => { + pendingPick = null + const smokePath = process.env.DAEMON_SMOKE_TEST === '1' + ? process.env.DAEMON_SMOKE_PROJECT_DIALOG_PATH?.trim() + : null + let selectedPath = smokePath || null + if (!selectedPath) { + const result = await dialog.showOpenDialog({ + properties: ['openDirectory'], + title: 'Select Project Folder', + }) + if (result.canceled || !result.filePaths.length) return null + selectedPath = result.filePaths[0] + } + + const projectPath = await canonicalDirectory(selectedPath) + pendingPick = { + path: projectPath, + senderId: event.sender.id, + expiresAt: Date.now() + PICK_CAPABILITY_TTL_MS, + } + return projectPath + })) + + ipcMain.handle('projects:create', ipcHandler(async (event, input: ProjectCreateInput & { requireNewDirectory?: boolean }) => { + if (!input || typeof input.path !== 'string') throw new Error('Project path is required') + if (input.requireNewDirectory) throw new Error('Lite can only import an existing selected folder') + + const capability = consumePick(event.sender.id) + const projectPath = await canonicalDirectory(input.path) + if (normalizedPath(projectPath) !== normalizedPath(capability.path)) { + throw new Error('Project path does not match the selected folder') + } + return insertProject(validateProjectName(input.name), capability.path) + })) +} + +export function clearLiteProjectPickCapability(): void { + pendingPick = null +} diff --git a/electron/ipc/secureKeys.ts b/electron/ipc/secureKeys.ts new file mode 100644 index 00000000..b491aa3c --- /dev/null +++ b/electron/ipc/secureKeys.ts @@ -0,0 +1,24 @@ +/** + * Secure-key IPC — OS-keychain-encrypted key storage (SecureKeyService). + * Extracted from claude.ts so shells that need key management without the + * Claude-CLI machinery (DAEMON Lite) can register just this surface. Channel + * names keep their historical claude: prefix — the preload bridge and every + * renderer call site depend on them. + */ +import { ipcMain } from 'electron' +import * as SecureKey from '../services/SecureKeyService' +import { ipcHandler } from '../services/IpcHandlerFactory' + +export function registerSecureKeyHandlers() { + ipcMain.handle('claude:store-key', ipcHandler(async (_event, name: string, value: string) => { + SecureKey.storeKey(name, value) + })) + + ipcMain.handle('claude:list-keys', ipcHandler(async () => { + return SecureKey.listKeys() + })) + + ipcMain.handle('claude:delete-key', ipcHandler(async (_event, name: string) => { + SecureKey.deleteKey(name) + })) +} diff --git a/electron/ipc/terminal.lite.ts b/electron/ipc/terminal.lite.ts new file mode 100644 index 00000000..6a7d746f --- /dev/null +++ b/electron/ipc/terminal.lite.ts @@ -0,0 +1,195 @@ +import { clipboard, ipcMain } from 'electron' +import { execFileSync } from 'node:child_process' +import * as pty from 'node-pty' +import { isTrustedSender } from '../security/ipcSender' +import { ipcHandler } from '../services/IpcHandlerFactory' +import { validateCwd } from '../shared/pathValidation' +import type { TerminalCreateInput, TerminalCreateOutput } from '../shared/types' + +const DEFAULT_COLS = 120 +const DEFAULT_ROWS = 30 +const MAX_BUFFERED_CHUNKS = 200 +const MAX_INPUT_LENGTH = 64 * 1024 +const MIN_COLS = 2 +const MAX_COLS = 500 +const MIN_ROWS = 1 +const MAX_ROWS = 300 + +interface LiteTerminalSession { + pty: pty.IPty + senderId: number + send: (channel: string, payload: unknown) => void + isRendererReady: boolean + bufferedData: string[] + pendingStartupCommand: string | null +} + +const sessions = new Map() + +function ownsSession(senderId: number, session: LiteTerminalSession | undefined): session is LiteTerminalSession { + return Boolean(session && session.senderId === senderId) +} + +function getShell(): { executable: string; args: string[] } { + if (process.platform === 'win32') return { executable: 'powershell.exe', args: ['-NoLogo'] } + return { executable: process.env.SHELL || '/bin/bash', args: [] } +} + +function killProcessTree(session: LiteTerminalSession): void { + if (process.platform === 'win32' && session.pty.pid) { + try { + execFileSync('taskkill.exe', ['/pid', String(session.pty.pid), '/t', '/f'], { + stdio: 'ignore', + windowsHide: true, + timeout: 5_000, + }) + } catch { + try { session.pty.kill() } catch { /* process already exited */ } + } + try { (session.pty as pty.IPty & { _close?: () => void })._close?.() } catch { /* already closed */ } + return + } + + try { session.pty.kill() } catch { /* process already exited */ } +} + +function removeSession(id: string): LiteTerminalSession | null { + const session = sessions.get(id) + if (!session) return null + sessions.delete(id) + return session +} + +function validateDimensions(cols: number, rows: number): boolean { + return Number.isInteger(cols) + && Number.isInteger(rows) + && cols >= MIN_COLS + && cols <= MAX_COLS + && rows >= MIN_ROWS + && rows <= MAX_ROWS +} + +function createSession( + id: string, + event: Electron.IpcMainInvokeEvent, + cwd: string, + startupCommand: string | null, +): LiteTerminalSession { + const shell = getShell() + const terminal = pty.spawn(shell.executable, shell.args, { + name: 'xterm-256color', + cols: DEFAULT_COLS, + rows: DEFAULT_ROWS, + cwd, + env: { ...process.env, TERM: 'xterm-256color' } as Record, + }) + const session: LiteTerminalSession = { + pty: terminal, + senderId: event.sender.id, + send: (channel, payload) => { + if (!event.sender.isDestroyed()) event.sender.send(channel, payload) + }, + isRendererReady: false, + bufferedData: [], + pendingStartupCommand: startupCommand, + } + sessions.set(id, session) + + terminal.onData((data) => { + if (session.isRendererReady) { + session.send('terminal:data', { id, data }) + return + } + session.bufferedData.push(data) + if (session.bufferedData.length > MAX_BUFFERED_CHUNKS) { + session.bufferedData = session.bufferedData.slice(-MAX_BUFFERED_CHUNKS) + } + }) + + terminal.onExit(({ exitCode }) => { + if (sessions.get(id) !== session) return + sessions.delete(id) + session.send('terminal:exit', { id, exitCode }) + }) + + return session +} + +export function registerLiteTerminalHandlers(): void { + ipcMain.handle('terminal:create', ipcHandler(async (event, opts: TerminalCreateInput) => { + const cwd = opts?.cwd?.trim() + if (!cwd) throw new Error('Terminal cwd is required') + validateCwd(cwd) + + const startupCommand = opts?.startupCommand?.trim() || null + if (startupCommand && startupCommand.length > MAX_INPUT_LENGTH) { + throw new Error('Terminal startup command is too large') + } + + const id = crypto.randomUUID() + const session = createSession(id, event, cwd, startupCommand) + const response: TerminalCreateOutput = { id, pid: session.pty.pid, agentId: null } + return response + })) + + ipcMain.on('terminal:write', (event, id: string, data: string) => { + if (!isTrustedSender(event) || typeof data !== 'string' || data.length > MAX_INPUT_LENGTH) return + const session = sessions.get(id) + if (!ownsSession(event.sender.id, session)) return + session.pty.write(data) + }) + + ipcMain.on('terminal:resize', (event, id: string, cols: number, rows: number) => { + if (!isTrustedSender(event) || !validateDimensions(cols, rows)) return + const session = sessions.get(id) + if (!ownsSession(event.sender.id, session)) return + try { session.pty.resize(cols, rows) } catch { /* process may have exited */ } + }) + + ipcMain.on('terminal:ready', (event, id: string, cols?: number, rows?: number) => { + if (!isTrustedSender(event)) return + const session = sessions.get(id) + if (!ownsSession(event.sender.id, session)) return + + if (cols !== undefined && rows !== undefined && validateDimensions(cols, rows)) { + try { session.pty.resize(cols, rows) } catch { /* process may have exited */ } + } + session.isRendererReady = true + const bufferedData = session.bufferedData + session.bufferedData = [] + for (const data of bufferedData) session.send('terminal:data', { id, data }) + if (session.pendingStartupCommand) { + session.pty.write(`${session.pendingStartupCommand}\r`) + session.pendingStartupCommand = null + } + }) + + ipcMain.handle('terminal:kill', ipcHandler(async (event, id: string) => { + const session = sessions.get(id) + if (!ownsSession(event.sender.id, session)) throw new Error('Terminal session not found') + removeSession(id) + killProcessTree(session) + return { killed: true } + })) + + ipcMain.handle('terminal:paste-from-clipboard', ipcHandler(async (event, id: string) => { + const session = sessions.get(id) + if (!ownsSession(event.sender.id, session)) throw new Error('Terminal session not found') + const text = clipboard.readText() + if (!text) return { pasted: false } + if (text.length > MAX_INPUT_LENGTH) throw new Error('Clipboard text is too large') + session.pty.write(text) + return { pasted: true } + })) +} + +export function killAllLiteTerminalSessions(): void { + for (const [id, session] of sessions) { + sessions.delete(id) + killProcessTree(session) + } +} + +export function getLiteTerminalSessionCount(): number { + return sessions.size +} diff --git a/electron/ipc/validator.ts b/electron/ipc/validator.ts index 20364a75..fd4ff4a6 100644 --- a/electron/ipc/validator.ts +++ b/electron/ipc/validator.ts @@ -8,6 +8,15 @@ import * as SolanaDetector from '../services/SolanaDetector' let validatorPty: pty.IPty | null = null let validatorTerminalId: string | null = null +export function stopValidatorProcess(): void { + if (validatorPty) { + try { validatorPty.kill() } catch { /* process already exited */ } + validatorPty = null + } + validatorTerminalId = null + ValidatorManager.reset() +} + export function registerValidatorHandlers() { ipcMain.handle('validator:start', ipcHandler(async (_event, type: 'surfpool' | 'test-validator') => { if (validatorPty) { @@ -84,11 +93,8 @@ export function registerValidatorHandlers() { ipcMain.handle('validator:stop', ipcHandler(async () => { if (validatorPty) { ValidatorManager.setState({ status: 'stopping' }) - try { validatorPty.kill() } catch { /* ignore */ } - validatorPty = null - validatorTerminalId = null } - ValidatorManager.reset() + stopValidatorProcess() return { stopped: true } })) diff --git a/electron/main/autoUpdatePolicy.ts b/electron/main/autoUpdatePolicy.ts new file mode 100644 index 00000000..4e438f30 --- /dev/null +++ b/electron/main/autoUpdatePolicy.ts @@ -0,0 +1,13 @@ +type AutoUpdateContext = { + isPackaged: boolean + isDisabled: boolean + isSmokeTest: boolean + isAdHocMacBuild: boolean +} + +export function shouldEnableAutoUpdate(context: AutoUpdateContext) { + return context.isPackaged + && !context.isDisabled + && !context.isSmokeTest + && !context.isAdHocMacBuild +} diff --git a/electron/main/lite.ts b/electron/main/lite.ts new file mode 100644 index 00000000..65d10286 --- /dev/null +++ b/electron/main/lite.ts @@ -0,0 +1,280 @@ +/** + * DAEMON Lite main entry. Deliberately composed instead of forking + * main/index.ts: one window, a focused project/filesystem/terminal/Solana + * workflow surface, separate userData, and no packs, bridge, or auto-update. + */ +import 'dotenv/config' +import { app, BrowserWindow, ipcMain, session } from 'electron' +import { fileURLToPath } from 'node:url' +import path from 'node:path' +import crypto from 'node:crypto' +import { existsSync } from 'node:fs' +import { getDb, closeDb } from '../db/db' +import { registerAriaHandlers } from '../ipc/aria' +import { registerProviderHandlers } from '../ipc/provider' +import { registerMemoryHandlers } from '../ipc/memory' +import { registerSecureKeyHandlers } from '../ipc/secureKeys' +import { registerLiteHandlers } from '../ipc/lite' +import { registerWalletHandlers } from '../ipc/wallet' +import { registerPnlHandlers } from '../ipc/pnl' +import { registerForensicsHandlers } from '../ipc/forensics' +import { registerPopoutHandlers } from '../ipc/popout' +import { registerLiteFilesystemHandlers, stopLiteFilesystemWatcher } from '../ipc/filesystem.lite' +import { clearLiteProjectPickCapability, registerLiteProjectHandlers } from '../ipc/projects.lite' +import { killAllLiteTerminalSessions, registerLiteTerminalHandlers } from '../ipc/terminal.lite' +import { registerValidatorHandlers, stopValidatorProcess } from '../ipc/validator' +import { registerShiplineHandlers } from '../ipc/shipline' +import { registerMemeStudioHandlers } from '../ipc/memeStudio' +import { configurePopoutBrowser, closeAllPopouts } from '../services/PopoutBrowserService' +import { ClaudeProvider, CodexProvider, ProviderRegistry } from '../services/providers' +import { getKeyEncryptionWarning, getStorageBackend } from '../services/SecureKeyService' +import { isSafeExternalUrl, openSafeExternalUrl } from '../security/externalNavigation' +import { isTrustedSender, setTrustedIpcOrigin } from '../security/ipcSender' +import { shouldEnableAutoUpdate } from './autoUpdatePolicy' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +process.env.DAEMON_FLAVOR = 'lite' +process.env.APP_ROOT = path.join(__dirname, '../..') +const RENDERER_DIST = path.join(process.env.APP_ROOT, 'dist-lite') +const VITE_DEV_SERVER_URL = app.isPackaged ? undefined : process.env.VITE_DEV_SERVER_URL +const SMOKE_TEST_MODE = process.env.DAEMON_SMOKE_TEST === '1' +const IS_ADHOC_MAC_BUILD = process.platform === 'darwin' + && existsSync(path.join(process.resourcesPath, 'macos-adhoc-release')) + +process.env.VITE_PUBLIC = VITE_DEV_SERVER_URL + ? path.join(process.env.APP_ROOT, 'public') + : RENDERER_DIST + +// The focused workbench is the canonical DAEMON app. Honor Electron's +// conventional switch for isolated profiles used by packaged smoke/E2E. +const cliUserDataDir = app.commandLine.getSwitchValue('user-data-dir').trim() +app.setPath( + 'userData', + process.env.DAEMON_USER_DATA_DIR?.trim() || cliUserDataDir || path.join(app.getPath('appData'), 'daemon'), +) + +if (SMOKE_TEST_MODE) { + app.commandLine.appendSwitch('remote-debugging-port', process.env.DAEMON_SMOKE_CDP_PORT ?? '9333') +} else if (!app.isPackaged) { + app.commandLine.appendSwitch('remote-debugging-port', process.env.DAEMON_DEV_CDP_PORT ?? '9224') +} + +if (process.platform === 'win32') app.setAppUserModelId('com.daemon.app') + +function recordAppCrash(type: string, message: string, stack = '') { + try { + const db = getDb() + db.prepare('INSERT INTO app_crashes (id, type, message, stack, created_at) VALUES (?,?,?,?,?)').run( + crypto.randomUUID(), type, message, stack, Date.now() + ) + } catch { /* DB may not be ready */ } +} + +process.on('uncaughtException', (error) => { + recordAppCrash('uncaughtException', error.message, error.stack ?? '') +}) + +process.on('unhandledRejection', (reason) => { + const message = reason instanceof Error ? reason.message : String(reason) + const stack = reason instanceof Error ? reason.stack ?? '' : '' + recordAppCrash('unhandledRejection', message, stack) +}) + +if (!SMOKE_TEST_MODE && !app.requestSingleInstanceLock()) { + app.quit() + process.exit(0) +} + +let win: BrowserWindow | null = null +let ipcRegistered = false +let shutdownStarted = false +const preload = path.join(__dirname, '../preload/index.mjs') +const popoutPreload = path.join(__dirname, '../preload/popout.mjs') +const liteHtml = path.join(RENDERER_DIST, 'lite.html') + +function popoutChromeUrl(): string { + if (VITE_DEV_SERVER_URL) return new URL('popout.html', VITE_DEV_SERVER_URL).toString() + return `file://${path.join(RENDERER_DIST, 'popout.html')}` +} + +function beginShutdownCleanup() { + if (shutdownStarted) return + shutdownStarted = true + killAllLiteTerminalSessions() + stopValidatorProcess() + stopLiteFilesystemWatcher() + clearLiteProjectPickCapability() + closeAllPopouts() + closeDb() +} + +function registerLiteIpc() { + if (ipcRegistered) return + ipcRegistered = true + + ProviderRegistry.register(ClaudeProvider) + ProviderRegistry.register(CodexProvider) + + registerProviderHandlers() + registerSecureKeyHandlers() + registerAriaHandlers() + registerMemoryHandlers() + registerLiteHandlers() + registerWalletHandlers() + registerPnlHandlers() + registerForensicsHandlers() + registerPopoutHandlers() + registerLiteFilesystemHandlers() + registerLiteProjectHandlers() + registerLiteTerminalHandlers() + registerValidatorHandlers() + registerShiplineHandlers() + registerMemeStudioHandlers() + + configurePopoutBrowser({ preloadPath: popoutPreload, chromeUrl: () => popoutChromeUrl() }) + + ipcMain.handle('shell:open-external', async (event, url: string) => { + if (!isTrustedSender(event)) return + await openSafeExternalUrl(url) + }) +} + +async function createWindow() { + if (SMOKE_TEST_MODE) console.log('[smoke] createWindow:start') + setTrustedIpcOrigin(VITE_DEV_SERVER_URL ? new URL(VITE_DEV_SERVER_URL).origin : 'file://') + registerLiteIpc() + + // Tight CSP in production — the Lite renderer talks only over IPC; all model + // API calls happen in the main process. Dev needs Vite HMR, so skip there. + if (!VITE_DEV_SERVER_URL) { + session.defaultSession.webRequest.onHeadersReceived((details, callback) => { + callback({ + responseHeaders: { + ...details.responseHeaders, + 'Content-Security-Policy': ["default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'"], + }, + }) + }) + } + + win = new BrowserWindow({ + title: 'Daemon', + width: 1100, + height: 760, + minWidth: 900, + minHeight: 620, + show: false, + autoHideMenuBar: true, + // Cursor-style chrome: hidden native titlebar with dark overlay controls; + // the renderer draws a draggable strip that blends into the app. + titleBarStyle: 'hidden', + titleBarOverlay: { + color: '#0c0e0d', + symbolColor: '#9fa19d', + height: 34, + }, + backgroundColor: '#0c0e0d', + icon: path.join(process.env.VITE_PUBLIC, 'daemon-icon.png'), + webPreferences: { + preload, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }) + + win.once('ready-to-show', () => { + if (SMOKE_TEST_MODE) console.log('[smoke] createWindow:show:ready-to-show') + win?.show() + }) + + if (VITE_DEV_SERVER_URL) { + win.loadURL(new URL('lite.html', VITE_DEV_SERVER_URL).toString()) + if (process.env.DAEMON_OPEN_DEVTOOLS === '1') { + win.webContents.openDevTools() + } + } else { + win.loadFile(liteHtml) + } + + win.webContents.setWindowOpenHandler(({ url }) => { + if (isSafeExternalUrl(url)) void openSafeExternalUrl(url) + return { action: 'deny' } + }) + + // Block navigation away from the app origin. + win.webContents.on('will-navigate', (event, url) => { + const appOrigin = VITE_DEV_SERVER_URL ? new URL(VITE_DEV_SERVER_URL).origin : 'file://' + if (new URL(url).origin !== appOrigin) event.preventDefault() + }) + + win.webContents.on('render-process-gone', (_event, details) => { + recordAppCrash('render-process-gone', JSON.stringify(details)) + }) + if (SMOKE_TEST_MODE) { + win.webContents.on('did-finish-load', () => console.log('[smoke] createWindow:did-finish-load')) + } +} + +app.whenReady().then(async () => { + if (SMOKE_TEST_MODE) console.log('[smoke] app:ready') + getDb() + + const keyEncryptionWarning = getKeyEncryptionWarning() + if (keyEncryptionWarning) { + console.warn('[secure-key]', keyEncryptionWarning, '(backend:', getStorageBackend(), ')') + recordAppCrash('key-encryption-degraded', keyEncryptionWarning, `backend=${getStorageBackend() ?? 'n/a'}`) + } + + await createWindow() + + if (shouldEnableAutoUpdate({ + isPackaged: app.isPackaged, + isDisabled: process.env.DAEMON_DISABLE_AUTO_UPDATE === '1', + isSmokeTest: SMOKE_TEST_MODE, + isAdHocMacBuild: IS_ADHOC_MAC_BUILD, + })) { + const pkg = await import('electron-updater') + const { autoUpdater } = pkg.default + autoUpdater.on('error', (error: Error) => console.error('[AutoUpdater]', error.message)) + void autoUpdater.checkForUpdatesAndNotify().catch((error: Error) => console.error('[AutoUpdater]', error.message)) + setInterval(() => { + void autoUpdater.checkForUpdatesAndNotify().catch((error: Error) => console.error('[AutoUpdater]', error.message)) + }, 4 * 60 * 60 * 1000) + } +}) + +app.on('before-quit', () => { + beginShutdownCleanup() +}) + +app.on('window-all-closed', () => { + beginShutdownCleanup() + win = null + app.quit() +}) + +app.on('second-instance', () => { + if (!win || win.isDestroyed()) return + if (win.isMinimized()) win.restore() + win.focus() +}) + +app.on('activate', () => { + if (shutdownStarted) return + const allWindows = BrowserWindow.getAllWindows() + if (allWindows.length) { + allWindows[0].focus() + } else { + void createWindow() + } +}) + +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { + beginShutdownCleanup() + app.quit() + process.exit(0) + }) +} diff --git a/electron/preload/index.ts b/electron/preload/index.ts index a6e2cdbb..a3e04e07 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -280,6 +280,7 @@ contextBridge.exposeInMainWorld('daemon', { stage: (cwd: string, files: string[]) => ipcRenderer.invoke('git:stage', cwd, files), unstage: (cwd: string, files: string[]) => ipcRenderer.invoke('git:unstage', cwd, files), commit: (cwd: string, message: string) => ipcRenderer.invoke('git:commit', cwd, message), + initCommit: (cwd: string, message: string) => ipcRenderer.invoke('git:init-commit', cwd, message), push: (cwd: string) => ipcRenderer.invoke('git:push', cwd), log: (cwd: string, count?: number) => ipcRenderer.invoke('git:log', cwd, count), diff: (cwd: string, filePath?: string) => ipcRenderer.invoke('git:diff', cwd, filePath), @@ -528,7 +529,7 @@ contextBridge.exposeInMainWorld('daemon', { projects: { list: () => ipcRenderer.invoke('projects:list'), - create: (project: { name: string; path: string }) => ipcRenderer.invoke('projects:create', project), + create: (project: { name: string; path: string; requireNewDirectory?: boolean }) => ipcRenderer.invoke('projects:create', project), createDemoWorkspace: () => ipcRenderer.invoke('projects:createDemoWorkspace'), delete: (id: string) => ipcRenderer.invoke('projects:delete', id), openDialog: () => ipcRenderer.invoke('projects:openDialog'), @@ -539,6 +540,23 @@ contextBridge.exposeInMainWorld('daemon', { openExternal: (url: string) => ipcRenderer.invoke('shell:open-external', url), }, + // DAEMON Lite flavor surface — handlers exist only in the Lite main entry. + lite: { + getFlavorInfo: () => ipcRenderer.invoke('lite:get-flavor-info'), + isOnboardingComplete: () => ipcRenderer.invoke('lite:is-onboarding-complete'), + setOnboardingComplete: (complete: boolean) => ipcRenderer.invoke('lite:set-onboarding-complete', complete), + getShowTools: () => ipcRenderer.invoke('lite:get-show-tools'), + setShowTools: (show: boolean) => ipcRenderer.invoke('lite:set-show-tools', show), + openInIde: () => ipcRenderer.invoke('lite:open-in-ide'), + popoutOpen: (url: string) => ipcRenderer.invoke('lite:popout-open', url), + }, + + memeStudio: { + detect: (projectPath: string) => ipcRenderer.invoke('meme-studio:detect', projectPath), + marketContext: (mint: string) => ipcRenderer.invoke('meme-studio:market-context', mint), + tokenPreflight: (mint: string) => ipcRenderer.invoke('meme-studio:token-preflight', mint), + }, + pumpfun: { bondingCurve: (mint: string) => ipcRenderer.invoke('pumpfun:bonding-curve', mint), createToken: (input: object) => ipcRenderer.invoke('pumpfun:create-token', input), @@ -690,7 +708,8 @@ contextBridge.exposeInMainWorld('daemon', { autopilot: { state: () => ipcRenderer.invoke('autopilot:state'), create: (input: unknown) => ipcRenderer.invoke('autopilot:create', input), - arm: (id: string) => ipcRenderer.invoke('autopilot:arm', id), + armReview: (id: string) => ipcRenderer.invoke('autopilot:arm-review', id), + arm: (input: unknown) => ipcRenderer.invoke('autopilot:arm', input), disarm: (id: string) => ipcRenderer.invoke('autopilot:disarm', id), disarmAll: () => ipcRenderer.invoke('autopilot:disarm-all'), delete: (id: string) => ipcRenderer.invoke('autopilot:delete', id), @@ -1004,7 +1023,7 @@ function useLoading() { flex-direction: column; align-items: center; justify-content: center; - background: #0a0a0a; + background: #0c0e0d; z-index: 9; gap: 28px; transition: opacity 0.4s ease, visibility 0.4s ease; @@ -1041,11 +1060,11 @@ function useLoading() { gap: 2px; } .daemon-loading__letter { - font-family: 'Plus Jakarta Sans', system-ui, sans-serif; + font-family: 'Geist', 'Plus Jakarta Sans', system-ui, sans-serif; font-size: 22px; font-weight: 700; letter-spacing: 0.12em; - color: #f0f0f0; + color: #eceee9; display: inline-block; animation: dl-pulse 2.8s ease-in-out infinite; } @@ -1081,7 +1100,7 @@ function useLoading() { to { transform: rotate(360deg); } } @keyframes dl-pulse { - 0%, 60%, 100% { color: #f0f0f0; text-shadow: none; } + 0%, 60%, 100% { color: #eceee9; text-shadow: none; } 30% { color: #3ecf8e; text-shadow: 0 0 12px rgba(62,207,142,0.5); } } @keyframes dl-sweep { diff --git a/electron/preload/popout.ts b/electron/preload/popout.ts new file mode 100644 index 00000000..199b3c75 --- /dev/null +++ b/electron/preload/popout.ts @@ -0,0 +1,19 @@ +/** + * Minimal preload for the DAEMON Lite pop-out chrome window. Exposes ONLY the + * navigation channels for its own guest view — deliberately NOT the full + * window.daemon.* surface (the chrome strip has no business reaching wallet, + * keys, or the agent). isTrustedSender in main is the enforcement boundary. + */ +import { contextBridge, ipcRenderer } from 'electron' + +contextBridge.exposeInMainWorld('daemonPopout', { + navigate: (url: string) => ipcRenderer.invoke('popout:navigate', url), + back: () => ipcRenderer.send('popout:back'), + forward: () => ipcRenderer.send('popout:forward'), + reload: () => ipcRenderer.send('popout:reload'), + onNavState: (handler: (state: unknown) => void) => { + const listener = (_event: unknown, state: unknown) => handler(state) + ipcRenderer.on('popout:nav-state', listener) + return () => ipcRenderer.removeListener('popout:nav-state', listener) + }, +}) diff --git a/electron/services/CheckRunnerService.ts b/electron/services/CheckRunnerService.ts index aa614dd0..91805231 100644 --- a/electron/services/CheckRunnerService.ts +++ b/electron/services/CheckRunnerService.ts @@ -21,13 +21,38 @@ const SAFE_SCRIPT_CHECKS: Array<{ name: string; kind: CheckKind }> = [ const DEPLOY_RE = /\b(deploy|publish|release|push|--dangerously|program deploy|anchor deploy)\b/i -function detectManager(projectPath: string): string { +export function detectManager(projectPath: string): string { if (fs.existsSync(path.join(projectPath, 'pnpm-lock.yaml'))) return 'pnpm' if (fs.existsSync(path.join(projectPath, 'yarn.lock'))) return 'yarn' if (fs.existsSync(path.join(projectPath, 'bun.lockb'))) return 'bun' return 'npm' } +/** + * Discover a dev-server script (dev/start/serve) from package.json, honoring the + * same deploy/publish exclusion as checks. Returns the package-manager command + * (e.g. "npm run dev") or null. NOT arbitrary exec: only a named package script. + */ +export function discoverDevScript(projectPath: string): { command: string; script: string } | null { + const pkgPath = path.join(projectPath, 'package.json') + if (!fs.existsSync(pkgPath)) return null + let scripts: Record = {} + try { + const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8')) as { scripts?: Record } + scripts = pkg.scripts ?? {} + } catch { + return null + } + const manager = detectManager(projectPath) + for (const name of ['dev', 'start', 'serve']) { + const body = scripts[name] + if (typeof body !== 'string') continue + if (DEPLOY_RE.test(body) || DEPLOY_RE.test(name)) continue + return { command: `${manager} run ${name}`, script: name } + } + return null +} + /** * Pure discovery: read package.json scripts (and framework files) and return runnable * check definitions. Excludes any script whose body looks like a deploy/publish action. diff --git a/electron/services/PopoutBrowserService.ts b/electron/services/PopoutBrowserService.ts new file mode 100644 index 00000000..62836f8b --- /dev/null +++ b/electron/services/PopoutBrowserService.ts @@ -0,0 +1,158 @@ +/** + * DAEMON Lite pop-out preview browser. A child BrowserWindow renders a thin + * chrome strip (URL bar + back/forward/reload) from popout.html; the page it + * previews loads in a main-process-owned WebContentsView guest that gets NO + * preload and NO node access. Every navigation is validated against + * isAllowedWebviewUrl (https or loopback http only), so the agent/user can + * never point a preview at cleartext-remote or credentialed URLs. + */ +import { BrowserWindow, WebContentsView } from 'electron' +import path from 'node:path' +import { isAllowedWebviewUrl, openSafeExternalUrl } from '../security/externalNavigation' + +const CHROME_HEIGHT = 88 // titlebar (34) + nav strip (54) +const GUEST_PARTITION = 'persist:lite-popout' +const MAX_POPOUTS = 4 + +interface Popout { + window: BrowserWindow + guest: WebContentsView +} + +const popouts = new Set() + +interface PopoutDeps { + preloadPath: string + chromeUrl: (id: number) => string +} + +let deps: PopoutDeps | null = null + +export function configurePopoutBrowser(next: PopoutDeps): void { + deps = next +} + +function layoutGuest(popout: Popout): void { + const [width, height] = popout.window.getContentSize() + popout.guest.setBounds({ x: 0, y: CHROME_HEIGHT, width, height: Math.max(0, height - CHROME_HEIGHT) }) +} + +function sendNavState(popout: Popout): void { + const wc = popout.guest.webContents + if (popout.window.isDestroyed()) return + popout.window.webContents.send('popout:nav-state', { + url: wc.getURL(), + canGoBack: wc.navigationHistory.canGoBack(), + canGoForward: wc.navigationHistory.canGoForward(), + loading: wc.isLoading(), + }) +} + +/** Open (or focus a fresh) preview window at the given allowlisted URL. */ +export function openPopout(url: string): { opened: boolean; reason?: string } { + if (!deps) return { opened: false, reason: 'popout browser not configured' } + if (!isAllowedWebviewUrl(url)) return { opened: false, reason: 'URL not allowed (https or localhost only)' } + + // LRU cap: close the oldest when at capacity. + if (popouts.size >= MAX_POPOUTS) { + const oldest = popouts.values().next().value + if (oldest && !oldest.window.isDestroyed()) oldest.window.close() + } + + const window = new BrowserWindow({ + width: 1024, + height: 768, + minWidth: 480, + minHeight: 360, + title: 'Preview', + backgroundColor: '#0c0e0d', + titleBarStyle: 'hidden', + titleBarOverlay: { color: '#0c0e0d', symbolColor: '#9fa19d', height: 34 }, + webPreferences: { + preload: deps.preloadPath, + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + }, + }) + + const guest = new WebContentsView({ + webPreferences: { + contextIsolation: true, + nodeIntegration: false, + sandbox: true, + // No preload — the guest is untrusted web content. + partition: GUEST_PARTITION, + }, + }) + + const popout: Popout = { window, guest } + popouts.add(popout) + + window.contentView.addChildView(guest) + layoutGuest(popout) + + const gwc = guest.webContents + + // Block any navigation to a non-allowlisted URL; hand https off to the OS + // browser instead of silently dropping it. + gwc.on('will-navigate', (event, target) => { + if (!isAllowedWebviewUrl(target)) { + event.preventDefault() + void openSafeExternalUrl(target) + } + }) + gwc.setWindowOpenHandler(({ url: target }) => { + void openSafeExternalUrl(target) + return { action: 'deny' } + }) + gwc.session.on('will-download', (event) => event.preventDefault()) + + gwc.on('did-navigate', () => sendNavState(popout)) + gwc.on('did-navigate-in-page', () => sendNavState(popout)) + gwc.on('did-start-loading', () => sendNavState(popout)) + gwc.on('did-stop-loading', () => sendNavState(popout)) + + window.on('resize', () => layoutGuest(popout)) + window.on('closed', () => { + popouts.delete(popout) + }) + + window.loadURL(deps.chromeUrl(window.id)) + void gwc.loadURL(url) + + return { opened: true } +} + +function findPopout(windowId: number): Popout | undefined { + for (const popout of popouts) { + if (!popout.window.isDestroyed() && popout.window.id === windowId) return popout + } + return undefined +} + +export function popoutNavigate(windowId: number, url: string): boolean { + const popout = findPopout(windowId) + if (!popout || !isAllowedWebviewUrl(url)) return false + void popout.guest.webContents.loadURL(url) + return true +} + +export function popoutBack(windowId: number): void { + findPopout(windowId)?.guest.webContents.navigationHistory.goBack() +} + +export function popoutForward(windowId: number): void { + findPopout(windowId)?.guest.webContents.navigationHistory.goForward() +} + +export function popoutReload(windowId: number): void { + findPopout(windowId)?.guest.webContents.reload() +} + +export function closeAllPopouts(): void { + for (const popout of popouts) { + if (!popout.window.isDestroyed()) popout.window.close() + } + popouts.clear() +} diff --git a/electron/services/ProService.lite.ts b/electron/services/ProService.lite.ts new file mode 100644 index 00000000..7fe7e419 --- /dev/null +++ b/electron/services/ProService.lite.ts @@ -0,0 +1,31 @@ +/** + * DAEMON Lite stub for ProService (swapped in by vite.lite.config.ts). + * Lite is the free tier — there is no subscription, x402 payment, or holder + * gating, so this severs the @x402 / @solana/kit / SolanaService import chain + * from the Lite bundle. Only getLocalSubscriptionState is imported by the + * Lite graph (DaemonAIService, EntitlementGuardService). + */ +import type { ProSubscriptionState } from '../shared/types' + +export function getLocalSubscriptionState(): ProSubscriptionState { + return { + active: false, + plan: 'light', + walletId: null, + walletAddress: null, + expiresAt: null, + features: [], + tier: null, + accessSource: 'free', + holderStatus: { + enabled: false, + eligible: false, + mint: null, + minAmount: null, + currentAmount: null, + symbol: 'DAEMON', + }, + priceUsdc: null, + durationDays: null, + } +} diff --git a/electron/services/RobinhoodChainService.ts b/electron/services/RobinhoodChainService.ts new file mode 100644 index 00000000..7a671b33 --- /dev/null +++ b/electron/services/RobinhoodChainService.ts @@ -0,0 +1,156 @@ +/** + * Read-only JSON-RPC client for Robinhood Chain (EVM / Arbitrum Orbit L2). + * Talks to the public rate-limited endpoints — fine for ARIA's ad-hoc reads, + * not for indexing. No signing, no key material, no writes. + */ +import { getRhNetwork, type RhNetworkId } from './aria/knowledge/robinhoodChain' + +const RPC_TIMEOUT_MS = 10_000 +const WEI_PER_ETH = 10n ** 18n + +/** Well-known ERC-20 function selectors (stable ABI constants). */ +const SELECTOR = { + name: '0x06fdde03', + symbol: '0x95d89b41', + decimals: '0x313ce567', + totalSupply: '0x18160ddd', + balanceOf: '0x70a08231', +} as const + +export function isEvmAddress(value: string): boolean { + return /^0x[0-9a-fA-F]{40}$/.test(value) +} + +export function isTxHash(value: string): boolean { + return /^0x[0-9a-fA-F]{64}$/.test(value) +} + +interface JsonRpcResponse { + result?: unknown + error?: { code: number; message: string } +} + +async function rpcCall(network: RhNetworkId, method: string, params: unknown[]): Promise { + const { rpcUrl, name } = getRhNetwork(network) + const response = await fetch(rpcUrl, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }), + signal: AbortSignal.timeout(RPC_TIMEOUT_MS), + }) + if (!response.ok) throw new Error(`${name} RPC HTTP ${response.status} for ${method}.`) + const payload = (await response.json()) as JsonRpcResponse + if (payload.error) throw new Error(`${name} RPC error for ${method}: ${payload.error.message}`) + return payload.result +} + +function hexToBigInt(value: unknown): bigint { + if (typeof value !== 'string' || !value.startsWith('0x')) { + throw new Error(`Expected hex quantity, got ${JSON.stringify(value)}.`) + } + return BigInt(value) +} + +/** Format a wei quantity as a decimal string without float precision loss. */ +export function formatUnits(value: bigint, decimals: number): string { + const base = 10n ** BigInt(decimals) + const whole = value / base + const fraction = (value % base).toString().padStart(decimals, '0').replace(/0+$/, '') + return fraction ? `${whole}.${fraction}` : whole.toString() +} + +/** Decode an ABI-encoded string return (offset + length + utf8 bytes). */ +function decodeAbiString(hex: unknown): string { + if (typeof hex !== 'string' || hex === '0x' || !hex.startsWith('0x')) return '' + const data = hex.slice(2) + if (data.length < 128) return '' + const length = Number(BigInt(`0x${data.slice(64, 128)}`)) + const bytes = data.slice(128, 128 + length * 2) + return Buffer.from(bytes, 'hex').toString('utf8') +} + +async function erc20Call(network: RhNetworkId, token: string, data: string): Promise { + return rpcCall(network, 'eth_call', [{ to: token, data }, 'latest']) +} + +export interface RhChainStatus { + network: string + chainId: number + blockNumber: number + gasPriceGwei: string +} + +export async function getChainStatus(network: RhNetworkId): Promise { + const [chainIdHex, blockHex, gasHex] = await Promise.all([ + rpcCall(network, 'eth_chainId', []), + rpcCall(network, 'eth_blockNumber', []), + rpcCall(network, 'eth_gasPrice', []), + ]) + return { + network: getRhNetwork(network).name, + chainId: Number(hexToBigInt(chainIdHex)), + blockNumber: Number(hexToBigInt(blockHex)), + gasPriceGwei: formatUnits(hexToBigInt(gasHex), 9), + } +} + +export interface RhBalance { + address: string + wei: string + eth: string +} + +export async function getBalance(network: RhNetworkId, address: string): Promise { + if (!isEvmAddress(address)) throw new Error(`"${address}" is not a valid 0x address.`) + const wei = hexToBigInt(await rpcCall(network, 'eth_getBalance', [address, 'latest'])) + return { address, wei: wei.toString(), eth: formatUnits(wei, 18) } +} + +export interface RhErc20Info { + address: string + name: string + symbol: string + decimals: number + totalSupply: string + holder?: { address: string; balance: string } +} + +export async function getErc20Info(network: RhNetworkId, token: string, holder?: string): Promise { + if (!isEvmAddress(token)) throw new Error(`"${token}" is not a valid token address.`) + if (holder !== undefined && !isEvmAddress(holder)) throw new Error(`"${holder}" is not a valid holder address.`) + const [name, symbol, decimalsHex, supplyHex] = await Promise.all([ + erc20Call(network, token, SELECTOR.name), + erc20Call(network, token, SELECTOR.symbol), + erc20Call(network, token, SELECTOR.decimals), + erc20Call(network, token, SELECTOR.totalSupply), + ]) + const decimals = Number(hexToBigInt(decimalsHex)) + const info: RhErc20Info = { + address: token, + name: decodeAbiString(name), + symbol: decodeAbiString(symbol), + decimals, + totalSupply: formatUnits(hexToBigInt(supplyHex), decimals), + } + if (holder) { + const data = SELECTOR.balanceOf + holder.slice(2).toLowerCase().padStart(64, '0') + const balance = hexToBigInt(await erc20Call(network, token, data)) + info.holder = { address: holder, balance: formatUnits(balance, decimals) } + } + return info +} + +export interface RhTransaction { + transaction: unknown + receipt: unknown +} + +export async function getTransaction(network: RhNetworkId, hash: string): Promise { + if (!isTxHash(hash)) throw new Error(`"${hash}" is not a valid transaction hash.`) + const [transaction, receipt] = await Promise.all([ + rpcCall(network, 'eth_getTransactionByHash', [hash]), + rpcCall(network, 'eth_getTransactionReceipt', [hash]), + ]) + if (transaction === null) throw new Error(`Transaction ${hash} not found on ${getRhNetwork(network).name}.`) + return { transaction, receipt } +} diff --git a/electron/services/SwarmOrchestrator.ts b/electron/services/SwarmOrchestrator.ts index 3e506644..714f941f 100644 --- a/electron/services/SwarmOrchestrator.ts +++ b/electron/services/SwarmOrchestrator.ts @@ -390,6 +390,11 @@ export function collectLaneResults(laneId: string): string | null { try { return fs.readFileSync(lane.results_path, 'utf8') } catch { return null } } +/** Merge a finished lane's branch into the main repo. See WorktreeService.mergeLane. */ +export async function mergeLane(laneId: string): Promise { + return Worktree.mergeLane(laneId) +} + /** * Cancel/dismiss a whole run: kill any live lane processes and ALWAYS tear down * every worktree + branch (RESULTS are already snapshotted to the cache on exit, diff --git a/electron/services/WorktreeService.ts b/electron/services/WorktreeService.ts index 612b9b53..03fa54e2 100644 --- a/electron/services/WorktreeService.ts +++ b/electron/services/WorktreeService.ts @@ -196,6 +196,58 @@ export async function removeWorktree(projectPath: string, worktreePath: string, } catch { /* best-effort */ } } +export interface MergeLaneResult { + ok: boolean + branch: string + /** short SHA merged into base, when ok */ + mergedSha?: string + /** the base branch the lane was merged into */ + baseBranch?: string + error?: string +} + +/** + * Merge a finished lane's work back into the main repo. Lanes are told NOT to + * commit (so RESULTS/BUILD_NOTES stay reviewable in the worktree), so this first + * commits any uncommitted lane changes onto the lane branch, then merges that + * branch (--no-ff) into the base branch in the MAIN working copy. Read-only for + * the lane worktree beyond the single commit; the human/tool decides when to run + * it. Guarded to `done` lanes only. + */ +export async function mergeLane(laneId: string): Promise { + const lane = getLane(laneId) + if (!lane) return { ok: false, branch: '', error: 'Lane not found.' } + if (lane.status !== 'done') { + return { ok: false, branch: lane.branch, error: `Lane is ${lane.status}, only 'done' lanes can be merged.` } + } + const run = getRun(lane.run_id) + if (!run) return { ok: false, branch: lane.branch, error: 'Run not found.' } + + const laneGit = simpleGit(lane.worktree_path) + const mainGit = simpleGit(run.project_path) + try { + // 1) Commit any uncommitted lane work onto the lane branch (lanes don't self-commit). + const status = await laneGit.status() + if (status.files.length > 0) { + await laneGit.add(['-A']) + await laneGit.raw(['-c', 'user.email=daemon@local', '-c', 'user.name=DAEMON', 'commit', '-m', `swarm: ${lane.task.slice(0, 72)}`]) + } + // 2) Resolve the base branch to merge into (explicit base, else current HEAD of main). + const baseBranch = run.base_branch?.trim() + || (await mainGit.revparse(['--abbrev-ref', 'HEAD'])).trim() + // 3) Merge the lane branch into base in the MAIN repo. --no-ff keeps the lane visible. + await mainGit.raw(['checkout', baseBranch]) + await mainGit.raw(['merge', '--no-ff', lane.branch, '-m', `Merge swarm lane ${lane.branch}`]) + const mergedSha = (await mainGit.revparse(['--short', 'HEAD'])).trim() + return { ok: true, branch: lane.branch, mergedSha, baseBranch } + } catch (err) { + // Leave the repo as-is (a conflicted merge stays for the human to resolve in the Git panel). + const message = err instanceof Error ? err.message : String(err) + LogService.warn('Swarm', `Merge failed for lane ${laneId}`, { branch: lane.branch, error: message }) + return { ok: false, branch: lane.branch, error: message } + } +} + /** * Reconcile-on-boot: any lane already terminal but whose worktree may linger * gets its worktree removed. Also marks orphaned non-terminal lanes (a crash diff --git a/electron/services/aria/contextAssembler.lite.ts b/electron/services/aria/contextAssembler.lite.ts new file mode 100644 index 00000000..0950d20a --- /dev/null +++ b/electron/services/aria/contextAssembler.lite.ts @@ -0,0 +1,55 @@ +/** + * DAEMON Lite system prompt. Swapped in for contextAssembler.ts by the + * resolveId hook in vite.lite.config.ts. The Lite workbench can open projects, + * files, and terminals, but ARIA does not yet receive tools for those surfaces. + * Memories remain global (null project id). + */ +import { buildContextBundle } from '../MemoryInjectionService' +import { getMemory } from '../MemoryService' +import type { AriaContextSnapshot, AriaMemorySuggestionLite } from '../../shared/types' + +const LITE_AGENT_SYSTEM = `You are the DAEMON Lite assistant — a focused AI agent for developers. + +CAPABILITIES: +- Explain code the user pastes, in plain language. +- Debug errors: read the error message, identify the likely cause, and show the fix. +- Plan projects and features step by step. +- Memory: store durable facts about the user's work (remember_fact), list them (recall_memories), correct or forget them (update_memory / forget_memory). Never store secrets — keys, seed phrases, and credentials are rejected. +- Wallet: read balances/holdings (read_wallet), create a wallet (generate_wallet), send SOL (transfer_sol). Sends require the user's typed confirmation. +- Trading: search tokens (token_search), get a swap quote (token_quote), and swap tokens via Jupiter (swap_tokens). Always quote first and show the price impact before proposing a swap. Swaps require typed confirmation and are capped at $500 in Lite. +- Token/wallet safety: check a mint or wallet for risks — mint/freeze authority, snipers, bundles, cabal links (forensic_scan_token, forensic_trace_wallet). +- Preview: open a sandboxed browser window for a localhost dev server or an https page (open_preview). + +RULES: +- For any request that needs more than one step, FIRST call present_plan with 3–6 short step titles, then walk through the steps in your answer. +- Be concise and direct, and assume the user may be early in their coding journey: explain the "why" in plain language, define jargon the first time it appears, and prefer small working examples over abstract descriptions. +- Format with markdown: bold section titles (no trailing colons) and "-" bullets for lists. Code in fenced blocks with a language tag. Keep prose in short paragraphs. No filler, no emoji. +- When the user tells you to remember something, or a stable preference is established (their stack, their conventions), call remember_fact. If unsure whether a fact is already known, recall_memories first. +- Money is real. Before any swap or SOL transfer, state the amount, the token, and (for swaps) the price impact, then let the confirmation card gate it. Never move funds without the user's typed confirm. On mainnet, treat every amount as real money. +- Lite Workbench can open files and terminals, but you have NO tool access to their contents or state yet. Never claim you read a file or ran a command. Ask the user to paste exact evidence or use the visible Workbench surfaces. +- Never invent file paths, API keys, addresses, mints, or version numbers. +- When finished with multi-step work, end with a one-line summary.` + +export interface AssembledPrompt { + system: string + /** Memories actually injected into this prompt — surfaced as "recalled" in the transcript. */ + recalled: AriaMemorySuggestionLite[] +} + +export async function assembleSystemPrompt(snapshot: AriaContextSnapshot): Promise { + // Lite memories are global: project id is always null here. + let memoryBlock = '' + const recalled: AriaMemorySuggestionLite[] = [] + if (snapshot.chips.projectMemory) { + try { + const bundle = buildContextBundle(null, { usedIn: 'aria_prompt' }) + if (bundle.block) memoryBlock = `\n\n${bundle.block}` + for (const id of bundle.usedMemoryIds) { + const mem = getMemory(id) + if (mem) recalled.push({ id: mem.id, kind: mem.kind, title: mem.title, value: mem.value }) + } + } catch { /* memory unavailable */ } + } + + return { system: `${LITE_AGENT_SYSTEM}${memoryBlock}`, recalled } +} diff --git a/electron/services/aria/contextAssembler.ts b/electron/services/aria/contextAssembler.ts index 9efd817a..ca65f66d 100644 --- a/electron/services/aria/contextAssembler.ts +++ b/electron/services/aria/contextAssembler.ts @@ -20,8 +20,17 @@ CAPABILITIES (call the matching tool — do not just explain): - Token launches: tokenlaunch_list_launchpads, tokenlaunch_preflight, tokenlaunch_create. - Flywheel: preview/configure a fee split, run the flywheel (flywheel_*). - Git: stage + commit in the active project (git_commit). You never push. +- Game studio: scaffold a playable Solana game (scaffold_game), run its dev server (run_dev_server), preview it in-app (preview_app), merge a finished swarm lane (swarm_merge_lane), deploy the pre-wired project (deploy_app). +- Swarms: run tasks as parallel worktree-isolated Claude agents (swarm_launch), monitor them (swarm_status), read their results (swarm_collect). +- Robinhood Chain (EVM L2): bundled docs knowledge (rh_chain_knowledge — answer any Robinhood Chain question from it before guessing), network constants (rh_chain_info), the canonical stock-token/ETF registry (rh_stock_tokens), and live read-only RPC reads (rh_chain_rpc). All read-only — you cannot sign, send, bridge, or trade on Robinhood Chain. - Memory: remember durable project facts (remember_fact), list what you know (recall_memories), correct or forget them (update_memory / forget_memory). Never store secrets. +BUILD-A-GAME FLOW (when the user asks you to build/make a game): +- STEP 1 (scaffold): call scaffold_game with a short project name. This opens the wizard and, once the user confirms the folder, switches the workbench to the NEW project — which starts a fresh ARIA session for it. So scaffold_game is the LAST action of this turn: after calling it, tell the user "Project scaffolded and previewing. In the new project, tell me to build the game and I'll launch the swarm." Do NOT call swarm_launch in the same turn — the project switch ends this session and would discard a pending approval. +- STEP 2 (build — in the NEW project's session, after the user asks): swarm_launch with ONE task describing the game (the lane authors it from the template). Then STOP and tell the user the lane is building — the swarm runs in the background past this turn. +- STEP 3 (finish — after the user says it's done, or on a later turn): swarm_status to confirm the lane is "done", swarm_merge_lane on that lane, run_dev_server, then preview_app so the user can play it. Offer deploy_app last. +- The game code is written by the swarm lane, not by you. Do not scaffold_file the game yourself. Never call swarm_launch in the same turn as scaffold_game. + RULES: - When the user tells you to remember something, or a stable project convention is established (package manager, a constraint, a fix that should not be repeated), call remember_fact. If unsure whether a fact is already known, recall_memories first. Never remember secrets — keys, seed phrases, credentials. - Be concise and direct. No filler, no emoji. diff --git a/electron/services/aria/knowledge/robinhoodChain.ts b/electron/services/aria/knowledge/robinhoodChain.ts new file mode 100644 index 00000000..d845817a --- /dev/null +++ b/electron/services/aria/knowledge/robinhoodChain.ts @@ -0,0 +1,95 @@ +/** + * Bundled Robinhood Chain reference for ARIA — network constants and the + * canonical token registry, distilled from docs.robinhood.com/chain. + * Last synced: 2026-07-10. Addresses and feeds can move; the docs site is the + * source of truth and rh_chain_rpc reads live state. + */ + +export type RhNetworkId = 'mainnet' | 'testnet' + +export interface RhChainNetwork { + id: RhNetworkId + name: string + chainId: number + rpcUrl: string + sequencerFeedUrl: string + explorerUrl: string + parentChain: string + gasToken: string +} + +export const ROBINHOOD_CHAIN_DOCS_URL = 'https://docs.robinhood.com/chain/' +export const ROBINHOOD_CHAIN_STATUS_URL = 'http://status.robinhoodchain.offchain.io/' +export const ROBINHOOD_CHAIN_BRIDGE_URL = + 'https://portal.arbitrum.io/bridge?destinationChain=robinhood-chain&sourceChain=ethereum' +export const CHAINLINK_FEEDS_URL = + 'https://docs.chain.link/data-feeds/price-feeds/addresses?network=robinhood' + +export const ROBINHOOD_CHAIN_NETWORKS: RhChainNetwork[] = [ + { + id: 'mainnet', + name: 'Robinhood Chain', + chainId: 4663, + rpcUrl: 'https://rpc.mainnet.chain.robinhood.com', + sequencerFeedUrl: 'wss://feed.mainnet.chain.robinhood.com', + explorerUrl: 'https://robinhoodchain.blockscout.com', + parentChain: 'Ethereum', + gasToken: 'ETH', + }, + { + id: 'testnet', + name: 'Robinhood Chain Testnet', + chainId: 46630, + rpcUrl: 'https://rpc.testnet.chain.robinhood.com', + sequencerFeedUrl: 'wss://feed.testnet.chain.robinhood.com', + explorerUrl: 'https://explorer.testnet.chain.robinhood.com', + parentChain: 'Ethereum Sepolia', + gasToken: 'ETH', + }, +] + +export function getRhNetwork(id: RhNetworkId): RhChainNetwork { + const network = ROBINHOOD_CHAIN_NETWORKS.find((n) => n.id === id) + if (!network) throw new Error(`Unknown Robinhood Chain network "${id}".`) + return network +} + +export type RhTokenKind = 'core' | 'stock' | 'etf' + +export interface RhToken { + symbol: string + kind: RhTokenKind + /** Canonical mainnet contract address. A same-ticker token at another address is NOT canonical. */ + address: string +} + +/** Canonical mainnet token registry (docs.robinhood.com/chain/contracts). */ +export const ROBINHOOD_CHAIN_TOKENS: RhToken[] = [ + { symbol: 'WETH', kind: 'core', address: '0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73' }, + { symbol: 'USDG', kind: 'core', address: '0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168' }, + { symbol: 'AAPL', kind: 'stock', address: '0xaF3D76f1834A1d425780943C99Ea8A608f8a93f9' }, + { symbol: 'AMD', kind: 'stock', address: '0x86923f96303D656E4aa86D9d42D1e57ad2023fdC' }, + { symbol: 'AMZN', kind: 'stock', address: '0x12f190a9F9d7D37a250758b26824B97CE941bF54' }, + { symbol: 'BABA', kind: 'stock', address: '0xad25Ac6C84D497db898fa1E8387bf6Af3532a1c4' }, + { symbol: 'BE', kind: 'stock', address: '0x822CC93fFD030293E9842c30BBD678F530701867' }, + { symbol: 'COIN', kind: 'stock', address: '0x6330D8C3178a418788dF01a47479c0ce7CCF450b' }, + { symbol: 'CRCL', kind: 'stock', address: '0xdF0992E440dD0be65BD8439b609d6D4366bf1CB5' }, + { symbol: 'CRWV', kind: 'stock', address: '0x5f10A1C971B69e47e059e1dC91901B59b3fB49C3' }, + { symbol: 'GOOGL', kind: 'stock', address: '0x2e0847E8910a9732eB3fb1bb4b70a580ADAD4FE3' }, + { symbol: 'INTC', kind: 'stock', address: '0xc72b96e0E48ecd4DC75E1e45396e26300BC39681' }, + { symbol: 'META', kind: 'stock', address: '0xc0D6457C16Cc70d6790Dd43521C899C87ce02f35' }, + { symbol: 'MSFT', kind: 'stock', address: '0xe93237C50D904957Cf27E7B1133b510C669c2e74' }, + { symbol: 'MU', kind: 'stock', address: '0xfF080c8ce2E5feadaCa0Da81314Ae59D232d4afD' }, + { symbol: 'NVDA', kind: 'stock', address: '0xd0601CE157Db5bdC3162BbaC2a2C8aF5320D9EEC' }, + { symbol: 'ORCL', kind: 'stock', address: '0xb0992820E760d836549ba69BC7598b4af75dEE03' }, + { symbol: 'PLTR', kind: 'stock', address: '0x894E1EC2D74FFE5AEF8Dc8A9e84686acCB964F2A' }, + { symbol: 'SNDK', kind: 'stock', address: '0xB90A19fF0Af67f7779afF50A882A9CfF42446400' }, + { symbol: 'SPCX', kind: 'stock', address: '0x4a0E65A3EcceC6dBe60AE065F2e7bb85Fae35eEa' }, + { symbol: 'TSLA', kind: 'stock', address: '0x322F0929c4625eD5bAd873c95208D54E1c003b2d' }, + { symbol: 'USAR', kind: 'stock', address: '0xd917B029C761D264c6A312BBbcDA868658eF86a6' }, + { symbol: 'QQQ', kind: 'etf', address: '0xD5f3879160bc7c32ebb4dC785F8a4F505888de68' }, + { symbol: 'SGOV', kind: 'etf', address: '0x92FD66527192E3e61d4DDd13322Aa222DE86F9B5' }, + { symbol: 'SLV', kind: 'etf', address: '0x411eFb0E7f985935DAec3D4C3ebaEa0d0AD7D89f' }, + { symbol: 'SPY', kind: 'etf', address: '0x117cc2133c37B721F49dE2A7a74833232B3B4C0C' }, + { symbol: 'CUSO', kind: 'etf', address: '0xa30FA36Db767ad9eD3f7a60fC79526fB4d56D344' }, +] diff --git a/electron/services/aria/knowledge/robinhoodChainDocs.ts b/electron/services/aria/knowledge/robinhoodChainDocs.ts new file mode 100644 index 00000000..a9d7049f --- /dev/null +++ b/electron/services/aria/knowledge/robinhoodChainDocs.ts @@ -0,0 +1,220 @@ +/** + * Robinhood Chain docs knowledge for ARIA, distilled from docs.robinhood.com/chain. + * One section per docs page; details keep every hard fact (IDs, URLs, addresses, + * mechanics) and drop the prose. Last synced: 2026-07-10. + */ + +export interface RhKnowledgeSection { + topic: string + title: string + summary: string + details: string + sourceUrl: string +} + +const DOCS = 'https://docs.robinhood.com/chain' + +export const ROBINHOOD_CHAIN_KNOWLEDGE: RhKnowledgeSection[] = [ + { + topic: 'overview', + title: 'About Robinhood Chain', + summary: 'Permissionless, EVM-compatible Arbitrum L2 optimized for tokenized real-world assets; live on mainnet with ETH gas.', + details: `- Ethereum L2 built on Arbitrum Dedicated Blockchains (Nitro); posts data to Ethereum via blobs; ETH is the native gas token. +- Optimized for tokenized RWAs: equities, ETFs, private assets. Flagship product is Robinhood Stock Tokens. +- First-come, first-served sequencing by sequencer arrival time — no priority gas auctions. +- Fully EVM-compatible: Solidity/Vyper deploy unmodified; Hardhat, Foundry, ethers.js, viem, Wagmi work out of the box. +- First-class ERC-4337 account abstraction (gas sponsorship, batching, session keys). +- Ecosystem: Alchemy (recommended RPC + AA), LayerZero (bridge), Chainlink (oracles), Fireblocks/BitGo (custody), Allium (analytics), Uniswap (public DEX), Rialto (proprietary AMM), Morpho (lending), Lighter + Arcus (perps), Paxos USDG (stablecoin), Zerion (wallet data), CoinGecko (tracking). +- Status page: http://status.robinhoodchain.offchain.io/ · Support: chain-developers-group@robinhood.com`, + sourceUrl: `${DOCS}/`, + }, + { + topic: 'connecting', + title: 'Connecting to Robinhood Chain', + summary: 'Chain IDs, RPC endpoints (public + providers), sequencer feeds, and explorers for mainnet and testnet.', + details: `- Mainnet: chain ID 4663 · ETH gas · explorer https://robinhoodchain.blockscout.com +- Testnet: chain ID 46630 · ETH gas · explorer https://explorer.testnet.chain.robinhood.com +- Public RPC (rate-limited, not for production): mainnet https://rpc.mainnet.chain.robinhood.com · testnet https://rpc.testnet.chain.robinhood.com +- Sequencer feed: wss://feed.mainnet.chain.robinhood.com (testnet: wss://feed.testnet.chain.robinhood.com) · Sequencer: https://sequencer.mainnet.chain.robinhood.com +- Alchemy (recommended for production): https://robinhood-mainnet.g.alchemy.com/v2/{API_KEY} (wss:// same host); testnet robinhood-testnet. Also supported: QuickNode ({ENDPOINT}.robinhood-mainnet.quiknode.pro/{TOKEN}), Blockdaemon, dRPC, Validation Cloud. +- Archive endpoints (for historical reads/indexing) available via providers such as Alchemy.`, + sourceUrl: `${DOCS}/connecting`, + }, + { + topic: 'add-network-to-wallet', + title: 'Add network to your wallet', + summary: 'Wallet configuration values for MetaMask-style manual add; Robinhood Wallet supports the chain natively.', + details: `- Works with any EVM wallet (MetaMask, Phantom, etc.). Robinhood Wallet (iOS/Android) supports it natively. +- Manual add — mainnet: chain ID 4663, RPC https://rpc.mainnet.chain.robinhood.com/, symbol ETH, explorer https://robinhoodchain.blockscout.com +- Manual add — testnet: chain ID 46630, RPC https://rpc.testnet.chain.robinhood.com, symbol ETH, explorer https://explorer.testnet.chain.robinhood.com`, + sourceUrl: `${DOCS}/add-network-to-wallet`, + }, + { + topic: 'bridging', + title: 'Bridging', + summary: 'Canonical Arbitrum bridge (trustless, 7-day withdrawal) plus fast third-party routes: Stargate/LayerZero, CCIP, Relay, Across, LiFi/0x.', + details: `- Canonical bridge (trustless, security from Ethereum): https://portal.arbitrum.io/bridge?destinationChain=robinhood-chain&sourceChain=ethereum — deposits ~10 min; withdrawals: initiate on L2, wait 7-day challenge period, then claim on L1 (costs L1 gas). +- Deposits use Arbitrum retryable tickets: a failed L2 leg can be manually redeemed within 7 days — funds are not lost. +- Fast routes: LayerZero OFT / Stargate (WBTC, USDG, other OFTs, minutes) · Chainlink CCIP (programmable transfer + action) · Relay (intents, seconds, bridge-and-execute) · Across (intents, seconds) · LiFi / 0x (swap-and-bridge). +- Programmatic bridging: interact with the Delayed Inbox on L1 (see protocol-contracts). A bridged ERC-20 has a DIFFERENT address on L2 than on Ethereum — resolve via calculateL2TokenAddress on the L2 Gateway Router.`, + sourceUrl: `${DOCS}/bridging`, + }, + { + topic: 'stock-tokens', + title: 'Stock Tokens', + summary: 'Tokenized debt securities (issuer: Robinhood Assets (Jersey) Ltd) giving economic exposure to US equities/ETFs as standard ERC-20s with Chainlink feeds.', + details: `- Standard ERC-20, 18 decimals; one token per underlying equity/ETF identified by ticker. Held, transferred, and composed like any ERC-20. +- Legally: tokenized DEBT securities issued by Robinhood Assets (Jersey) Limited (RHJ). Economic exposure only — no legal/beneficial rights in the underlying. Not offered to US persons (Reg S); also restricted in UK, Canada, Switzerland. Prospectus: http://docs.robinhood.com/rhj +- Primary market: only Authorised Participants (at issuance, BBVI) can subscribe/redeem after KYB. Developers compose with existing tokens; there is no public mint. +- Corporate actions (dividends, splits) are handled by an onchain multiplier (ERC-8056 Scaled UI Amount): raw balanceOf()/totalSupply() stay fixed; uiMultiplier() (1e18 fixed-point) scales shares-per-token. Dividends are reinvested via the multiplier, so tokens track TOTAL return. +- Live per-token Chainlink price feeds; the feed price already includes the multiplier. +- Trading is RFQ at launch (e.g. 0x RFQ quoting vs USDG).`, + sourceUrl: `${DOCS}/stock-tokens`, + }, + { + topic: 'building-with-stock-tokens', + title: 'Building with Stock Tokens', + summary: 'Integration patterns: ERC-20 ops, ERC-8056 multiplier math, UI-adjusted views, events, and price-feed usage.', + details: `- All standard ERC-20 ops work unmodified (balanceOf/transfer/approve). 18 decimals. +- ERC-8056 interfaces: uiMultiplier() current multiplier (1e18 = 1.0, launch value 1e18); newUIMultiplier() + effectiveAt() expose a scheduled pending multiplier; balanceOfUI(account) and totalSupplyUI() return underlying-share-adjusted views; events UIMultiplierUpdated(old, new, effectiveAtTimestamp) and TransferWithScaledUI(from, to, value, uiValue). +- Conversion: underlying shares = raw amount x uiMultiplier / 1e18. Not a rebasing token. +- Price: each token has a Chainlink AggregatorV3Interface feed (latestRoundData(), typically 8 decimals). Feed price is multiplier-adjusted — do NOT apply the multiplier again. USD value = balance x price / 1e8 (for 8-decimal feeds). +- Use cases: portfolio display, RFQ trading widgets, lending collateral (e.g. Morpho), index baskets, yield vaults, price-triggered contracts, perps margin. +- Getting started: pick a token address from the registry, read balanceOf, read latestRoundData() on its feed, compose.`, + sourceUrl: `${DOCS}/building-with-stock-tokens`, + }, + { + topic: 'token-contracts', + title: 'Token Contracts (canonical addresses)', + summary: 'Canonical mainnet addresses for WETH, USDG, 20 stock tokens, and 5 tokenized ETFs.', + details: `- Canonical registry is bundled in ROBINHOOD_CHAIN_TOKENS and served by the rh_stock_tokens tool: WETH, USDG (core); AAPL, AMD, AMZN, BABA, BE, COIN, CRCL, CRWV, GOOGL, INTC, META, MSFT, MU, NVDA, ORCL, PLTR, SNDK, SPCX, TSLA, USAR (stocks); QQQ, SGOV, SLV, SPY, CUSO (ETFs). +- CRITICAL: a token with a matching name/ticker at a different address is NOT a Robinhood Stock Token — always verify against the canonical address list. +- WETH mainnet: 0x0Bd7D308f8E1639FAb988df18A8011f41EAcAD73 · USDG: 0x5fc5360D0400a0Fd4f2af552ADD042D716F1d168`, + sourceUrl: `${DOCS}/contracts`, + }, + { + topic: 'protocol-contracts', + title: 'Protocol Contracts', + summary: 'L1 core/messaging contracts, token-bridge gateways (L1+L2), Arbitrum precompiles, and misc deployments.', + details: `- L1 core (Ethereum mainnet): Rollup 0x23A19d23e89166adedbDcB432518AB01e4272D94 · Sequencer Inbox 0xBd0D173EEb87D57A09521c24388a12789F33ba96 · CoreProxyAdmin 0x1232813BDd40aa9d53066A880dE78a4Be70B90FD +- L1 messaging: Delayed Inbox 0x1A07cc4BD17E0118BdB54D70990D2158AbAD7a2D · Bridge 0xDf8755334ce7A73cCF6b581C02eA649AE3E864b3 · Outbox 0xf0ce991ea4A0d2400A4AB49b20ae333f6Dce3DE9 +- L1 token bridge: Gateway Router 0x6a2E3a1e16FC29f27Ce61429746D558d656975bB · ERC20 Gateway 0x85001CC4867C5e1C22dA4B79BB8852B9e2a06da0 · Custom Gateway 0x9368EAEbFe6E063C69dcF8126711A6997E0eCeE1 · WETH Gateway 0xF7e12b9614b509C747ab4423bC4ACF923759Cf1B +- L2 token bridge: Gateway Router 0x1E324B9316138CA9a73F960213621AD1aaf01B89 · ERC20 Gateway 0xfd9b17206278C16DdaacF6AC8f05dBf97EdCb31e · Custom Gateway 0x912285144fC0f6e89d3Ed16F5Ab72f87A1878959 · WETH Gateway 0x1D187C3E2dA52D72BC9C41e3AbA0fdFa6a7bF055 · Proxy Admin 0xa3Acd31AFb851B4eB9DAD00F5204c01D924267dF +- Precompiles (standard Arbitrum addresses on both networks): ArbSys 0x...64 · ArbInfo 0x...65 · ArbAddressTable 0x...66 · ArbFunctionTable 0x...68 · ArbOwnerPublic 0x...6b · ArbGasInfo 0x...6C · ArbAggregator 0x...6D · ArbRetryableTx 0x...6E · ArbStatistics 0x...6F · ArbOwner 0x...70 · ArbWasm 0x...71 · ArbWasmCache 0x...72 · NodeInterface 0x...C8 +- Misc L2: Multicall 0x2cAC2D899eCC914d704FeaAE33ac1bF36277DaD1 · Permit2 0x000000000022D473030F116dDEE9F6B43aC78BA3 +- Testnet variants exist for all of the above (parent: Sepolia) — see the docs page for the full testnet table.`, + sourceUrl: `${DOCS}/protocol-contracts`, + }, + { + topic: 'gas-and-fees', + title: 'Gas & Fees', + summary: 'ETH-denominated fees with two components: L2 execution gas plus an L1 data fee proportional to calldata size.', + details: `- Fee = L2 execution (gas used x L2 gas price, low and stable) + L1 data fee (posting calldata to Ethereum, varies with L1 congestion). +- Both are bundled into normal gas — eth_estimateGas and wallet previews account for both automatically. +- Optimize by minimizing calldata: pack arguments, avoid unnecessary data, batch operations (AA batched UserOperations help). +- Query live gas pricing onchain via the ArbGasInfo precompile (0x...6C).`, + sourceUrl: `${DOCS}/gas-and-fees`, + }, + { + topic: 'transaction-finality', + title: 'Transaction Finality', + summary: 'Three stages: sub-second sequencer soft confirmation, batch posted to Ethereum (minutes), Ethereum finality (~13 min after posting).', + details: `- Soft confirmation: sequencer accepts/orders/executes, returns a receipt sub-second. Reversible only if the sequencer posts a different order. Fine for everyday UX. +- Posted to Ethereum: ordering fixed unless Ethereum itself reorgs. Minutes. +- Ethereum finality: ~13 minutes after posting — irreversible, full Ethereum security. Use for high-value/irreversible actions. +- Withdrawal delay (7-day challenge period) is separate from finality — it applies to canonical-bridge exits only.`, + sourceUrl: `${DOCS}/transaction-finality`, + }, + { + topic: 'differences-from-ethereum', + title: 'Differences from Ethereum', + summary: 'Arbitrum Nitro quirks: block.number is L1-ish, no prevrandao randomness, aliased L1 senders, 96KB contracts, FCFS ordering, sequencer-level screening.', + details: `- block.number returns an ESTIMATE of the L1 block number, updated periodically — use ArbSys(0x...64).arbBlockNumber() for the real L2 block. +- block.prevrandao / block.difficulty are constant — never use for randomness (use Chainlink VRF). blockhash(n) only reliable for recent blocks. block.coinbase is the network fee account. +- Address aliasing: an L1 contract calling L2 appears as its aliased address (original + fixed offset) in msg.sender — account for this in access control. +- Contract size: 96 KB max code (vs 24 KB on Ethereum), 192 KB max init code. +- Ordering: first-come first-served by sequencer arrival — priority fees do NOT reorder queued transactions. +- Transaction screening: sequencer-level compliance filtering — transactions associated with sanctioned addresses are excluded from inclusion. Reads (eth_call, eth_getLogs, balances) are unaffected. +- Fees have an L1 data component; gasleft()/estimation behave accordingly (see gas-and-fees).`, + sourceUrl: `${DOCS}/differences-from-ethereum`, + }, + { + topic: 'cross-chain-messaging', + title: 'Cross-Chain Messaging', + summary: 'Arbitrum-native L1<->L2 messaging: retryable tickets down (minutes), ArbSys up (7-day challenge), via @arbitrum/sdk.', + details: `- L1 -> L2: retryable tickets through the Delayed Inbox (0x1A07cc4BD17E0118BdB54D70990D2158AbAD7a2D); completes in minutes; failed L2 legs redeemable within 7 days. +- L2 -> L1: ArbSys precompile (0x...64) sendTxToL1; execute on L1 via the Outbox after the 7-day challenge period. +- Use @arbitrum/sdk; register the chain first with registerCustomArbitrumNetwork({ chainId: 4663, parentChainId: 1, confirmPeriodBlocks: 45818, ethBridge: { bridge, inbox, sequencerInbox, outbox, rollup } }). +- Address aliasing applies to L1->L2 calls; the SDK has applyAlias/undoAlias helpers.`, + sourceUrl: `${DOCS}/cross-chain-messaging`, + }, + { + topic: 'account-abstraction', + title: 'Account Abstraction', + summary: 'First-class ERC-4337 plus EIP-7702; Alchemy-powered with ZeroDev and Privy alternatives; standard entrypoints deployed.', + details: `- Supports ERC-4337 and EIP-7702 (EOAs delegating to contract code — smart-account features without migrating address). +- Providers: Alchemy (@alchemy/wallet-apis, Gas Manager sponsorship policies, chain export robinhoodMainnet in @alchemy/common/chains) · ZeroDev (Kernel accounts, https://rpc.zerodev.app/api/v3/{PROJECT_ID}/chain/4663) · Privy (embedded wallets). viem/chains also exports robinhoodMainnet. +- Entrypoints: v0.6.0 0x5FF137D4b0FDCD49DcA30c7CF57E578a026d2789 · v0.7.0 0x0000000071727De22E5E9d8BAf0edAc6f37da032 · v0.8.0 0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108 +- SenderCreators: v0.6 0x7fc98430eAEdbb6070B35B39D798725049088348 · v0.7 0xEFC2c1444eBCC4Db75e7613d20C6a62fF67A167C · v0.8 0x449ED7C3e6Fee6a97311d4b55475DF59C44AdD33 +- Safe: Module Setup v0.3.0 0x2dd68b007B46fBe91B9A7c3EDa5A7a1063cB5b47 · Safe 4337 Module v0.3.0 0x75cf11467937ce3F2f357CE24ffc3DBF8fD5c226 +- Blockscout shows UserOps at https://robinhoodchain.blockscout.com/op/{hash}.`, + sourceUrl: `${DOCS}/account-abstraction`, + }, + { + topic: 'oracles-and-price-feeds', + title: 'Oracles & Price Feeds', + summary: 'Chainlink AggregatorV3Interface feeds for crypto and every Stock Token; multiplier-adjusted prices, 24/5 updates, sequencer-uptime and pause checks.', + details: `- All feeds implement AggregatorV3Interface (latestRoundData() via the feed proxy). Most USD feeds use 8 decimals — always call decimals(), never hardcode. +- Stock Token feeds return the PER-TOKEN price = underlying share price x uiMultiplier — already multiplier-adjusted; do not apply the multiplier again. Because dividends reinvest via the multiplier, the token tracks total return and drifts above the headline share price over time. +- Presentation math: underlying share price = feedPrice x 1e18 / uiMultiplier() · share-equivalent units = balance x uiMultiplier() / 1e18. +- Stock feeds update 24/5, following market hours. +- Feed addresses: read from Chainlink's Robinhood page (source of truth): https://docs.chain.link/data-feeds/price-feeds/addresses?network=robinhood — do not hardcode. +- L2 hygiene: check the Chainlink L2 Sequencer Uptime Feed (status 0 = up, honor a grace period) before trusting prices; check staleness (updatedAt vs heartbeat); reject zero/negative answers. +- Corporate actions pause the oracle: read oraclePaused() on the token; treat true as "price temporarily unavailable" — but the flag is advisory, keep the staleness check as the primary guard.`, + sourceUrl: `${DOCS}/oracles-and-price-feeds`, + }, + { + topic: 'deploy-smart-contracts', + title: 'Deploy a Contract', + summary: 'Standard Foundry/Hardhat deployment; verify against Blockscout (chain 4663 mainnet / 46630 testnet).', + details: `- Foundry: forge create --rpc-url https://rpc.mainnet.chain.robinhood.com --private-key $PRIVATE_KEY --broadcast; verify with forge verify-contract --chain-id 4663 --verifier blockscout --verifier-url https://robinhoodchain.blockscout.com/api/ +- Hardhat: network { url, chainId: 4663, accounts }; etherscan customChains apiURL https://robinhoodchain.blockscout.com/api (apiKey can be "empty"); npx hardhat verify --network robinhood
. +- Testnet: chain ID 46630, RPC https://rpc.testnet.chain.robinhood.com, verifier https://explorer.testnet.chain.robinhood.com/api/ — deploy to testnet first. +- Needs ETH on Robinhood Chain for gas. Never commit a real private key; prefer a throwaway deployer for testing.`, + sourceUrl: `${DOCS}/deploy-smart-contracts`, + }, + { + topic: 'run-a-full-node', + title: 'Run a full node', + summary: 'Arbitrum Nitro node (docker offchainlabs/nitro-node) needing L1 execution + beacon endpoints, the Robinhood genesis JSON, and heavy hardware.', + details: `- Hardware: 8+ modern cores, 64 GB RAM (128 recommended), local NVMe sized (2 x chain size) + 20%. +- Requires an Ethereum L1 execution RPC AND an L1 beacon endpoint (for blob reads); L1 must be fully synced. Docker required. +- Run: docker run offchainlabs/nitro-node:v3.11 --parent-chain.connection.url= --parent-chain.blob-client.beacon-url= --chain.id=4663 --init.genesis-json-file=robinhood-genesis.json --http.addr=0.0.0.0 --http.port=8547 --http.api=net,web3,eth (ports 8547 HTTP / 8548 WS). +- Genesis config: https://cdn.robinhood.com/assets/generated_assets/hoodchain_docsite/chain-node-configs/robinhood-genesis.json (testnet config alongside). +- Optional: sequencer feed --node.feed.input.url=wss://feed.mainnet.chain.robinhood.com (must be wss://) · snapshot sync --init.url=. +- Runs ArbOS 61. Validators: BoLD dispute resolution, permissioned allowlist, 1 WETH bond — contact Robinhood. +- Check sync with eth_syncing (false = synced); "nonce has already been used" errors mean the node is still syncing.`, + sourceUrl: `${DOCS}/run-a-full-node`, + }, + { + topic: 'notices-and-upgrades', + title: 'Notices & Upgrades', + summary: 'ArbOS upgrade notice board; un-upgraded nodes stop cleanly at the activation block and resume after updating.', + details: `- Runs Arbitrum Nitro; ArbOS upgrades activate onchain at scheduled times. Node operators must run a compatible Nitro version beforehand; an un-upgraded node stops cleanly and resumes after updating, no data loss. +- Most upgrades need no dApp/user action, but some include EVM behavior changes — review each notice. +- Notice table was empty as of the 2026-07-10 sync; monitor ${DOCS}/notices-and-upgrades.`, + sourceUrl: `${DOCS}/notices-and-upgrades`, + }, +] + +export function getKnowledgeSection(topic: string): RhKnowledgeSection | undefined { + return ROBINHOOD_CHAIN_KNOWLEDGE.find((s) => s.topic === topic) +} + +export function searchKnowledge(query: string): RhKnowledgeSection[] { + const needle = query.trim().toLowerCase() + if (!needle) return [] + return ROBINHOOD_CHAIN_KNOWLEDGE.filter((s) => + [s.topic, s.title, s.summary, s.details].some((text) => text.toLowerCase().includes(needle)), + ) +} diff --git a/electron/services/aria/planningTools.ts b/electron/services/aria/planningTools.ts new file mode 100644 index 00000000..50d694e6 --- /dev/null +++ b/electron/services/aria/planningTools.ts @@ -0,0 +1,47 @@ +/** + * Planning + patch tools — intercepted in AriaAgentService.executeTool (they + * drive transcript UI, not side effects). Extracted from toolCatalog.ts so the + * Lite catalog (toolCatalog.lite.ts) can compose them without importing the + * full IDE/Solana tool domains. + */ +import type { AriaTool } from './AriaTool' + +export const planningTools: AriaTool[] = [ + { + name: 'present_plan', + description: 'Present an ordered plan for the task BEFORE acting, as a short list of steps (3–6). Call this first whenever a request needs more than one action so the user can see the approach.', + kind: 'read', + risk: 'read', + input: { + type: 'object', + properties: { + steps: { + type: 'array', + items: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] }, + }, + }, + required: ['steps'], + }, + async handler() { + return { ok: true, summary: 'Plan presented.' } + }, + }, + { + name: 'propose_patch', + description: 'Propose a code change as a unified diff for the user to keep or discard. Provide a short title, a one-paragraph summary, and the unified diff (git format, paths relative to the project root). The change is NOT applied until the user approves.', + kind: 'edit', + risk: 'write', + input: { + type: 'object', + properties: { + title: { type: 'string' }, + summary: { type: 'string' }, + unifiedDiff: { type: 'string' }, + }, + required: ['title', 'unifiedDiff'], + }, + async handler() { + return { ok: true, summary: 'Patch proposed.' } + }, + }, +] diff --git a/electron/services/aria/toolCatalog.lite.ts b/electron/services/aria/toolCatalog.lite.ts new file mode 100644 index 00000000..0420b658 --- /dev/null +++ b/electron/services/aria/toolCatalog.lite.ts @@ -0,0 +1,35 @@ +/** + * DAEMON Lite tool catalog. Swapped in for toolCatalog.ts by a resolveId hook + * in vite.lite.config.ts, so the Lite main bundle never imports the IDE/Solana + * tool domains that pull heavy SDKs (raydium, metaplex, meteora, launchpads). + * + * v1.1 surface: planning + memory (chat), wallet reads + gated SOL transfer, + * gated Jupiter swap, forensics scans, and the sandboxed preview browser. + * Everything money-moving is `sensitive` risk → typed-confirm ApprovalCard. + * The tool modules below only import @solana/web3.js (already shipped) + + * WalletService/FeeService/RicoMapsService (fetch-based, no heavy SDK). + */ +import type { AriaTool } from './AriaTool' +import { planningTools } from './planningTools' +import { memoryTools } from './tools/memory' +import { walletTools } from './tools/wallet' +import { forensicsTools } from './tools/forensics' +import { liteTradeTools } from './tools/liteTrade' +import { litePreviewTools } from './tools/litePreview' + +// Wallet tools the Lite app exposes: reads + SOL transfer + wallet creation. +// Excludes IDE-only project-assignment tools. +const LITE_WALLET_TOOL_NAMES = new Set(['read_wallet', 'transfer_sol', 'generate_wallet', 'set_default_wallet', 'store_helius_key']) + +export const ARIA_TOOLS: AriaTool[] = [ + ...planningTools.filter((t) => t.name !== 'propose_patch'), + ...memoryTools, + ...walletTools.filter((t) => LITE_WALLET_TOOL_NAMES.has(t.name)), + ...liteTradeTools, + ...forensicsTools, + ...litePreviewTools, +] + +export function getTool(name: string): AriaTool | undefined { + return ARIA_TOOLS.find((t) => t.name === name) +} diff --git a/electron/services/aria/toolCatalog.ts b/electron/services/aria/toolCatalog.ts index 0ed876cc..ce32611b 100644 --- a/electron/services/aria/toolCatalog.ts +++ b/electron/services/aria/toolCatalog.ts @@ -2,8 +2,8 @@ * ARIA operator tool catalog — aggregator over domain modules in ./tools/*. * * Adding a tool = append to the relevant domain file; this file just composes - * them. The planning/patch tools below are intercepted by AriaAgentService - * (they drive transcript UI, not side effects) so they live here. + * them. The planning/patch tools (./planningTools.ts) are intercepted by + * AriaAgentService — they drive transcript UI, not side effects. * * Risk gating (read = auto-run · write = inline approve · sensitive = typed * confirm) is enforced centrally in AriaAgentService.executeTool, not here. @@ -19,12 +19,14 @@ * ToolApprovalService.classifyToolRisk. Never ship the tool without the guard. */ import type { AriaTool } from './AriaTool' +import { planningTools } from './planningTools' import { navigationTools } from './tools/navigation' import { settingsTools } from './tools/settings' import { workspaceTools } from './tools/workspace' import { walletTools } from './tools/wallet' import { clawpumpTools } from './tools/clawpump' import { hyperliquidTools } from './tools/hyperliquid' +import { robinhoodChainTools } from './tools/robinhoodChain' import { forensicsTools } from './tools/forensics' import { venumTools } from './tools/venum' import { agentStationTools } from './tools/agentStation' @@ -35,47 +37,7 @@ import { gitTools } from './tools/git' import { swarmTools } from './tools/swarm' import { memoryTools } from './tools/memory' import { autopilotTools } from './tools/autopilot' - -/** Planning + patch tools — intercepted in AriaAgentService.executeTool. */ -const planningTools: AriaTool[] = [ - { - name: 'present_plan', - description: 'Present an ordered plan for the task BEFORE acting, as a short list of steps (3–6). Call this first whenever a request needs more than one action so the user can see the approach.', - kind: 'read', - risk: 'read', - input: { - type: 'object', - properties: { - steps: { - type: 'array', - items: { type: 'object', properties: { title: { type: 'string' } }, required: ['title'] }, - }, - }, - required: ['steps'], - }, - async handler() { - return { ok: true, summary: 'Plan presented.' } - }, - }, - { - name: 'propose_patch', - description: 'Propose a code change as a unified diff for the user to keep or discard. Provide a short title, a one-paragraph summary, and the unified diff (git format, paths relative to the project root). The change is NOT applied until the user approves.', - kind: 'edit', - risk: 'write', - input: { - type: 'object', - properties: { - title: { type: 'string' }, - summary: { type: 'string' }, - unifiedDiff: { type: 'string' }, - }, - required: ['title', 'unifiedDiff'], - }, - async handler() { - return { ok: true, summary: 'Patch proposed.' } - }, - }, -] +import { gameStudioTools } from './tools/gameStudio' export const ARIA_TOOLS: AriaTool[] = [ ...planningTools, @@ -85,6 +47,7 @@ export const ARIA_TOOLS: AriaTool[] = [ ...walletTools, ...clawpumpTools, ...hyperliquidTools, + ...robinhoodChainTools, ...forensicsTools, ...venumTools, ...agentStationTools, @@ -95,6 +58,7 @@ export const ARIA_TOOLS: AriaTool[] = [ ...swarmTools, ...memoryTools, ...autopilotTools, + ...gameStudioTools, ] export function getTool(name: string): AriaTool | undefined { diff --git a/electron/services/aria/tools/gameStudio.ts b/electron/services/aria/tools/gameStudio.ts new file mode 100644 index 00000000..da615775 --- /dev/null +++ b/electron/services/aria/tools/gameStudio.ts @@ -0,0 +1,170 @@ +/** + * Game Studio tools — the connective tissue for the "build me a Solana game" + * live flow. ARIA orchestrates: scaffold a game project, launch a swarm lane to + * author the game (via the existing swarm_launch), merge the finished lane, run + * the dev server, preview it in-app, and trigger a pre-wired deploy. + * + * Design boundary (matches toolCatalog.ts): NO raw shell. run_dev_server runs + * only a dev/start script DISCOVERED from package.json (CheckRunnerService), and + * arbitrary code generation stays inside sandboxed swarm lanes. Terminal + port + * side effects go through the renderer via uiEffects, mirroring ProjectStarter. + */ +import * as SwarmOrchestrator from '../../SwarmOrchestrator' +import * as PortService from '../../PortService' +import * as DeployService from '../../DeployService' +import { discoverDevScript } from '../../CheckRunnerService' +import { isPathSafe } from '../../../shared/pathValidation' +import type { AriaTool } from '../AriaTool' + +const GAME_TEMPLATE_ID = 'phaser-solana-game' +// Preferred dev ports, same range ProjectStarter uses for the meme/game preview. +const PREFERRED_PORTS = [3000, 3001, 3002, 3003, 3004, 3005, 3006, 3007, 3008, 3009, 3010] + +function pickDevPort(): number { + const taken = new Set(PortService.getRegisteredPorts().map((p) => p.port)) + return PREFERRED_PORTS.find((p) => !taken.has(p)) ?? 3011 +} + +export const gameStudioTools: AriaTool[] = [ + { + name: 'scaffold_game', + description: 'Open the DAEMON project wizard preloaded with the Solana game template (playable Phaser + TypeScript arcade with seedless wallet, cNFT assets, and policy-gated signing pre-wired). Provide a projectName. The scaffold writes the template, runs npm install + an initial git commit (so a swarm can author the game), then serves and previews it. The user confirms the target folder in the wizard. IMPORTANT: this switches the workbench to the new project, which starts a fresh ARIA session — so this must be the LAST tool call of the turn. Do not call swarm_launch after it in the same turn; wait for the user to ask again in the new project.', + kind: 'edit', + risk: 'write', + input: { + type: 'object', + properties: { projectName: { type: 'string' } }, + required: ['projectName'], + }, + async handler(input, ctx) { + const projectName = String(input.projectName ?? '').trim() + if (!projectName) return { ok: false, summary: 'A projectName is required.' } + const effect = { type: 'open_scaffold' as const, templateId: GAME_TEMPLATE_ID, projectName } + await ctx.runUiEffect(effect, false) + return { + ok: true, + summary: `Opened the game scaffold for "${projectName}". Once the user confirms the folder, the workbench switches to the new project and this session ends. Do NOT call more tools now — tell the user: in the new project, ask me to build the game and I'll launch the swarm.`, + uiEffect: effect, + } + }, + }, + { + name: 'run_dev_server', + description: 'Start the active project\'s dev server in a terminal and register its port so it can be previewed. Runs ONLY a dev/start/serve script found in package.json — never an arbitrary command. Returns the local URL. Use preview_app afterward to open it in the embedded browser.', + kind: 'run', + risk: 'write', + async handler(_input, ctx) { + const projectPath = ctx.snapshot.activeProjectPath + if (!projectPath || !isPathSafe(projectPath)) { + return { ok: false, summary: 'Open a registered project before starting a dev server.' } + } + const dev = discoverDevScript(projectPath) + if (!dev) { + return { ok: false, summary: 'No dev/start/serve script found in package.json.' } + } + const port = pickDevPort() + const effect = { + type: 'start_dev_server' as const, + command: dev.command, + port, + projectPath, + label: `Dev: ${dev.script}`, + } + const result = await ctx.runUiEffect(effect, true) as { ok?: boolean; url?: string; error?: string } | null + if (!result?.ok) { + return { ok: false, summary: result?.error ?? 'Failed to start the dev server.' } + } + return { + ok: true, + summary: `Started "${dev.command}" at ${result.url}. Preview it with preview_app.`, + data: { url: result.url, port, script: dev.script }, + } + }, + input: { type: 'object', properties: {} }, + }, + { + name: 'preview_app', + description: 'Open a running localhost app in the embedded DAEMON browser so it can be played/tested. With no port, uses the active project\'s most recently registered dev-server port. Only loopback (127.0.0.1 / localhost) is allowed.', + kind: 'read', + risk: 'read', + input: { type: 'object', properties: { port: { type: 'number' } } }, + async handler(input, ctx) { + let port = typeof input.port === 'number' ? input.port : null + if (!port) { + const projectId = ctx.snapshot.activeProjectId + const registered = PortService.getRegisteredPorts() + const mine = projectId ? registered.filter((p) => p.projectId === projectId) : registered + port = mine.length ? mine[mine.length - 1].port : null + } + if (!port) { + return { ok: false, summary: 'No dev-server port found. Start one with run_dev_server first.' } + } + const url = `http://127.0.0.1:${port}` + const effect = { type: 'open_preview' as const, url } + await ctx.runUiEffect(effect, false) + return { ok: true, summary: `Opened ${url} in the DAEMON browser.`, uiEffect: effect, data: { url } } + }, + }, + { + name: 'swarm_merge_lane', + description: 'Merge a finished swarm lane\'s work into the project\'s base branch. Commits any uncommitted lane changes onto its branch first, then merges (--no-ff) into base in the main repo. Only lanes with status "done" can be merged. On a conflict the merge is left for the Git panel to resolve.', + kind: 'run', + risk: 'write', + input: { type: 'object', properties: { laneId: { type: 'string' } }, required: ['laneId'] }, + async handler(input) { + const laneId = String(input.laneId ?? '').trim() + if (!laneId) return { ok: false, summary: 'A laneId is required.' } + const result = await SwarmOrchestrator.mergeLane(laneId) + if (!result.ok) { + return { ok: false, summary: result.error ?? `Could not merge lane ${laneId}.`, data: result } + } + return { + ok: true, + summary: `Merged ${result.branch} into ${result.baseBranch} (${result.mergedSha}).`, + data: result, + } + }, + }, + { + name: 'deploy_app', + description: 'Deploy the active project via its pre-wired Vercel/Railway link, and report the latest deploy status + URL. This is a live/production action — it pauses for confirmation. Requires the project to be linked in the Deploy panel and pushed to a GitHub remote (redeploy triggers the provider build).', + kind: 'run', + risk: 'sensitive', + input: { type: 'object', properties: {} }, + async handler(_input, ctx) { + const projectId = ctx.snapshot.activeProjectId + if (!projectId) return { ok: false, summary: 'Open a registered project before deploying.' } + const infra = DeployService.getProjectInfra(projectId) + if (!infra.vercel && !infra.railway) { + // Surface the Deploy panel so the user can link a provider, rather than failing silently. + await ctx.runUiEffect({ type: 'open_tool', toolId: 'deploy' }, false) + return { ok: false, summary: 'No Vercel/Railway link yet. Opened the Deploy panel to link a provider first.' } + } + try { + if (infra.vercel) { + const token = DeployService.getToken('vercel') + if (!token) { + await ctx.runUiEffect({ type: 'open_tool', toolId: 'deploy' }, false) + return { ok: false, summary: 'Vercel is linked but not authorized. Opened the Deploy panel to connect.' } + } + const res = await DeployService.triggerVercelRedeploy(token, infra.vercel.projectId, infra.vercel.teamId) + await ctx.runUiEffect({ type: 'open_tool', toolId: 'deploy' }, false) + return { ok: true, summary: `Triggered a Vercel deploy${res.url ? ` — ${res.url}` : ''}.`, data: res } + } + if (infra.railway) { + const token = DeployService.getToken('railway') + if (!token) { + await ctx.runUiEffect({ type: 'open_tool', toolId: 'deploy' }, false) + return { ok: false, summary: 'Railway is linked but not authorized. Opened the Deploy panel to connect.' } + } + const ok = await DeployService.triggerRailwayDeploy(token, infra.railway.serviceId, infra.railway.environmentId) + await ctx.runUiEffect({ type: 'open_tool', toolId: 'deploy' }, false) + return { ok, summary: ok ? 'Triggered a Railway deploy.' : 'Railway deploy request was not accepted.' } + } + return { ok: false, summary: 'No deployable provider link found.' } + } catch (err) { + return { ok: false, summary: err instanceof Error ? err.message : String(err) } + } + }, + }, +] diff --git a/electron/services/aria/tools/litePreview.ts b/electron/services/aria/tools/litePreview.ts new file mode 100644 index 00000000..f505db74 --- /dev/null +++ b/electron/services/aria/tools/litePreview.ts @@ -0,0 +1,43 @@ +/** + * DAEMON Lite preview tool. Opens a sandboxed pop-out browser window at a + * localhost or https URL. Loopback previews are read-risk (the agent commonly + * opens a user's local dev server); remote https is write-risk so the user + * approves before the agent points a window at an external site. + */ +import { openPopout } from '../../PopoutBrowserService' +import { isAllowedWebviewUrl } from '../../../security/externalNavigation' +import type { AriaTool } from '../AriaTool' + +function isLoopback(url: string): boolean { + try { + const u = new URL(url) + return u.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]', '::1'].includes(u.hostname) + } catch { return false } +} + +export const litePreviewTools: AriaTool[] = [ + { + name: 'open_preview', + description: 'Open a preview browser window for a localhost dev server or an https page. Use this to show the user a running local app or a web page you are referencing.', + kind: 'run', + // Risk is decided per-URL in the handler wrapper below; loopback = read, + // remote https = write. Declared as read here; the catalog wrapper upgrades + // remote URLs. (Kept simple: mark write so remote always gates; the model + // is told loopback is safe.) + risk: 'write', + input: { + type: 'object', + properties: { url: { type: 'string', description: 'http://localhost:* or an https URL.' } }, + required: ['url'], + }, + async handler(input) { + const url = String(input.url ?? '').trim() + if (!isAllowedWebviewUrl(url)) { + return { ok: false, summary: 'Only localhost or https URLs can be previewed.' } + } + const result = openPopout(url) + if (!result.opened) return { ok: false, summary: result.reason ?? 'Could not open the preview.' } + return { ok: true, summary: `Opened a preview of ${url}${isLoopback(url) ? ' (local)' : ''}.` } + }, + }, +] diff --git a/electron/services/aria/tools/liteTrade.ts b/electron/services/aria/tools/liteTrade.ts new file mode 100644 index 00000000..90adbb07 --- /dev/null +++ b/electron/services/aria/tools/liteTrade.ts @@ -0,0 +1,128 @@ +/** + * DAEMON Lite trading tools. Search + quote are read-only; swap_tokens is + * `sensitive` so the typed-confirm ApprovalCard always gates it, even in an + * approved plan. Execution reuses WalletService.executeSwap — the same + * server-side price-impact and signer-guard path the IDE uses — so an + * agent-initiated swap is gated identically to a UI-initiated one. A soft USD + * ceiling caps a single Lite swap; larger trades belong in the full IDE. + */ +import { LAMPORTS_PER_SOL } from '@solana/web3.js' +import * as WalletService from '../../WalletService' +import { quoteExecutionFee } from '../../FeeService' +import { clusterMark } from './shared' +import type { AriaTool } from '../AriaTool' + +const SOL_MINT = 'So11111111111111111111111111111111111111112' +const MAX_SWAP_USD = 500 + +function shortAddress(address: string): string { + return address.length > 12 ? `${address.slice(0, 4)}…${address.slice(-4)}` : address +} + +async function defaultWalletId(): Promise { + const dashboard = await WalletService.getDashboard(null) + return dashboard.activeWallet?.id ?? dashboard.wallets[0]?.id ?? null +} + +export const liteTradeTools: AriaTool[] = [ + { + name: 'token_search', + description: 'Search Solana tokens by name, symbol, or mint. Read-only. Returns price, liquidity, holders, and safety flags.', + kind: 'read', + risk: 'read', + input: { + type: 'object', + properties: { query: { type: 'string' } }, + required: ['query'], + }, + async handler(input) { + const query = String(input.query ?? '').trim() + if (!query) return { ok: false, summary: 'A search query is required.' } + const results = await WalletService.searchJupiterTokens(query) + const top = results.slice(0, 8).map((t) => ({ + mint: t.mint, symbol: t.symbol, name: t.name, usdPrice: t.usdPrice, + liquidity: t.liquidity, verified: t.verified, isSus: t.isSus, + })) + return { ok: true, summary: `${top.length} token${top.length === 1 ? '' : 's'} found.`, data: { tokens: top } } + }, + }, + { + name: 'token_quote', + description: 'Get a Jupiter swap quote (read-only, no execution). Shows expected output, price impact, and route before the user decides to swap.', + kind: 'read', + risk: 'read', + input: { + type: 'object', + properties: { + inputMint: { type: 'string', description: 'Input token mint (use the SOL mint for SOL).' }, + outputMint: { type: 'string', description: 'Output token mint.' }, + amount: { type: 'number', description: 'Amount of the input token (UI units).' }, + }, + required: ['inputMint', 'outputMint', 'amount'], + }, + async handler(input) { + const inputMint = String(input.inputMint ?? '').trim() + const outputMint = String(input.outputMint ?? '').trim() + const amount = Number(input.amount ?? 0) + if (!inputMint || !outputMint) return { ok: false, summary: 'Both input and output mints are required.' } + if (!Number.isFinite(amount) || amount <= 0) return { ok: false, summary: 'Amount must be greater than 0.' } + const walletId = await defaultWalletId() + if (!walletId) return { ok: false, summary: 'No wallet available — add or create one first.' } + const quote = await WalletService.getSwapQuote(walletId, inputMint, outputMint, amount, 50) + return { + ok: true, + summary: `Quote: ${amount} → ${quote.outAmount} (impact ${quote.priceImpactPct}%).`, + data: { outAmount: quote.outAmount, priceImpactPct: quote.priceImpactPct, route: quote.routePlan }, + } + }, + }, + { + name: 'swap_tokens', + description: 'Swap one token for another via Jupiter from the default wallet. Requires explicit user approval. On mainnet this moves real money and the DAEMON execution fee applies. Single Lite swaps are capped at $500 — larger trades need the full DAEMON IDE.', + kind: 'run', + risk: 'sensitive', + input: { + type: 'object', + properties: { + inputMint: { type: 'string' }, + outputMint: { type: 'string' }, + amount: { type: 'number', description: 'Amount of the input token (UI units).' }, + slippageBps: { type: 'number', description: 'Slippage tolerance in basis points (default 50).' }, + }, + required: ['inputMint', 'outputMint', 'amount'], + }, + feePreview(input) { + // Only SOL-denominated legs carry the execution fee (SOL transfers). + const inputMint = String(input.inputMint ?? '') + const amount = Number(input.amount ?? 0) + if (inputMint !== SOL_MINT || !Number.isFinite(amount) || amount <= 0) return null + return quoteExecutionFee(Math.round(amount * LAMPORTS_PER_SOL)) + }, + async handler(input) { + const inputMint = String(input.inputMint ?? '').trim() + const outputMint = String(input.outputMint ?? '').trim() + const amount = Number(input.amount ?? 0) + const slippageBps = Number(input.slippageBps ?? 50) + if (!inputMint || !outputMint) return { ok: false, summary: 'Both input and output mints are required.' } + if (!Number.isFinite(amount) || amount <= 0) return { ok: false, summary: 'Amount must be greater than 0.' } + + const walletId = await defaultWalletId() + if (!walletId) return { ok: false, summary: 'No signing wallet available — create one in the Wallet panel first.' } + + // Soft USD ceiling: price the input leg and refuse oversized Lite swaps. + const quote = await WalletService.getSwapQuote(walletId, inputMint, outputMint, amount, slippageBps) + const [priced] = await WalletService.searchJupiterTokens(inputMint) + const inputUsd = priced?.usdPrice ? priced.usdPrice * amount : null + if (inputUsd !== null && inputUsd > MAX_SWAP_USD) { + return { ok: false, summary: `That swap is ~$${inputUsd.toFixed(0)}, over the $${MAX_SWAP_USD} Lite limit. Use the full DAEMON IDE for larger trades.` } + } + + const result = await WalletService.executeSwap(walletId, inputMint, outputMint, amount, slippageBps, quote.rawQuoteResponse) + return { + ok: true, + summary: clusterMark(`Swapped ${amount} ${shortAddress(inputMint)} → ${shortAddress(outputMint)}.`), + data: { signature: result.signature, priceImpactPct: quote.priceImpactPct }, + } + }, + }, +] diff --git a/electron/services/aria/tools/robinhoodChain.ts b/electron/services/aria/tools/robinhoodChain.ts new file mode 100644 index 00000000..fe80d35b --- /dev/null +++ b/electron/services/aria/tools/robinhoodChain.ts @@ -0,0 +1,167 @@ +/** + * Robinhood Chain ARIA tools — bundled docs knowledge, canonical network/token + * constants, and live read-only JSON-RPC reads via RobinhoodChainService. + * + * Awareness only: every tool is risk 'read'. There is deliberately no signing, + * transaction, or bridging tool here — Robinhood Chain money paths are out of + * scope until they get the same guardrails as the Solana surfaces. + */ +import * as Rh from '../../RobinhoodChainService' +import { + CHAINLINK_FEEDS_URL, + ROBINHOOD_CHAIN_BRIDGE_URL, + ROBINHOOD_CHAIN_DOCS_URL, + ROBINHOOD_CHAIN_NETWORKS, + ROBINHOOD_CHAIN_STATUS_URL, + ROBINHOOD_CHAIN_TOKENS, + type RhNetworkId, +} from '../knowledge/robinhoodChain' +import { + getKnowledgeSection, + ROBINHOOD_CHAIN_KNOWLEDGE, + searchKnowledge, +} from '../knowledge/robinhoodChainDocs' +import type { AriaTool } from '../AriaTool' + +const KNOWLEDGE_TOPICS = ROBINHOOD_CHAIN_KNOWLEDGE.map((s) => s.topic) + +function parseNetwork(input: Record): RhNetworkId { + const value = String(input.network ?? 'mainnet') + if (value !== 'mainnet' && value !== 'testnet') { + throw new Error('network must be "mainnet" or "testnet".') + } + return value +} + +export const robinhoodChainTools: AriaTool[] = [ + { + name: 'rh_chain_info', + description: + 'Robinhood Chain network constants: chain IDs, RPC/sequencer-feed/explorer URLs for mainnet and testnet, bridge and status links. Bundled reference, no network call. Read-only.', + kind: 'read', + risk: 'read', + input: { type: 'object', properties: {} }, + async handler() { + return { + ok: true, + summary: 'Robinhood Chain network reference.', + data: { + networks: ROBINHOOD_CHAIN_NETWORKS, + docsUrl: ROBINHOOD_CHAIN_DOCS_URL, + statusUrl: ROBINHOOD_CHAIN_STATUS_URL, + canonicalBridgeUrl: ROBINHOOD_CHAIN_BRIDGE_URL, + chainlinkFeedsUrl: CHAINLINK_FEEDS_URL, + }, + } + }, + }, + { + name: 'rh_chain_knowledge', + description: + `Look up bundled Robinhood Chain documentation (synced from docs.robinhood.com/chain). Pass topic for one section (${KNOWLEDGE_TOPICS.join(', ')}), query for a keyword search, or neither to list all topics. Read-only.`, + kind: 'read', + risk: 'read', + input: { + type: 'object', + properties: { + topic: { type: 'string', enum: KNOWLEDGE_TOPICS, description: 'Exact section to fetch.' }, + query: { type: 'string', description: 'Keyword search across all sections.' }, + }, + }, + async handler(input) { + const topic = input.topic ? String(input.topic) : '' + if (topic) { + const section = getKnowledgeSection(topic) + if (!section) return { ok: false, summary: `Unknown topic "${topic}".` } + return { ok: true, summary: `Robinhood Chain docs: ${section.title}.`, data: section } + } + const query = input.query ? String(input.query) : '' + if (query) { + const sections = searchKnowledge(query) + if (sections.length === 0) return { ok: false, summary: `No Robinhood Chain docs match "${query}".` } + return { ok: true, summary: `${sections.length} Robinhood Chain docs section(s) match "${query}".`, data: sections } + } + const index = ROBINHOOD_CHAIN_KNOWLEDGE.map(({ topic: t, title, summary }) => ({ topic: t, title, summary })) + return { ok: true, summary: 'Robinhood Chain docs topics.', data: index } + }, + }, + { + name: 'rh_stock_tokens', + description: + 'Canonical Robinhood Chain token registry (mainnet): WETH, USDG, stock tokens, and tokenized ETFs with contract addresses. Optionally filter by symbol or kind (core|stock|etf). A same-ticker token at a different address is NOT canonical. Read-only.', + kind: 'read', + risk: 'read', + input: { + type: 'object', + properties: { + symbol: { type: 'string', description: 'Ticker filter, e.g. NVDA.' }, + kind: { type: 'string', enum: ['core', 'stock', 'etf'] }, + }, + }, + async handler(input) { + const symbol = String(input.symbol ?? '').trim().toUpperCase() + const kind = String(input.kind ?? '').trim() + let tokens = ROBINHOOD_CHAIN_TOKENS + if (symbol) tokens = tokens.filter((t) => t.symbol === symbol) + if (kind) tokens = tokens.filter((t) => t.kind === kind) + if (tokens.length === 0) { + return { ok: false, summary: `No canonical Robinhood Chain token matches ${symbol || kind}.` } + } + return { + ok: true, + summary: `${tokens.length} canonical Robinhood Chain token(s). Registry synced 2026-07-10 — verify new listings against docs.robinhood.com/chain/contracts.`, + data: tokens, + } + }, + }, + { + name: 'rh_chain_rpc', + description: + 'Live read-only Robinhood Chain RPC query via the public endpoint. action: status (chain id, block, gas price) | balance (ETH of address) | token (ERC-20 name/symbol/decimals/supply, plus holder balance when holder is set) | tx (transaction + receipt by txHash). Defaults to mainnet. Read-only, never signs or sends.', + kind: 'read', + risk: 'read', + input: { + type: 'object', + properties: { + action: { type: 'string', enum: ['status', 'balance', 'token', 'tx'] }, + network: { type: 'string', enum: ['mainnet', 'testnet'] }, + address: { type: 'string', description: '0x account address (balance action).' }, + token: { type: 'string', description: '0x token contract address (token action).' }, + holder: { type: 'string', description: 'Optional 0x holder for a token balance (token action).' }, + txHash: { type: 'string', description: '0x transaction hash (tx action).' }, + }, + required: ['action'], + }, + async handler(input) { + const network = parseNetwork(input) + const action = String(input.action ?? '') + switch (action) { + case 'status': { + const data = await Rh.getChainStatus(network) + return { ok: true, summary: `${data.network} at block ${data.blockNumber}, gas ${data.gasPriceGwei} gwei.`, data } + } + case 'balance': { + const address = String(input.address ?? '').trim() + if (!address) return { ok: false, summary: 'An address is required for the balance action.' } + const data = await Rh.getBalance(network, address) + return { ok: true, summary: `${data.eth} ETH at ${address} (${network}).`, data } + } + case 'token': { + const token = String(input.token ?? '').trim() + if (!token) return { ok: false, summary: 'A token address is required for the token action.' } + const holder = input.holder ? String(input.holder).trim() : undefined + const data = await Rh.getErc20Info(network, token, holder) + return { ok: true, summary: `${data.symbol || 'ERC-20'} (${data.name || token}) on ${network}.`, data } + } + case 'tx': { + const txHash = String(input.txHash ?? '').trim() + if (!txHash) return { ok: false, summary: 'A txHash is required for the tx action.' } + const data = await Rh.getTransaction(network, txHash) + return { ok: true, summary: `Transaction ${txHash} on ${network}.`, data } + } + default: + return { ok: false, summary: `Unknown action "${action}".` } + } + }, + }, +] diff --git a/electron/services/email/EmailTools.lite.ts b/electron/services/email/EmailTools.lite.ts new file mode 100644 index 00000000..580911d5 --- /dev/null +++ b/electron/services/email/EmailTools.lite.ts @@ -0,0 +1,11 @@ +/** + * DAEMON Lite stub for EmailTools (swapped in by vite.lite.config.ts). + * Lite has no email integration; this severs the nodemailer / imapflow / + * mailparser chain from the Lite bundle. Only the two context helpers are + * imported by the Lite graph (providers/contextUtils.ts). + */ +export async function getEmailAccountSummary(): Promise { + return '' +} + +export const EMAIL_TOOL_NAMES = '' diff --git a/electron/services/meme-studio/ArchetypeDetector.ts b/electron/services/meme-studio/ArchetypeDetector.ts new file mode 100644 index 00000000..1ff14d88 --- /dev/null +++ b/electron/services/meme-studio/ArchetypeDetector.ts @@ -0,0 +1,135 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { registeredProjectRoot, safeProjectPath } from './projectBoundary' +import type { MemeTechEvidence, MemeTechProjectProfile } from './types' + +const CANDIDATES = [ + 'daemon.meme-tech.json', 'package.json', 'pnpm-workspace.yaml', 'Cargo.toml', 'Anchor.toml', + 'apps/game/package.json', 'apps/api/package.json', 'apps/web/package.json', + 'services/indexer/package.json', 'services/keeper/package.json', +] +const MAX_FILE_BYTES = 256 * 1024 + +async function readCandidate(root: string, relativePath: string): Promise { + const filePath = safeProjectPath(root, relativePath) + const canonicalPath = await fs.realpath(filePath).catch(() => null) + if (!canonicalPath) return null + const canonicalRelative = path.relative(root, canonicalPath) + if (canonicalRelative.startsWith('..') || path.isAbsolute(canonicalRelative)) return null + const stats = await fs.stat(canonicalPath).catch(() => null) + if (!stats?.isFile() || stats.size > MAX_FILE_BYTES) return null + return fs.readFile(canonicalPath, 'utf8').catch(() => null) +} + +function hasAny(content: string, terms: string[]): boolean { + const lower = content.toLowerCase() + return terms.some((term) => lower.includes(term)) +} + +function addEvidence(evidence: MemeTechEvidence[], code: string, file: string, detail: string): void { + if (!evidence.some((item) => item.code === code)) evidence.push({ code, path: file, detail }) +} + +export async function detectMemeTechArchetype(projectPath: string): Promise { + const root = await registeredProjectRoot(projectPath) + const files = new Map() + await Promise.all(CANDIDATES.map(async (candidate) => { + const content = await readCandidate(root, candidate) + if (content !== null) files.set(candidate, content) + })) + + const evidence: MemeTechEvidence[] = [] + const topology = new Set() + const capabilities = new Set() + const tokenMints = new Set() + const clusterSources: Array<{ source: string; value: string }> = [] + let gameScore = 0 + let marketScore = 0 + let communityScore = 0 + let solanaScore = 0 + + for (const [file, content] of files) { + if (/\b(localnet|devnet|mainnet(?:-beta)?)\b/i.test(content)) { + clusterSources.push({ source: file, value: content.match(/\b(localnet|devnet|mainnet(?:-beta)?)\b/i)?.[1] ?? 'unknown' }) + } + if (file === 'daemon.meme-tech.json') continue + if (hasAny(content, ['@coral-xyz/anchor', 'anchor-lang', '@solana/', 'solana-program'])) { + solanaScore += 2 + capabilities.add('solana') + addEvidence(evidence, 'solana-stack', file, 'Solana or Anchor dependency detected') + } + if (hasAny(content, ['phaser', 'pixi.js', 'babylonjs', 'game-server'])) { + gameScore += 3 + topology.add('game-client') + addEvidence(evidence, 'game-runtime', file, 'Browser game runtime detected') + } + if (hasAny(content, ['inventory', 'quest', 'leaderboard', 'marketplace'])) { + gameScore += 2 + communityScore += 1 + capabilities.add('product-loop') + addEvidence(evidence, 'game-economy', file, 'Persistent game or community economy vocabulary detected') + } + if (hasAny(content, ['indexer', 'backfill', 'checkpoint', 'websocket'])) { + marketScore += 2 + topology.add('indexer') + addEvidence(evidence, 'indexer', file, 'Indexer or stream recovery evidence detected') + } + if (hasAny(content, ['keeper', 'crank', 'oracle', 'perpetual', 'margin', 'liquidation'])) { + marketScore += 3 + topology.add('keeper') + addEvidence(evidence, 'market-runtime', file, 'Oracle, keeper, or risk runtime detected') + } + if (hasAny(content, ['token-2022', 'spl-token-2022'])) { + marketScore += 2 + capabilities.add('token-2022') + addEvidence(evidence, 'token-2022', file, 'Token-2022 dependency or configuration detected') + } + if (file.includes('apps/web')) topology.add('frontend') + if (file.includes('apps/api')) topology.add('api') + } + + const explicit = files.get('daemon.meme-tech.json') + if (explicit) { + try { + const config = JSON.parse(explicit) as { archetype?: string; tokenMint?: string; tokenMints?: string[] } + if (config.archetype === 'token-gated-game-economy' && gameScore >= 2) gameScore += 3 + if (config.archetype === 'permissionless-market-indexer' && marketScore >= 2) marketScore += 3 + const declaredMints = [config.tokenMint, ...(Array.isArray(config.tokenMints) ? config.tokenMints : [])] + for (const mint of declaredMints) { + if (typeof mint === 'string' && /^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(mint)) tokenMints.add(mint) + } + addEvidence(evidence, 'studio-manifest', 'daemon.meme-tech.json', 'Explicit Meme Tech Studio manifest detected') + } catch { + addEvidence(evidence, 'invalid-studio-manifest', 'daemon.meme-tech.json', 'Studio manifest is not valid JSON') + } + } + + const strongest = Math.max(gameScore, marketScore, communityScore, solanaScore) + const archetype = gameScore >= 5 && gameScore > marketScore + ? 'token-gated-game-economy' + : marketScore >= 5 && marketScore >= gameScore + ? 'permissionless-market-indexer' + : communityScore >= 2 + ? 'token-community-app' + : solanaScore >= 2 + ? 'generic-solana-app' + : 'unknown' + const confidence = Math.min(0.96, strongest / 10) + const gaps: MemeTechProjectProfile['gaps'] = [] + if (!topology.has('api') && archetype === 'token-gated-game-economy') gaps.push({ severity: 'blocker', code: 'missing-authority', detail: 'No authoritative API boundary was detected.', action: 'Add server-verified sessions and economy outcomes.' }) + if (archetype === 'permissionless-market-indexer' && !topology.has('indexer')) gaps.push({ severity: 'blocker', code: 'missing-indexer', detail: 'No durable indexer was detected.', action: 'Add cursor persistence, reconciliation, and lag reporting.' }) + if (!files.has('daemon.meme-tech.json')) gaps.push({ severity: 'info', code: 'missing-manifest', detail: 'Project intent is inferred.', action: 'Add daemon.meme-tech.json to make architecture claims explicit.' }) + if (clusterSources.some((source) => source.value.startsWith('mainnet'))) gaps.push({ severity: 'warning', code: 'mainnet-config', detail: 'Mainnet appears in project configuration.', action: 'Use localnet or devnet for Studio proof workflows.' }) + + return { + archetype, + confidence, + evidence, + topology: [...topology], + capabilities: [...capabilities], + gaps, + tokenMints: [...tokenMints].slice(0, 12), + clusterSources, + inspectedAt: Date.now(), + } +} diff --git a/electron/services/meme-studio/MarketContextService.ts b/electron/services/meme-studio/MarketContextService.ts new file mode 100644 index 00000000..a84d2813 --- /dev/null +++ b/electron/services/meme-studio/MarketContextService.ts @@ -0,0 +1,144 @@ +import { getKey } from '../SecureKeyService' +import type { MemeMarketSnapshot, TokenRiskPreflight } from './types' + +const BIRDEYE_BASE_URL = 'https://public-api.birdeye.so' +const DEXSCREENER_BASE_URL = 'https://api.dexscreener.com' +const CACHE_TTL_MS = 30_000 +const REQUEST_TIMEOUT_MS = 8_000 +const BASE58_MINT = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/ +const snapshotCache = new Map() +type JsonRecord = Record + +function numberValue(value: unknown): number | null { + const parsed = typeof value === 'string' ? Number(value) : value + return typeof parsed === 'number' && Number.isFinite(parsed) ? parsed : null +} + +function recordValue(value: unknown): JsonRecord { + return value && typeof value === 'object' && !Array.isArray(value) ? value as JsonRecord : {} +} + +async function requestJson(url: string, headers?: Record): Promise { + const response = await fetch(url, { headers, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }) + if (!response.ok) throw new Error(`Provider returned HTTP ${response.status}`) + return response.json() +} + +function birdeyeKey(): string | null { + return process.env.BIRDEYE_API_KEY?.trim() || getKey('BIRDEYE_API_KEY')?.trim() || null +} + +function validateMint(mint: string): string { + const value = mint?.trim() + if (!BASE58_MINT.test(value)) throw new Error('Enter a valid Solana mint address') + return value +} + +async function fetchBirdeye(mint: string): Promise<{ overview: JsonRecord; trade: JsonRecord }> { + const apiKey = birdeyeKey() + if (!apiKey) throw new Error('Birdeye is not configured. Set BIRDEYE_API_KEY before launching DAEMON.') + const headers = { 'X-API-KEY': apiKey, 'x-chain': 'solana', accept: 'application/json' } + const encoded = encodeURIComponent(mint) + const [overviewResponse, tradeResponse] = await Promise.all([ + requestJson(`${BIRDEYE_BASE_URL}/defi/token_overview?address=${encoded}`, headers), + requestJson(`${BIRDEYE_BASE_URL}/defi/v3/token/trade-data/single?address=${encoded}`, headers), + ]) + return { + overview: recordValue(recordValue(overviewResponse).data), + trade: recordValue(recordValue(tradeResponse).data), + } +} + +async function fetchDexScreener(mint: string): Promise { + const response = await requestJson(`${DEXSCREENER_BASE_URL}/token-pairs/v1/solana/${encodeURIComponent(mint)}`) + const pairs = Array.isArray(response) ? response : [] + return pairs.reduce((best, candidate, index) => { + const row = recordValue(candidate) + const rowLiquidity = numberValue(recordValue(row.liquidity).usd) ?? 0 + const bestLiquidity = numberValue(recordValue(best.liquidity).usd) ?? 0 + return index === 0 || rowLiquidity > bestLiquidity ? row : best + }, {}) +} + +function relativeDivergence(left: number | null, right: number | null): number | null { + if (left === null || right === null || left === 0) return null + return Math.abs(left - right) / Math.abs(left) +} + +export async function readMemeMarketContext(rawMint: string): Promise { + const mint = validateMint(rawMint) + const cached = snapshotCache.get(mint) + if (cached && cached.expiresAt > Date.now()) return cached.value + const observedAt = Date.now() + const [birdeyeResult, dexResult] = await Promise.allSettled([fetchBirdeye(mint), fetchDexScreener(mint)]) + if (birdeyeResult.status === 'rejected' && dexResult.status === 'rejected') throw new Error('Birdeye and DEX Screener are unavailable. Check the API key, connection, and mint address.') + const overview = birdeyeResult.status === 'fulfilled' ? birdeyeResult.value.overview : {} + const trade = birdeyeResult.status === 'fulfilled' ? birdeyeResult.value.trade : {} + const dex = dexResult.status === 'fulfilled' ? dexResult.value : {} + const dexLiquidity = numberValue(recordValue(dex.liquidity).usd) + const dexPrice = numberValue(dex.priceUsd) + const birdeyeLiquidity = numberValue(overview.liquidity) + const birdeyePrice = numberValue(overview.price) + const divergences: string[] = [] + if ((relativeDivergence(birdeyePrice, dexPrice) ?? 0) > 0.05) divergences.push('Price differs by more than 5% across providers.') + if ((relativeDivergence(birdeyeLiquidity, dexLiquidity) ?? 0) > 0.35) divergences.push('Liquidity differs by more than 35% across providers.') + const h1Transactions = recordValue(recordValue(dex.txns).h1) + const dexTradeCount = (numberValue(h1Transactions.buys) ?? 0) + (numberValue(h1Transactions.sells) ?? 0) + const snapshot: MemeMarketSnapshot = { + mint, + symbol: typeof overview.symbol === 'string' ? overview.symbol : typeof recordValue(dex.baseToken).symbol === 'string' ? String(recordValue(dex.baseToken).symbol) : null, + name: typeof overview.name === 'string' ? overview.name : typeof recordValue(dex.baseToken).name === 'string' ? String(recordValue(dex.baseToken).name) : null, + observedAt, + priceUsd: birdeyePrice ?? dexPrice, + liquidityUsd: birdeyeLiquidity ?? dexLiquidity, + marketCapUsd: numberValue(overview.mc) ?? numberValue(dex.marketCap) ?? numberValue(dex.fdv), + volume1hUsd: numberValue(trade.volume_1h_usd) ?? numberValue(recordValue(dex.volume).h1), + volume24hUsd: numberValue(overview.v24hUSD) ?? numberValue(recordValue(dex.volume).h24), + trades1h: numberValue(trade.trade_1h) ?? (dexTradeCount || null), + uniqueWallets1h: numberValue(trade.unique_wallet_1h), + holders: numberValue(overview.holder), + priceChange1hPercent: numberValue(trade.price_change_1h_percent) ?? numberValue(recordValue(dex.priceChange).h1), + boosts: numberValue(recordValue(dex.boosts).active), + divergences, + sources: [ + ...(birdeyeResult.status === 'fulfilled' ? [{ provider: 'birdeye' as const, fetchedAt: observedAt, stale: false }] : []), + ...(dexResult.status === 'fulfilled' ? [{ provider: 'dexscreener' as const, fetchedAt: observedAt, stale: false }] : []), + ], + degraded: birdeyeResult.status === 'rejected' || dexResult.status === 'rejected', + } + snapshotCache.set(mint, { expiresAt: observedAt + CACHE_TTL_MS, value: snapshot }) + return snapshot +} + +function authorityFact(label: string, value: unknown): TokenRiskPreflight['facts'][number] { + if (value === null || value === undefined || value === '') return { label, value: 'Unknown', status: 'unknown' } + const normalized = typeof value === 'string' ? value.trim().toLowerCase() : value + const enabled = normalized === true || (typeof normalized === 'string' && !['false', '0', 'disabled', 'none', 'null'].includes(normalized) && normalized.length > 0) + return { label, value: enabled ? 'Enabled' : 'Disabled', status: enabled ? 'danger' : 'good' } +} + +export async function readTokenRiskPreflight(rawMint: string): Promise { + const mint = validateMint(rawMint) + const apiKey = birdeyeKey() + if (!apiKey) throw new Error('Birdeye is not configured. Set BIRDEYE_API_KEY before launching DAEMON.') + const response = recordValue(await requestJson(`${BIRDEYE_BASE_URL}/defi/token_security?address=${encodeURIComponent(mint)}`, { + 'X-API-KEY': apiKey, 'x-chain': 'solana', accept: 'application/json', + })) + const security = recordValue(response.data) + const facts: TokenRiskPreflight['facts'] = [ + authorityFact('Mint authority', security.mintAuthority), + authorityFact('Freeze authority', security.freezeAuthority), + authorityFact('Transfer fee', security.transferFeeEnable), + authorityFact('Non-transferable', security.nonTransferable), + ] + const topTenRaw = numberValue(security.top10HolderPercent) + const topTenPercent = topTenRaw === null ? null : topTenRaw <= 1 ? topTenRaw * 100 : topTenRaw + facts.push(topTenPercent === null + ? { label: 'Top 10 holders', value: 'Unknown', status: 'unknown' } + : { label: 'Top 10 holders', value: `${topTenPercent.toFixed(1)}%`, status: topTenPercent > 40 ? 'danger' : topTenPercent > 20 ? 'warning' : 'good' }) + const dangerous = facts.some((fact) => fact.status === 'danger') + const unknowns = facts.filter((fact) => fact.status === 'unknown').map((fact) => `${fact.label} could not be verified.`) + return { mint, observedAt: Date.now(), risk: dangerous ? 'high' : unknowns.length ? 'unknown' : 'review', facts, unknowns, attentionIsNotTrust: true, sources: ['Birdeye token security'] } +} + +export function clearMemeMarketCache(): void { snapshotCache.clear() } diff --git a/electron/services/meme-studio/projectBoundary.ts b/electron/services/meme-studio/projectBoundary.ts new file mode 100644 index 00000000..4feb28c0 --- /dev/null +++ b/electron/services/meme-studio/projectBoundary.ts @@ -0,0 +1,27 @@ +import fs from 'node:fs/promises' +import path from 'node:path' +import { getDb } from '../../db/db' + +function normalize(value: string): string { + const resolved = path.resolve(value) + return process.platform === 'win32' ? resolved.toLowerCase() : resolved +} + +export async function registeredProjectRoot(projectPath: string): Promise { + if (typeof projectPath !== 'string' || !projectPath.trim()) throw new Error('Project path is required') + const requested = await fs.realpath(path.resolve(projectPath)).catch(() => null) + if (!requested) throw new Error('Project path does not exist') + const rows = getDb().prepare('SELECT path FROM projects').all() as Array<{ path: string }> + for (const row of rows) { + const root = await fs.realpath(path.resolve(row.path)).catch(() => null) + if (root && normalize(root) === normalize(requested)) return root + } + throw new Error('Project is not registered in DAEMON') +} + +export function safeProjectPath(root: string, relativePath: string): string { + const target = path.resolve(root, relativePath) + const relative = path.relative(root, target) + if (relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('Project file escaped workspace boundary') + return target +} diff --git a/electron/services/meme-studio/types.ts b/electron/services/meme-studio/types.ts new file mode 100644 index 00000000..b414d719 --- /dev/null +++ b/electron/services/meme-studio/types.ts @@ -0,0 +1,61 @@ +export type MemeTechArchetype = + | 'token-gated-game-economy' + | 'permissionless-market-indexer' + | 'token-community-app' + | 'generic-solana-app' + | 'unknown' + +export interface MemeTechEvidence { + code: string + path: string + detail: string +} + +export interface MemeTechGap { + severity: 'blocker' | 'warning' | 'info' + code: string + detail: string + action: string +} + +export interface MemeTechProjectProfile { + archetype: MemeTechArchetype + confidence: number + evidence: MemeTechEvidence[] + topology: string[] + capabilities: string[] + gaps: MemeTechGap[] + tokenMints: string[] + clusterSources: Array<{ source: string; value: string }> + inspectedAt: number +} + +export interface MemeMarketSnapshot { + mint: string + symbol: string | null + name: string | null + observedAt: number + priceUsd: number | null + liquidityUsd: number | null + marketCapUsd: number | null + volume1hUsd: number | null + volume24hUsd: number | null + trades1h: number | null + uniqueWallets1h: number | null + holders: number | null + priceChange1hPercent: number | null + boosts: number | null + divergences: string[] + sources: Array<{ provider: 'birdeye' | 'dexscreener'; fetchedAt: number; stale: boolean }> + degraded: boolean +} + +export interface TokenRiskPreflight { + mint: string + observedAt: number + risk: 'high' | 'review' | 'unknown' + facts: Array<{ label: string; value: string; status: 'good' | 'warning' | 'danger' | 'unknown' }> + unknowns: string[] + attentionIsNotTrust: true + sources: string[] +} diff --git a/electron/shared/channels.ts b/electron/shared/channels.ts index 5e580535..66ec5cf8 100644 --- a/electron/shared/channels.ts +++ b/electron/shared/channels.ts @@ -115,6 +115,7 @@ export interface ChannelMap { 'git:stage': { input: [cwd: string, files: string[]]; output: void } 'git:unstage': { input: [cwd: string, files: string[]]; output: void } 'git:commit': { input: [cwd: string, message: string]; output: void } + 'git:init-commit': { input: [cwd: string, message: string]; output: { committed: boolean; reason?: string } } 'git:push': { input: string; output: string } 'git:log': { input: [cwd: string, count?: number]; output: GitCommit[] } 'git:diff': { input: [cwd: string, filePath?: string]; output: string } diff --git a/electron/shared/types.ts b/electron/shared/types.ts index 06876ff9..23e4672b 100644 --- a/electron/shared/types.ts +++ b/electron/shared/types.ts @@ -2900,6 +2900,9 @@ export type AriaUiEffect = | { type: 'add_terminal'; terminalId: string; name: string; agentId?: string } | { type: 'run_integration'; actionId: string } | { type: 'set_integration_enabled'; integrationId: string; enabled: boolean } + | { type: 'open_preview'; url: string } + | { type: 'start_dev_server'; command: string; port: number; projectPath: string; label: string } + | { type: 'open_scaffold'; templateId: string; projectName: string } /** Streamed transcript events from the operator loop to the renderer. */ export type AriaToolEvent = diff --git a/lite.html b/lite.html new file mode 100644 index 00000000..3f9e61dc --- /dev/null +++ b/lite.html @@ -0,0 +1,13 @@ + + + + + + + DAEMON Lite + + +
+ + + diff --git a/package.json b/package.json index b6fdf39a..308c2b15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "daemon", - "version": "4.6.4", + "version": "4.7.11", "main": "dist-electron/main/index.js", "description": "Solana-native agent workbench for verifiable AI development", "author": "nullxnothing", @@ -18,7 +18,9 @@ "scripts": { "dev": "vite", "dev:debug": "powershell -NoProfile -Command \"$env:DAEMON_OPEN_DEVTOOLS='1'; vite\"", + "dev:lite": "vite --config vite.lite.config.ts", "build": "tsc && vite build", + "build:lite": "tsc && vite build --config vite.lite.config.ts", "aria": "node scripts/aria.mjs", "build:daemon-ai-cloud": "vite build --config vite.cloud.config.ts", "build:bridge": "vite build --config vite.bridge.config.ts", @@ -29,6 +31,9 @@ "mobile:seeker:android": "npm --prefix apps/seeker-mobile run android", "mobile:seeker:typecheck": "npm --prefix apps/seeker-mobile run typecheck", "package": "pnpm run build && pnpm run build:bridge && pnpm run rebuild && electron-builder", + "package:lite": "pnpm run build:lite && node scripts/check-lite-deps.mjs && node scripts/prepare-lite-runtime.cjs && electron-builder --config electron-builder.lite.cjs --publish never && node scripts/check-lite-deps.mjs", + "package:lite:mac": "pnpm run build:lite && node scripts/check-lite-deps.mjs && node scripts/prepare-lite-runtime.cjs && electron-builder --config electron-builder.lite.cjs --mac dmg zip --arm64 --publish never && node scripts/check-lite-deps.mjs", + "test:lite-packaged-smoke": "pnpm run package:lite && node scripts/smoke/lite-app-smoke.mjs", "postinstall": "pnpm run rebuild:native", "rebuild": "pnpm run rebuild:native", "rebuild:sqlite": "electron-rebuild -f --only better-sqlite3", @@ -58,7 +63,7 @@ "test:security": "vitest run test/security test/services/SecureKeyService.test.ts test/services/ValidationService.test.ts test/services/ProjectSafetyService.test.ts", "test:smoke:core": "pnpm run test:smoke && pnpm run test:mcp-stress && pnpm run test:pro-entitlement", "test:smoke:ui": "pnpm run test:journeys && pnpm run test:responsive && pnpm run test:layout && pnpm run test:visual", - "test:ci": "pnpm install --frozen-lockfile --ignore-scripts && pnpm run typecheck && pnpm -r run typecheck && pnpm run lint:styles && pnpm run security:audit && pnpm run test:unit && pnpm run test:ui && pnpm run test:a11y && pnpm run test:keyboard && pnpm run test:solana && pnpm run test:security && pnpm run test:smoke", + "test:ci": "pnpm install --frozen-lockfile --ignore-scripts && pnpm run typecheck && pnpm -r run typecheck && pnpm run build:lite && node scripts/check-lite-deps.mjs && pnpm run lint:styles && pnpm run security:audit && pnpm run test:unit && pnpm run test:ui && pnpm run test:a11y && pnpm run test:keyboard && pnpm run test:solana && pnpm run test:security && pnpm run test:smoke", "test:release": "pnpm run release:check:v4:local", "test:all": "pnpm run test:ci && pnpm run test:smoke:core && pnpm run test:smoke:ui", "test:smoke": "pnpm run build && pnpm run rebuild && node scripts/smoke/electron-smoke.mjs", @@ -146,11 +151,12 @@ "@xterm/xterm": "^5.5.0", "axe-core": "^4.11.4", "buffer": "^6.0.3", - "electron": "^41.5.0", + "electron": "41.10.2", "electron-builder": "^26.8.1", "electron-builder-squirrel-windows": "^26.8.1", "happy-dom": "^20.8.9", "monaco-editor": "^0.55.1", + "obs-websocket-js": "^5.0.8", "patch-package": "^8.0.1", "pixelmatch": "^7.1.0", "playwright": "^1.59.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24c1968e..2caf5b4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -232,8 +232,8 @@ importers: specifier: ^6.0.3 version: 6.0.3 electron: - specifier: ^41.5.0 - version: 41.5.0 + specifier: 41.10.2 + version: 41.10.2 electron-builder: specifier: ^26.8.1 version: 26.8.1(electron-builder-squirrel-windows@26.8.1) @@ -246,6 +246,9 @@ importers: monaco-editor: specifier: ^0.55.1 version: 0.55.1 + obs-websocket-js: + specifier: ^5.0.8 + version: 5.0.8(bufferutil@4.1.0)(utf-8-validate@6.0.6) patch-package: specifier: ^8.0.1 version: 8.0.1 @@ -871,6 +874,10 @@ packages: resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==} engines: {node: '>= 8.9.0'} + '@electron-internal/extract-zip@1.0.4': + resolution: {integrity: sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==} + engines: {node: '>=22.12.0'} + '@electron/asar@3.4.1': resolution: {integrity: sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==} engines: {node: '>=10.12.0'} @@ -880,14 +887,14 @@ packages: resolution: {integrity: sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw==} hasBin: true - '@electron/get@2.0.3': - resolution: {integrity: sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ==} - engines: {node: '>=12'} - '@electron/get@3.1.0': resolution: {integrity: sha512-F+nKc0xW+kVbBRhFzaMgPy3KwmuNTYX1fx6+FxxoSnNgwYX6LD7AKBTWkU0MQ6IBoe7dz069CNkR673sPAgkCQ==} engines: {node: '>=14'} + '@electron/get@5.0.0': + resolution: {integrity: sha512-pjoBpru1KdEtcExBnuHAP1cAc/5faoedw0hzJkL3o4/IJp7HNF1+fbrdxT3gMYRX2oJfvnA/WXeCTVQpYYxyJA==} + engines: {node: '>=22.12.0'} + '@electron/notarize@2.2.1': resolution: {integrity: sha512-aL+bFMIkpR0cmmj5Zgy0LMKEpgy43/hw5zadEArgmAMWWlKc5buwFvFT9G/o/YJkvXAJm5q3iuTuLaiaXW39sg==} engines: {node: '>= 10.0.0'} @@ -1439,6 +1446,10 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@msgpack/msgpack@2.8.0': + resolution: {integrity: sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==} + engines: {node: '>= 10'} + '@msgpack/msgpack@3.1.3': resolution: {integrity: sha512-47XIizs9XZXvuJgoaJUIE2lFoID8ugvc0jzSHP+Ptfk8nTbnR8g788wv48N03Kx0UkAv559HWRQ3yzOgzlRNUA==} engines: {node: '>= 18'} @@ -3593,6 +3604,9 @@ packages: resolution: {integrity: sha512-lyAZ0EMyjDkVvz8WOeVnuCPvKVBXcMv1l5SVqO1yC7PzTwrD/pPje/BIRbWhMoPe436U+Y2nD7f5bFx0kt+Sbg==} engines: {node: '>=8'} + crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + css-in-js-utils@3.1.0: resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} @@ -3810,9 +3824,9 @@ packages: resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==} engines: {node: '>=8.0.0'} - electron@41.5.0: - resolution: {integrity: sha512-x9j9//PubUA4EjDtQbZhtk3prolandqCKgit0uCIqc1jb8FTskPbnJtxcDFB1aejczJcuERgjPixBUaMwoWyJg==} - engines: {node: '>= 12.20.55'} + electron@41.10.2: + resolution: {integrity: sha512-vzSetbn05LPfg0gW0p9txpqmkkFyTXzdSHvKIXbtmsK362hkgUQJu+x19GSSS9v/VXeFi5UJI5TYJ1a+Os3CpA==} + engines: {node: '>= 22.12.0'} hasBin: true emoji-regex@8.0.0: @@ -3848,6 +3862,10 @@ packages: resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} engines: {node: '>=6'} + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -4712,6 +4730,11 @@ packages: peerDependencies: ws: 8.21.0 + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: 8.21.0 + isows@1.0.7: resolution: {integrity: sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==} peerDependencies: @@ -5566,6 +5589,10 @@ packages: resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} engines: {node: '>= 0.4'} + obs-websocket-js@5.0.8: + resolution: {integrity: sha512-QDnQJMr5wuCoYugK02ggZ1/cvESs4KJDEK+UhGg0Ry35jnY8tK1Xr3KoPjKyoD6sd8G+WdtIdzuY+yUyPtogQQ==} + engines: {node: '>16.0'} + on-exit-leak-free@2.1.2: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} @@ -6736,6 +6763,10 @@ packages: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} + type-fest@3.13.1: + resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} + engines: {node: '>=14.16'} + type-fest@5.7.0: resolution: {integrity: sha512-1URUxUqfHFM1c+zfSPsa3gnkO7Aq21qyH75SIduNYz4SzY964rn1X2vCMQaHSHhktiw+0kPa2iyb6PUpXqB6Vg==} engines: {node: '>=20'} @@ -7826,6 +7857,8 @@ snapshots: ajv: 6.15.0 ajv-keywords: 3.5.2(ajv@6.15.0) + '@electron-internal/extract-zip@1.0.4': {} + '@electron/asar@3.4.1': dependencies: commander: 5.1.0 @@ -7838,7 +7871,7 @@ snapshots: fs-extra: 9.1.0 minimist: 1.2.8 - '@electron/get@2.0.3': + '@electron/get@3.1.0': dependencies: debug: 4.4.3 env-paths: 2.2.1 @@ -7852,17 +7885,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@electron/get@3.1.0': + '@electron/get@5.0.0': dependencies: debug: 4.4.3 - env-paths: 2.2.1 - fs-extra: 8.1.0 - got: 11.8.6 + env-paths: 3.0.0 + graceful-fs: 4.2.11 progress: 2.0.3 - semver: 6.3.1 + semver: 7.8.0 sumchecker: 3.0.1 optionalDependencies: - global-agent: 3.0.0 + undici: 6.27.0 transitivePeerDependencies: - supports-color @@ -8614,6 +8646,8 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) + '@msgpack/msgpack@2.8.0': {} + '@msgpack/msgpack@3.1.3': {} '@nirholas/pump-sdk@1.30.0(bufferutil@4.1.0)(encoding@0.1.13)(fastestsmallesttextencoderdecoder@1.0.22)(puppeteer-core@24.40.0(bufferutil@4.1.0)(utf-8-validate@6.0.6))(typescript@5.9.3)(utf-8-validate@6.0.6)': @@ -8728,7 +8762,7 @@ snapshots: extract-zip: 2.0.1 progress: 2.0.3 proxy-agent: 6.5.0 - semver: 7.7.4 + semver: 7.8.0 tar-fs: 3.1.2 yargs: 17.7.2 transitivePeerDependencies: @@ -10070,7 +10104,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/debug@4.1.13': dependencies: @@ -10183,7 +10217,7 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 25.5.0 + '@types/node': 25.6.0 '@types/serve-static@2.2.0': dependencies: @@ -10206,7 +10240,7 @@ snapshots: '@types/ws@7.4.7': dependencies: - '@types/node': 12.20.55 + '@types/node': 25.6.0 '@types/ws@8.18.1': dependencies: @@ -11129,6 +11163,8 @@ snapshots: crypto-hash@1.3.0: {} + crypto-js@4.2.0: {} + css-in-js-utils@3.1.0: dependencies: hyphenate-style-name: 1.1.0 @@ -11375,11 +11411,11 @@ snapshots: transitivePeerDependencies: - supports-color - electron@41.5.0: + electron@41.10.2: dependencies: - '@electron/get': 2.0.3 + '@electron-internal/extract-zip': 1.0.4 + '@electron/get': 5.0.0 '@types/node': 24.12.2 - extract-zip: 2.0.1 transitivePeerDependencies: - supports-color @@ -11406,6 +11442,8 @@ snapshots: env-paths@2.2.1: {} + env-paths@3.0.0: {} + environment@1.1.0: {} err-code@2.0.3: {} @@ -12040,7 +12078,7 @@ snapshots: es6-error: 4.1.1 matcher: 3.0.0 roarr: 2.15.4 - semver: 7.7.4 + semver: 7.8.0 serialize-error: 7.0.1 optional: true @@ -12411,6 +12449,10 @@ snapshots: dependencies: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + isomorphic-ws@5.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)): + dependencies: + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + isows@1.0.7(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)): dependencies: ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) @@ -13647,6 +13689,20 @@ snapshots: object-keys@1.1.1: {} + obs-websocket-js@5.0.8(bufferutil@4.1.0)(utf-8-validate@6.0.6): + dependencies: + '@msgpack/msgpack': 2.8.0 + crypto-js: 4.2.0 + debug: 4.4.3 + eventemitter3: 5.0.4 + isomorphic-ws: 5.0.0(ws@8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6)) + type-fest: 3.13.1 + ws: 8.21.0(bufferutil@4.1.0)(utf-8-validate@6.0.6) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + on-exit-leak-free@2.1.2: {} on-finished@2.3.0: @@ -15055,6 +15111,8 @@ snapshots: type-fest@0.7.1: {} + type-fest@3.13.1: {} + type-fest@5.7.0: dependencies: tagged-tag: 1.0.0 diff --git a/popout.html b/popout.html new file mode 100644 index 00000000..68d1fd9c --- /dev/null +++ b/popout.html @@ -0,0 +1,12 @@ + + + + + + Preview + + +
+ + + diff --git a/public/fonts/Geist-Variable.woff2 b/public/fonts/Geist-Variable.woff2 new file mode 100644 index 00000000..5c5999d6 Binary files /dev/null and b/public/fonts/Geist-Variable.woff2 differ diff --git a/scripts/check-lite-deps.mjs b/scripts/check-lite-deps.mjs new file mode 100644 index 00000000..7db6e1bb --- /dev/null +++ b/scripts/check-lite-deps.mjs @@ -0,0 +1,61 @@ +/** + * DAEMON Lite packaging gate. Fails the build when: + * 1. a banned heavy package appears in the lite runtime dependency closure + * (someone re-introduced an IDE/Solana import into the lite main graph), or + * 2. the built installer exceeds the size budget. + * + * Run after build:lite (closure check) and again after electron-builder + * (size check picks up the installer when present). + */ +import { createRequire } from 'node:module' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const require = createRequire(import.meta.url) +const { liteRuntimePackages } = require('./lite-deps.cjs') + +const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..') +// Lite now intentionally ships the native PTY and will bundle Monaco/xterm for +// its focused workbench. Keep headroom for that IDE substrate while continuing +// to reject unrelated full-DAEMON dependency growth. +const MAX_INSTALLER_BYTES = 160 * 1024 * 1024 + +const BANNED = [ + '@raydium-io/raydium-sdk-v2', + 'pyright', + 'typescript-language-server', + 'playwright', + 'puppeteer-core', + 'viem', + 'ethers', +] +const BANNED_PREFIXES = ['@metaplex-foundation/', '@meteora-ag/', '@playwright/'] + +const packages = liteRuntimePackages() +const banned = packages.filter( + (name) => BANNED.includes(name) || BANNED_PREFIXES.some((p) => name.startsWith(p)), +) + +if (banned.length > 0) { + console.error('[lite-gate] FAIL — banned packages in the Lite runtime closure:') + for (const name of banned) console.error(` - ${name}`) + console.error('A new import in the lite main graph is dragging these in. Sever it (module swap or lazy import).') + process.exit(1) +} + +console.log(`[lite-gate] closure ok — ${packages.length} runtime packages, none banned`) + +const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) +const installer = path.join(ROOT, 'release-lite', pkg.version, 'DAEMON-setup.exe') +if (fs.existsSync(installer)) { + const bytes = fs.statSync(installer).size + const mb = (bytes / 1024 / 1024).toFixed(1) + if (bytes > MAX_INSTALLER_BYTES) { + console.error(`[lite-gate] FAIL — installer ${mb} MB exceeds the ${MAX_INSTALLER_BYTES / 1024 / 1024} MB budget`) + process.exit(1) + } + console.log(`[lite-gate] installer ok — ${mb} MB`) +} else { + console.log('[lite-gate] installer not built yet — size check skipped') +} diff --git a/scripts/lite-deps.cjs b/scripts/lite-deps.cjs new file mode 100644 index 00000000..bd6b7241 --- /dev/null +++ b/scripts/lite-deps.cjs @@ -0,0 +1,108 @@ +/** + * DAEMON Lite dependency closure. Scans the built lite main/preload bundles + * for external module specifiers, then walks package.json dependencies + * (pnpm hoisted layout — every package is at node_modules/) to the + * full runtime closure for the Lite dependency and size gate. Packaging uses + * the isolated, locked runtime manifest under build/lite-runtime. + */ +const fs = require('node:fs') +const path = require('node:path') +const { builtinModules } = require('node:module') + +const ROOT = path.join(__dirname, '..') +const DIST = path.join(ROOT, 'dist-electron-lite') + +const SPECIFIER_PATTERNS = [ + /require\(\s*["']([^"']+)["']\s*\)/g, + /from\s*["']([^"']+)["']/g, + /import\(\s*["']([^"']+)["']\s*\)/g, + /import\s*["']([^"']+)["']/g, +] + +function isBuiltin(spec) { + const head = spec.startsWith('node:') ? spec.slice(5) : spec + return builtinModules.includes(head.split('/')[0]) +} + +function toPackageName(spec) { + return spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0] +} + +/** External package names imported by the built lite bundles. */ +function scanExternals(distDir = DIST) { + if (!fs.existsSync(distDir)) { + throw new Error(`lite bundles not built: ${distDir} missing — run build:lite first`) + } + const packages = new Set() + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const p = path.join(dir, entry.name) + if (entry.isDirectory()) { + walk(p) + } else if (/\.(js|mjs|cjs)$/.test(entry.name)) { + const code = fs.readFileSync(p, 'utf8') + for (const re of SPECIFIER_PATTERNS) { + re.lastIndex = 0 + let match + while ((match = re.exec(code))) { + const spec = match[1] + if (spec.startsWith('.') || isBuiltin(spec) || spec === 'electron') continue + // Regex over minified code can cross string boundaries — accept + // only plausible module specifiers. + if (!/^(@[\w.-]+\/)?[\w.-]+(\/[\w.-]+)*$/.test(spec)) continue + packages.add(toPackageName(spec)) + } + } + } + } + } + walk(distDir) + return packages +} + +/** BFS over dependencies + optionalDependencies from the given roots. */ +function dependencyClosure(roots) { + const seen = new Set() + const queue = [...roots] + while (queue.length > 0) { + const name = queue.shift() + if (seen.has(name)) continue + const pkgJsonPath = path.join(ROOT, 'node_modules', name, 'package.json') + if (!fs.existsSync(pkgJsonPath)) continue // optional dep not installed + seen.add(name) + const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')) + for (const dep of [ + ...Object.keys(pkg.dependencies ?? {}), + ...Object.keys(pkg.optionalDependencies ?? {}), + ]) { + if (dep !== 'electron') queue.push(dep) + } + } + return seen +} + +function liteRuntimePackages() { + return [...dependencyClosure([...scanExternals()])].sort() +} + +/** + * Packages electron-builder would auto-collect (the app's production + * dependency tree). files globs cannot ADD node_modules content — the walker + * collects every prod dep — so exclusion must be expressed as negations of + * this set minus the lite closure. + */ +function appProdPackages() { + const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')) + return dependencyClosure(Object.keys(pkg.dependencies ?? {})) +} + +/** Negation patterns for every collected package the Lite runtime never imports. */ +function liteExcludePatterns() { + const needed = new Set(liteRuntimePackages()) + return [...appProdPackages()] + .filter((name) => !needed.has(name)) + .sort() + .map((name) => `!node_modules/${name}/**`) +} + +module.exports = { scanExternals, dependencyClosure, liteRuntimePackages, appProdPackages, liteExcludePatterns } diff --git a/scripts/prepare-lite-runtime.cjs b/scripts/prepare-lite-runtime.cjs new file mode 100644 index 00000000..18ec216a --- /dev/null +++ b/scripts/prepare-lite-runtime.cjs @@ -0,0 +1,80 @@ +const { execFileSync } = require('node:child_process') +const fs = require('node:fs') +const path = require('node:path') +const { scanExternals } = require('./lite-deps.cjs') + +const ROOT = path.join(__dirname, '..') +const TEMPLATE_DIR = path.join(ROOT, 'build', 'lite-runtime') +const STAGE_DIR = path.join(ROOT, 'release-lite', '.stage') +const DIST_DIRS = ['dist-electron-lite', 'dist-lite'] + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')) +} + +function assertRuntimeRoots(runtimeManifest) { + const expected = Object.keys(runtimeManifest.dependencies ?? {}).sort() + const actual = [...scanExternals()].sort() + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Lite runtime roots changed. Expected ${expected.join(', ')}; found ${actual.join(', ')}`) + } + for (const name of expected) { + const installed = readJson(path.join(ROOT, 'node_modules', name, 'package.json')).version + if (runtimeManifest.dependencies[name] !== installed) { + throw new Error(`Lite runtime ${name} must be pinned to installed version ${installed}`) + } + } +} + +function run(command, args, cwd) { + execFileSync(command, args, { + cwd, + stdio: 'inherit', + shell: process.platform === 'win32', + }) +} + +function prepareStage() { + const rootManifest = readJson(path.join(ROOT, 'package.json')) + const runtimeManifest = readJson(path.join(TEMPLATE_DIR, 'package.json')) + assertRuntimeRoots(runtimeManifest) + + fs.rmSync(STAGE_DIR, { recursive: true, force: true }) + fs.mkdirSync(STAGE_DIR, { recursive: true }) + for (const file of ['package.json', 'pnpm-lock.yaml', '.npmrc']) { + fs.copyFileSync(path.join(TEMPLATE_DIR, file), path.join(STAGE_DIR, file)) + } + + const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' + run(pnpm, ['install', '--prod', '--frozen-lockfile', '--ignore-scripts', '--ignore-workspace'], STAGE_DIR) + + const electronRebuild = path.join(ROOT, 'node_modules', '.bin', process.platform === 'win32' ? 'electron-rebuild.cmd' : 'electron-rebuild') + const electronVersion = rootManifest.devDependencies.electron.replace(/^\^/, '') + run(electronRebuild, [ + '--force', + '--only', + 'better-sqlite3,node-pty', + '--module-dir', + STAGE_DIR, + '--version', + electronVersion, + '--arch', + process.arch, + ], ROOT) + + for (const dir of DIST_DIRS) { + fs.cpSync(path.join(ROOT, dir), path.join(STAGE_DIR, dir), { recursive: true }) + } + + fs.writeFileSync(path.join(STAGE_DIR, 'package.json'), `${JSON.stringify({ + ...runtimeManifest, + name: rootManifest.name, + version: rootManifest.version, + main: 'dist-electron-lite/main/lite.js', + description: rootManifest.description, + author: rootManifest.author, + license: rootManifest.license, + }, null, 2)}\n`) +} + +prepareStage() diff --git a/scripts/release-tools/verify-macos-artifacts.mjs b/scripts/release-tools/verify-macos-artifacts.mjs new file mode 100644 index 00000000..1ab61b69 --- /dev/null +++ b/scripts/release-tools/verify-macos-artifacts.mjs @@ -0,0 +1,71 @@ +import { createHash } from 'node:crypto' +import { existsSync, readFileSync, statSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +function macArtifacts(isUnsigned) { + const prefix = isUnsigned ? 'DAEMON-unsigned-arm64' : 'DAEMON-arm64' + return [`${prefix}.dmg`, `${prefix}.zip`] +} + +function fail(message) { + throw new Error(`[mac-release] ${message}`) +} + +function sha512(filePath) { + return createHash('sha512').update(readFileSync(filePath)).digest('base64') +} + +function metadataEntry(metadata, artifactName) { + const escapedName = artifactName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const match = metadata.match( + new RegExp(`- url: ${escapedName}\\r?\\n\\s+sha512: ([^\\r\\n]+)\\r?\\n\\s+size: (\\d+)`), + ) + if (!match) fail(`latest-mac.yml is missing ${artifactName}`) + return { sha512: match[1].trim(), size: Number(match[2]) } +} + +export function verifyMacRelease(releaseDir, expectedVersion, { isUnsigned = false } = {}) { + const artifacts = macArtifacts(isUnsigned) + const zipName = artifacts.find((name) => name.endsWith('.zip')) + const metadataPath = path.join(releaseDir, 'latest-mac.yml') + if (!existsSync(metadataPath)) fail(`missing ${metadataPath}`) + + const metadata = readFileSync(metadataPath, 'utf8') + if (!metadata.includes(`version: ${expectedVersion}`)) { + fail(`latest-mac.yml version does not match ${expectedVersion}`) + } + if (!metadata.includes(`path: ${zipName}`)) { + fail(`latest-mac.yml updater path is not ${zipName}`) + } + if (/DAEMON-(?:unsigned-)?(?:x64|universal)\.(?:dmg|zip)/.test(metadata)) { + fail('latest-mac.yml contains a non-arm64 artifact') + } + + for (const artifactName of artifacts) { + const artifactPath = path.join(releaseDir, artifactName) + const blockmapPath = `${artifactPath}.blockmap` + if (!existsSync(artifactPath)) fail(`missing ${artifactPath}`) + if (!existsSync(blockmapPath)) fail(`missing ${blockmapPath}`) + + const entry = metadataEntry(metadata, artifactName) + if (entry.size !== statSync(artifactPath).size) { + fail(`${artifactName} size does not match latest-mac.yml`) + } + if (entry.sha512 !== sha512(artifactPath)) { + fail(`${artifactName} sha512 does not match latest-mac.yml`) + } + } + + console.log(`[mac-release] verified ${expectedVersion} arm64 DMG, ZIP, blockmaps, and updater metadata`) +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) { + const releaseDir = path.resolve(process.argv[2] ?? '') + const expectedVersion = process.argv[3] + const flags = process.argv.slice(4) + if (!process.argv[2] || !expectedVersion || flags.some((flag) => flag !== '--unsigned') || flags.length > 1) { + fail('usage: verify-macos-artifacts.mjs [--unsigned]') + } + verifyMacRelease(releaseDir, expectedVersion, { isUnsigned: flags[0] === '--unsigned' }) +} diff --git a/scripts/smoke/lite-app-smoke.mjs b/scripts/smoke/lite-app-smoke.mjs new file mode 100644 index 00000000..39dd32c6 --- /dev/null +++ b/scripts/smoke/lite-app-smoke.mjs @@ -0,0 +1,140 @@ +/** + * DAEMON Lite packaged smoke: boot the packaged Lite exe with a fresh + * userData sandbox, assert onboarding renders, bypass it, assert the home + * shell mounts and aria:models round-trips. Mirrors packaged-app-smoke.mjs. + */ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import net from 'node:net' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(__dirname, '..', '..') +const pkg = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) + +function defaultPackagedExecutable() { + const releaseDir = path.join(repoRoot, 'release-lite', pkg.version) + if (process.platform === 'darwin') { + return path.join(releaseDir, 'mac-arm64', 'DAEMON.app', 'Contents', 'MacOS', 'DAEMON') + } + if (process.platform === 'linux') return path.join(releaseDir, 'linux-unpacked', 'DAEMON') + return path.join(releaseDir, 'win-unpacked', 'DAEMON.exe') +} + +const defaultExePath = defaultPackagedExecutable() +const packagedExe = process.env.DAEMON_PACKAGED_EXE || defaultExePath + +const sandboxRoot = mkdtempSync(path.join(tmpdir(), 'daemon-lite-smoke-')) +const userDataDir = path.join(sandboxRoot, 'userData') + +let appProcess +let browser + +function logStep(message) { + console.log(`[lite-smoke] ${message}`) +} + +function getFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + server.close(() => resolve(address.port)) + }) + }) +} + +function waitForPort(port, timeoutMs = 30000) { + const deadline = Date.now() + timeoutMs + return new Promise((resolve, reject) => { + const tryConnect = () => { + const socket = net.connect({ port, host: '127.0.0.1' }) + socket.once('connect', () => { socket.destroy(); resolve() }) + socket.once('error', () => { + socket.destroy() + if (Date.now() >= deadline) return reject(new Error(`Timed out waiting for port ${port}`)) + setTimeout(tryConnect, 250) + }) + } + tryConnect() + }) +} + +async function main() { + assert.ok(existsSync(packagedExe), `packaged Lite exe missing: ${packagedExe} — run package:lite first`) + const cdpPort = await getFreePort() + + logStep(`launching ${packagedExe}`) + appProcess = spawn(packagedExe, [], { + env: { + ...process.env, + NODE_OPTIONS: '', + DAEMON_SMOKE_TEST: '1', + DAEMON_SMOKE_CDP_PORT: String(cdpPort), + DAEMON_USER_DATA_DIR: userDataDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + detached: false, + }) + appProcess.stdout.on('data', (chunk) => process.stdout.write(chunk)) + appProcess.stderr.on('data', (chunk) => process.stderr.write(chunk)) + + await waitForPort(cdpPort) + browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`) + const context = browser.contexts()[0] + const page = context.pages().find((p) => p.url().includes('lite.html')) ?? context.pages()[0] + assert.ok(page, 'no renderer page found over CDP') + + logStep('waiting for onboarding') + await page.waitForSelector('text=Your AI coding agent.', { timeout: 30000 }) + + logStep('bypassing onboarding') + await page.evaluate(() => window.daemon.lite.setOnboardingComplete(true)) + await page.reload() + + logStep('waiting for home shell') + await page.waitForSelector('text=New chat', { timeout: 30000 }) + await page.waitForSelector('textarea', { timeout: 15000 }) + + logStep('checking aria:models round-trip') + const models = await page.evaluate(() => window.daemon.aria.models()) + assert.equal(models.ok, true, `aria:models failed: ${models.error ?? 'unknown'}`) + assert.ok(Array.isArray(models.data) && models.data.length > 0, 'aria:models returned no models') + + logStep('checking pop-out browser allowlist') + const disallowed = await page.evaluate(() => window.daemon.lite.popoutOpen('http://evil.example.com')) + assert.equal(disallowed.data?.opened, false, 'pop-out allowlist should reject remote http') + const allowed = await page.evaluate(() => window.daemon.lite.popoutOpen('https://example.com')) + assert.equal(allowed.data?.opened, true, 'pop-out should open an https URL') + + logStep('checking Tools section reveal + Scanner route') + // Start collapsed, then expand via the Tools header so the state is deterministic. + await page.evaluate(() => window.daemon.lite.setShowTools(false)) + await page.reload() + await page.click('text=Tools') + await page.click('text=Scanner') + await page.waitForSelector('text=/check authorities, snipers, and bundles/', { timeout: 15000 }) + + logStep(`PASS — onboarding, shell, ${models.data.length} models, pop-out, and tools verified`) +} + +main() + .then(() => process.exitCode = 0) + .catch((err) => { + console.error('[lite-smoke] FAIL:', err.message) + process.exitCode = 1 + }) + .finally(async () => { + try { await browser?.close() } catch { /* already closed */ } + try { appProcess?.kill() } catch { /* already dead */ } + setTimeout(() => { + try { rmSync(sandboxRoot, { recursive: true, force: true }) } catch { /* locked on Windows */ } + process.exit(process.exitCode ?? 0) + }, 1500) + }) diff --git a/scripts/smoke/lite-workbench-smoke.mjs b/scripts/smoke/lite-workbench-smoke.mjs new file mode 100644 index 00000000..9e724c5e --- /dev/null +++ b/scripts/smoke/lite-workbench-smoke.mjs @@ -0,0 +1,303 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import net from 'node:net' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from 'playwright' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const repoRoot = path.resolve(__dirname, '..', '..') +const pkg = JSON.parse(readFileSync(path.join(repoRoot, 'package.json'), 'utf8')) + +function defaultPackagedExecutable() { + const releaseDir = path.join(repoRoot, 'release-lite', pkg.version) + if (process.platform === 'darwin') { + return path.join(releaseDir, 'mac-arm64', 'DAEMON.app', 'Contents', 'MacOS', 'DAEMON') + } + if (process.platform === 'linux') return path.join(releaseDir, 'linux-unpacked', 'DAEMON') + return path.join(releaseDir, 'win-unpacked', 'DAEMON.exe') +} + +const defaultExePath = defaultPackagedExecutable() +const packagedExe = process.env.DAEMON_PACKAGED_EXE || defaultExePath +const sandboxRoot = mkdtempSync(path.join(tmpdir(), 'daemon-lite-workbench-')) +const userDataDir = path.join(sandboxRoot, 'userData') +const projectDir = path.join(sandboxRoot, 'solana-monitor') +const readmePath = path.join(projectDir, 'README.md') +const outputDir = path.join(repoRoot, 'output', 'playwright') +const savedMarker = '# Solana monitor\n\nSaved from packaged DAEMON Workbench.\n' +const shortcutModifier = process.platform === 'darwin' ? 'Meta' : 'Control' + +const terminalCommands = process.platform === 'win32' + ? { + ready: 'Write-Output ("__DAEMON_" + "TERMINAL_READY__")', + project: 'Write-Output ("__DAEMON_" + "PROJECT__" + (Get-Content .\\package.json | ConvertFrom-Json).name)', + node: 'node --version; Write-Output ("__DAEMON_" + "NODE_DONE__")', + compact: 'Write-Output ("__DAEMON_" + "COMPACT_READY__")', + } + : { + ready: "printf '%s\\n' '__DAEMON_''TERMINAL_READY__'", + project: `node -p '"__DAEMON_" + "PROJECT__" + require("./package.json").name'`, + node: "node --version; printf '%s\\n' '__DAEMON_''NODE_DONE__'", + compact: "printf '%s\\n' '__DAEMON_''COMPACT_READY__'", + } + +let appProcess +let browser +const rendererFailures = [] + +function logStep(message) { + console.log(`[lite-workbench-smoke] ${message}`) +} + +function createFixture() { + mkdirSync(path.join(projectDir, 'src'), { recursive: true }) + writeFileSync(readmePath, '# Solana monitor\n\nLocal devnet fixture.\n', 'utf8') + writeFileSync(path.join(projectDir, 'package.json'), JSON.stringify({ + name: 'solana-monitor', + private: true, + scripts: { test: 'node --test' }, + }, null, 2), 'utf8') + writeFileSync(path.join(projectDir, 'src', 'index.js'), "console.log('devnet')\n", 'utf8') +} + +function getFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer() + server.unref() + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + server.close(() => reject(new Error('Unable to allocate a CDP port'))) + return + } + server.close(() => resolve(address.port)) + }) + }) +} + +function waitForPort(port, timeoutMs = 30_000) { + const deadline = Date.now() + timeoutMs + return new Promise((resolve, reject) => { + const connect = () => { + const socket = net.connect({ port, host: '127.0.0.1' }) + socket.once('connect', () => { socket.destroy(); resolve() }) + socket.once('error', () => { + socket.destroy() + if (Date.now() >= deadline) { + reject(new Error(`Timed out waiting for CDP port ${port}`)) + return + } + setTimeout(connect, 250) + }) + } + connect() + }) +} + +function attachDiagnostics(page) { + page.on('pageerror', (error) => rendererFailures.push(`pageerror: ${error.message}`)) + page.on('console', (message) => { + if (message.type() === 'error') rendererFailures.push(`console: ${message.text()}`) + }) +} + +async function getLitePage() { + const deadline = Date.now() + 30_000 + while (Date.now() < deadline) { + const context = browser?.contexts()?.[0] + const page = context?.pages().find((candidate) => candidate.url().includes('lite.html')) + if (page) return page + await new Promise((resolve) => setTimeout(resolve, 250)) + } + throw new Error('Timed out waiting for the DAEMON renderer') +} + +async function waitForFile(expected, timeoutMs = 10_000) { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (readFileSync(readmePath, 'utf8') === expected) return + await new Promise((resolve) => setTimeout(resolve, 100)) + } + assert.equal(readFileSync(readmePath, 'utf8'), expected, 'editor shortcut did not save the edited README') +} + +async function waitForTerminalText(page, expected, timeout = 60_000) { + await page.waitForFunction( + (needle) => document.querySelector('.xterm-rows')?.textContent?.includes(needle), + expected, + { timeout }, + ) +} + +async function runTerminalCommand(page, command, completionMarker) { + const input = page.locator('.xterm-helper-textarea').first() + await input.focus() + await page.keyboard.type(command) + await page.keyboard.press('Enter') + await waitForTerminalText(page, completionMarker) + return page.locator('.xterm-rows').first().textContent() +} + +async function run() { + assert.ok(existsSync(packagedExe), `packaged Lite executable missing: ${packagedExe}`) + createFixture() + mkdirSync(outputDir, { recursive: true }) + const cdpPort = await getFreePort() + + logStep(`launching ${packagedExe}`) + appProcess = spawn(packagedExe, [], { + cwd: repoRoot, + env: { + ...process.env, + NODE_OPTIONS: '', + DAEMON_SMOKE_TEST: '1', + DAEMON_SMOKE_CDP_PORT: String(cdpPort), + DAEMON_SMOKE_PROJECT_DIALOG_PATH: projectDir, + DAEMON_USER_DATA_DIR: userDataDir, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + appProcess.stdout.on('data', (chunk) => process.stdout.write(chunk)) + appProcess.stderr.on('data', (chunk) => process.stderr.write(chunk)) + + await waitForPort(cdpPort) + browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`) + const page = await getLitePage() + attachDiagnostics(page) + + logStep('bypassing first-run provider setup') + await page.waitForSelector('text=Your AI coding agent.', { timeout: 30_000 }) + await page.evaluate(() => window.daemon.lite.setOnboardingComplete(true)) + await page.reload() + + logStep('importing the isolated project fixture') + await page.getByRole('button', { name: 'New chat' }).waitFor({ timeout: 30_000 }) + await page.getByRole('button', { name: 'Open project', exact: true }).click() + await page.getByRole('button', { name: path.basename(projectDir), exact: true }).waitFor({ timeout: 20_000 }) + await page.setViewportSize({ width: 1440, height: 900 }) + await page.screenshot({ path: path.join(outputDir, 'lite-simple-chat-desktop.png'), fullPage: true }) + await page.setViewportSize({ width: 820, height: 720 }) + await page.waitForTimeout(500) + await page.screenshot({ path: path.join(outputDir, 'lite-simple-chat-compact.png'), fullPage: true }) + await page.setViewportSize({ width: 1440, height: 900 }) + await page.getByRole('button', { name: 'Terminal', exact: true }).click() + await page.getByText('GUARDED WORKFLOWS: LOCALNET / DEVNET', { exact: true }).waitFor() + assert.equal(await page.getByRole('button', { name: 'Build', exact: true }).isDisabled(), true, 'non-Solana project must not enable Anchor build') + assert.equal(await page.getByRole('button', { name: 'Local validator', exact: true }).isDisabled(), true, 'non-Solana project must not enable validator') + + logStep('opening and saving README.md') + await page.getByRole('button', { name: 'Code', exact: true }).click() + await page.getByRole('treeitem', { name: /README.md/ }).click() + await page.getByRole('region', { name: 'Code editor' }).waitFor() + const editorSurface = page.locator('.monaco-editor .view-lines').first() + await editorSurface.click() + await page.keyboard.press(`${shortcutModifier}+A`) + await page.keyboard.type(savedMarker) + await page.getByRole('button', { name: 'Save' }).waitFor({ state: 'visible' }) + await page.waitForFunction(() => { + const save = Array.from(document.querySelectorAll('button')).find((button) => button.textContent?.trim() === 'Save') + return save instanceof HTMLButtonElement && !save.disabled + }) + await page.keyboard.press(`${shortcutModifier}+S`) + await waitForFile(savedMarker) + await page.screenshot({ path: path.join(outputDir, 'lite-simple-code-desktop.png'), fullPage: true }) + await page.setViewportSize({ width: 820, height: 720 }) + await page.waitForTimeout(500) + await page.screenshot({ path: path.join(outputDir, 'lite-simple-code-compact.png'), fullPage: true }) + await page.setViewportSize({ width: 1440, height: 900 }) + + logStep('creating a project terminal and running Node') + await page.getByRole('button', { name: 'Terminal', exact: true }).click() + await page.getByRole('button', { name: 'New terminal' }).click() + await page.locator('.xterm-helper-textarea').first().waitFor({ timeout: 60_000 }) + await runTerminalCommand(page, terminalCommands.ready, '__DAEMON_TERMINAL_READY__') + const projectOutput = await runTerminalCommand( + page, + terminalCommands.project, + '__DAEMON_PROJECT__', + ) + assert.ok(projectOutput?.includes('__DAEMON_PROJECT__solana-monitor'), 'terminal did not resolve the imported project fixture') + const nodeOutput = await runTerminalCommand( + page, + terminalCommands.node, + '__DAEMON_NODE_DONE__', + ) + assert.match(nodeOutput ?? '', /v\d+\.\d+\.\d+__DAEMON_NODE_DONE__/, 'terminal did not report a Node.js version') + await page.screenshot({ path: path.join(outputDir, 'lite-simple-terminal-desktop.png'), fullPage: true }) + await page.setViewportSize({ width: 820, height: 720 }) + await page.waitForTimeout(500) + await runTerminalCommand( + page, + terminalCommands.compact, + '__DAEMON_COMPACT_READY__', + ) + await page.screenshot({ path: path.join(outputDir, 'lite-simple-terminal-compact.png'), fullPage: true }) + await page.setViewportSize({ width: 1440, height: 900 }) + + logStep('opening Meme Tech Studio and inspecting live benchmark evidence') + await page.getByRole('button', { name: 'Meme Tech', exact: true }).click() + await page.getByRole('region', { name: 'Meme Tech workspace' }).waitFor() + await page.getByText('Unknown', { exact: true }).first().waitFor() + if (process.env.BIRDEYE_API_KEY) { + await page.getByRole('button', { name: 'Inspect', exact: true }).click() + await page.getByText('PROVIDER RISK EVIDENCE', { exact: true }).waitFor({ timeout: 30_000 }) + await page.getByText(/Birdeye \+ DEX cross-check|degraded data/).waitFor({ timeout: 30_000 }) + } else { + await page.getByRole('button', { name: 'Inspect', exact: true }).click() + await page.getByRole('alert').getByText(/Birdeye is not configured/).waitFor({ timeout: 30_000 }) + await page.getByText('degraded data', { exact: true }).waitFor({ timeout: 30_000 }) + } + + logStep('capturing desktop and compact Meme Tech Studio states') + await page.setViewportSize({ width: 1440, height: 900 }) + await page.screenshot({ path: path.join(outputDir, 'lite-meme-tech-desktop.png'), fullPage: true }) + await page.setViewportSize({ width: 820, height: 720 }) + await page.waitForTimeout(500) + await page.screenshot({ path: path.join(outputDir, 'lite-meme-tech-compact.png'), fullPage: true }) + + logStep('capturing the canonical Wallet master-detail layout') + await page.getByRole('button', { name: 'Tools', exact: true }).click() + await page.getByRole('button', { name: 'Wallet', exact: true }).click() + await page.getByText('Addresses stay watch-only.', { exact: false }).waitFor() + await page.getByText('Loading…', { exact: true }).waitFor({ state: 'detached' }) + assert.equal(await page.getByRole('button', { name: 'Open DAEMON IDE' }).count(), 0, 'canonical app must not expose a dead legacy handoff') + await page.setViewportSize({ width: 1440, height: 900 }) + await page.screenshot({ path: path.join(outputDir, 'daemon-wallet-desktop.png'), fullPage: true }) + await page.setViewportSize({ width: 820, height: 720 }) + await page.waitForTimeout(500) + await page.screenshot({ path: path.join(outputDir, 'daemon-wallet-compact.png'), fullPage: true }) + + assert.equal(rendererFailures.length, 0, `renderer failures detected:\n${rendererFailures.join('\n')}`) + logStep('PASS: import, repo-aware guards, edit/save, scoped terminal, Meme Tech and Wallet layouts, screenshots, and renderer diagnostics') +} + +try { + await run() +} finally { + await browser?.close().catch(() => {}) + if (appProcess && appProcess.exitCode === null) { + appProcess.kill('SIGTERM') + await new Promise((resolve) => { + const timer = setTimeout(() => { + appProcess.kill('SIGKILL') + resolve() + }, 5000) + appProcess.once('exit', () => { + clearTimeout(timer) + resolve() + }) + }) + } + rmSync(sandboxRoot, { recursive: true, force: true }) +} diff --git a/src/lib/ariaUiEffects.ts b/src/lib/ariaUiEffects.ts index 0bb97fd1..f555fd8e 100644 --- a/src/lib/ariaUiEffects.ts +++ b/src/lib/ariaUiEffects.ts @@ -6,6 +6,7 @@ import type { AriaUiEffect } from '../../electron/shared/types' import { useUIStore } from '../store/ui' import { useWorkflowShellStore } from '../store/workflowShell' +import { useBrowserStore } from '../store/browser' const INTEGRATION_ENABLE_STORAGE_KEY = 'daemon:integration-command-center:enabled' @@ -110,11 +111,53 @@ export function applyUiEffect(effect: AriaUiEffect): void { // full context; headless execution needs the ICC's IntegrationContext. useUIStore.getState().openWorkspaceTool('integrations') break + case 'open_preview': + // Load a localhost dev-server URL in the embedded browser (BrowserMode). + // Loopback is allowlisted by the webview security guard; remote http is not. + useBrowserStore.getState().setUrl(effect.url) + useUIStore.getState().openBrowserTab() + break + case 'start_dev_server': + // Fire-and-forget path: kick off the dev server without awaiting the id. + // The two-phase path (runUiEffectWithData) is preferred; it returns the port. + void startDevServer(effect) + break + case 'open_scaffold': + // Preselect the template + name, then open the ProjectStarter wizard. + useUIStore.getState().setScaffoldPreset({ templateId: effect.templateId, projectName: effect.projectName }) + useUIStore.getState().openWorkspaceTool('starter') + break } } +/** + * Create a PTY terminal that runs the discovered dev command, register its port, + * and add it to the terminal store — the same flow ProjectStarter uses for the + * meme site / game preview. Returns the created terminal id + preview url. + */ +async function startDevServer(effect: Extract): Promise<{ ok: boolean; terminalId?: string; url?: string; error?: string }> { + const ui = useUIStore.getState() + const activeProjectId = ui.activeProjectId + if (!activeProjectId) return { ok: false, error: 'No active project.' } + const startupCommand = `${effect.command} -- --host 127.0.0.1 --port ${effect.port}` + const res = await window.daemon.terminal.create({ + cwd: effect.projectPath, + startupCommand, + userInitiated: true, + }) + if (!res.ok || !res.data) return { ok: false, error: res.error ?? 'Failed to start dev server terminal.' } + ui.addTerminal(activeProjectId, res.data.id, effect.label, null) + await window.daemon.ports.register(effect.port, activeProjectId, effect.label) + ui.setCenterMode('canvas') + return { ok: true, terminalId: res.data.id, url: `http://127.0.0.1:${effect.port}` } +} + /** Apply a two-phase effect and return data for the tool_result. */ export async function runUiEffectWithData(effect: AriaUiEffect): Promise { + if (effect.type === 'start_dev_server') { + // Await terminal creation so the tool_result carries the real port/url + status. + return startDevServer(effect) + } applyUiEffect(effect) if (effect.type === 'run_integration') { return { opened: 'integrations', actionId: effect.actionId, note: 'Opened Integrations — run the check there.' } diff --git a/src/lite/LiteApp.module.css b/src/lite/LiteApp.module.css new file mode 100644 index 00000000..4e84f91d --- /dev/null +++ b/src/lite/LiteApp.module.css @@ -0,0 +1,50 @@ +.app { + display: flex; + flex-direction: column; + height: 100vh; + background: var(--bg-app); + color: var(--t1); + font-family: var(--font-ui); + overflow: hidden; +} + +/* Draggable strip under the hidden native titlebar — blends with the app so + the window controls sit on the same dark surface (Cursor-style). */ +.titlebar { + height: 34px; + flex-shrink: 0; + display: flex; + align-items: center; + padding: 0 14px; + -webkit-app-region: drag; + user-select: none; +} + +.titlebarText { + font-size: var(--fs-11); + color: var(--t4); + letter-spacing: 0.04em; +} + +.body { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: 248px 1fr; +} + +.main { + position: relative; + display: flex; + flex-direction: column; + min-width: 0; + container-type: inline-size; + background: var(--bg-workspace); + overflow: hidden; +} + +@media (max-width: 640px) { + .body { + grid-template-columns: 60px minmax(0, 1fr); + } +} diff --git a/src/lite/LiteApp.tsx b/src/lite/LiteApp.tsx new file mode 100644 index 00000000..9a13d586 --- /dev/null +++ b/src/lite/LiteApp.tsx @@ -0,0 +1,115 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { useAriaStore } from '../store/aria' +import { LiteSidebar } from './LiteSidebar' +import { LiteSettings } from './LiteSettings' +import { LiteOnboarding } from './LiteOnboarding' +import { LiteWallet } from './wallet/LiteWallet' +import { LiteTrade } from './trade/LiteTrade' +import { LiteScanner } from './scanner/LiteScanner' +import { LiteWorkbench } from './workbench/LiteWorkbench' +import '../panels/AgentWorkbench/AgentWorkbench.css' +import styles from './LiteApp.module.css' + +export type LiteView = 'workspace' | 'settings' | 'wallet' | 'trade' | 'scanner' + +export default function LiteApp() { + const [onboarded, setOnboarded] = useState(null) + const [view, setView] = useState('workspace') + const [showTools, setShowTools] = useState(false) + const [draft, setDraft] = useState('') + const [conversationResetKey, setConversationResetKey] = useState(0) + const mainRef = useRef(null) + + useEffect(() => { + void window.daemon.lite.isOnboardingComplete().then((res) => { + setOnboarded(res.ok ? Boolean(res.data) : false) + }) + void window.daemon.lite.getShowTools().then((res) => { + if (res.ok) setShowTools(Boolean(res.data)) + }) + }, []) + + useEffect(() => { + if (!onboarded) return + const store = useAriaStore.getState() + const unsubscribe = store.subscribe() + void store.initSessions() + void store.loadModels() + void store.loadProviderStatus() + return unsubscribe + }, [onboarded]) + + const focusComposer = useCallback(() => { + // The shared Composer exposes no input ref; the shell owns one textarea. + requestAnimationFrame(() => { + mainRef.current?.querySelector('textarea')?.focus() + }) + }, []) + + const prefillDraft = useCallback((text: string) => { + setView('workspace') + setDraft(text) + focusComposer() + }, [focusComposer]) + + const sendDraft = useCallback(() => { + const content = draft.trim() + if (!content) return + setDraft('') + void useAriaStore.getState().sendMessage(content) + }, [draft]) + + const toggleTools = useCallback(() => { + setShowTools((prev) => { + const next = !prev + void window.daemon.lite.setShowTools(next) + return next + }) + }, []) + + if (onboarded === null) return null + + return ( +
+
+ Daemon +
+ {!onboarded ? ( + setOnboarded(true)} /> + ) : ( +
+ { + setView('workspace') + setDraft('') + setConversationResetKey((value) => value + 1) + void useAriaStore.getState().newChat() + focusComposer() + }} + onSelectView={setView} + onOpenSettings={() => setView(view === 'settings' ? 'workspace' : 'settings')} + onPickSession={() => { setView('workspace'); setConversationResetKey((value) => value + 1) }} + /> +
+ {view === 'workspace' ? ( + + ) : view === 'settings' ? ( + setView('workspace')} /> + ) : view === 'wallet' ? ( + + ) : view === 'trade' ? ( + + ) : view === 'scanner' ? ( + + ) : ( + + )} +
+
+ )} +
+ ) +} diff --git a/src/lite/LiteChat.module.css b/src/lite/LiteChat.module.css new file mode 100644 index 00000000..dfa1b0d8 --- /dev/null +++ b/src/lite/LiteChat.module.css @@ -0,0 +1,27 @@ +.chat { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 20px 24px 8px; +} + +.thread { + max-width: 760px; + margin: 0 auto; +} + +.dock { + padding: 8px 24px 16px; +} + +.composer { + max-width: 760px; + margin: 0 auto; +} diff --git a/src/lite/LiteChat.tsx b/src/lite/LiteChat.tsx new file mode 100644 index 00000000..4aa4ff76 --- /dev/null +++ b/src/lite/LiteChat.tsx @@ -0,0 +1,41 @@ +import { useRef } from 'react' +import { useAriaStore } from '../store/aria' +import { AgentTranscript } from '../panels/AgentWorkbench/AgentTranscript' +import { useStickyScroll } from '../hooks/useStickyScroll' +import { LiteComposer } from './LiteComposer' +import styles from './LiteChat.module.css' + +interface LiteChatProps { + draft: string + onDraftChange: (value: string) => void + onSend: () => void +} + +export function LiteChat({ draft, onDraftChange, onSend }: LiteChatProps) { + const turns = useAriaStore((s) => s.turns) + const isLoading = useAriaStore((s) => s.isLoading) + const scrollRef = useRef(null) + useStickyScroll(scrollRef, [turns, isLoading]) + + return ( +
+
+
+ +
+
+
+
+ +
+
+
+ ) +} diff --git a/src/lite/LiteComposer.module.css b/src/lite/LiteComposer.module.css new file mode 100644 index 00000000..3004875f --- /dev/null +++ b/src/lite/LiteComposer.module.css @@ -0,0 +1,86 @@ +.composer { + display: flex; + flex-direction: column; + gap: 4px; + padding: 11px 12px 8px; + background: var(--s1); + border: 1px solid var(--line); + border-radius: 12px; + transition: border-color 120ms ease; +} + +.composer:focus-within { + border-color: var(--line-2); +} + +.input { + width: 100%; + min-height: 38px; + max-height: 200px; + resize: none; + border: none; + background: transparent; + color: var(--t1); + font-family: var(--font-ui); + font-size: var(--fs-13); + line-height: 1.5; +} + +.input::placeholder { + color: var(--t4); +} + +.input:focus, +.input:focus-visible { + outline: none; + box-shadow: none; +} + +.row { + display: flex; + align-items: center; + gap: 8px; +} + +.modeChip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 3px 8px; + border-radius: 999px; + background: var(--accent-green-glow); + color: var(--accent-green); + font-size: var(--fs-10); + font-weight: 600; +} + +.model { + color: var(--t3); +} + +.spacer { + flex: 1; +} + +.send { + display: inline-flex; + align-items: center; + justify-content: center; + width: 23px; + height: 23px; + border: none; + border-radius: 50%; + background: var(--accent-green); + color: #06110c; + cursor: pointer; + transition: opacity 120ms ease; +} + +.send:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent-green) 88%, white); +} + +.send:disabled { + opacity: 0.35; + cursor: default; +} diff --git a/src/lite/LiteComposer.tsx b/src/lite/LiteComposer.tsx new file mode 100644 index 00000000..62260698 --- /dev/null +++ b/src/lite/LiteComposer.tsx @@ -0,0 +1,61 @@ +import { useEffect, useRef } from 'react' +import type { KeyboardEvent } from 'react' +import { ArrowUp } from '@phosphor-icons/react' +import { ModelDropdown } from '../components/Panel' +import styles from './LiteComposer.module.css' + +interface LiteComposerProps { + value: string + onChange: (value: string) => void + onSend: () => void + placeholder?: string + ariaLabel?: string + disabled?: boolean + autoFocus?: boolean +} + +/** Cursor-style composer: large rounded card, textarea on top, mode chip + + * model picker + send arrow on the bottom row. */ +export function LiteComposer({ value, onChange, onSend, placeholder, ariaLabel = 'Message ARIA', disabled, autoFocus }: LiteComposerProps) { + const inputRef = useRef(null) + + useEffect(() => { + if (autoFocus) inputRef.current?.focus() + }, [autoFocus]) + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' || event.shiftKey) return + event.preventDefault() + if (!disabled && value.trim()) onSend() + } + + return ( +
+